v3 : seconde relecture — une coquille ne touche ni chiffre, ni signe, ni relation ; retour arrière du déploiement
- Correction de coquille : un chiffre, un signe, un séparateur dans un nombre ou une relation changés (« >= » → « <= », « + » → « - », « 182.625 » → « 182 625 », « % » → « ‰ ») rendent la modification « de fond ». Testé dans le moteur et par l'API sur le vrai texte K1. - Déploiement : les conteneurs retirés de la racine qui tournaient sont notés ; si la suite échoue, l'étape « retablir » arrête le produit et les relance. Le produit est recréé à chaque déploiement (--force-recreate). - Cookies préfixés en https : __Host-ld_session, __Secure-ld_oauth (pas de cookie planté par un site voisin sous librodrome.org). - syft épinglé (v1.55.0) dans les deux pipelines : ce conteneur a le socket Docker. - Docs : limites connues assumées, groupe d'essai à deux voix, error.identity_kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no number, no negation
|
||||
//! touched). The votes stay; the correction is shown with its diff.
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
|
||||
//! never a figure, a sign, a separator inside a number or a relation
|
||||
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
@@ -48,9 +49,32 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// The numbers of a text, as written: "50" and "5,0", "1 5" and "15" differ.
|
||||
fn numbers(s: &str) -> Vec<&str> {
|
||||
s.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect()
|
||||
/// Symbols that carry a value or a relation, wherever they stand.
|
||||
const RELATIONS: &[char] =
|
||||
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
|
||||
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
|
||||
const SIGNS: &[char] = &['-', '−', '–', '/'];
|
||||
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
|
||||
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
|
||||
|
||||
/// What the text says in figures and symbols, as written. Two texts with the
|
||||
/// same letters but a different figure, sign, separator inside a number or
|
||||
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
|
||||
/// say the same thing.
|
||||
fn figures(s: &str) -> String {
|
||||
let cs: Vec<char> = s.chars().collect();
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
cs.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, c)| {
|
||||
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
|
||||
c.is_numeric()
|
||||
|| RELATIONS.contains(c)
|
||||
|| (SIGNS.contains(c) && (before || after))
|
||||
|| (SEPARATORS.contains(c) && before && after)
|
||||
})
|
||||
.map(|(_, c)| *c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folded words (runs of letters/digits).
|
||||
@@ -62,8 +86,8 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
if old == new {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
// A number is never a typo: punctuation or a space inside it changes its value.
|
||||
if numbers(old) != numbers(new) {
|
||||
// A figure, a sign or a relation is never a typo: it changes what the text says.
|
||||
if figures(old) != figures(new) {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
@@ -208,11 +232,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_is_never_a_typo() {
|
||||
// Same letters and digits, different values (independent review, 2026-10-21).
|
||||
assert_eq!(classify("au moins 50 voix", "au moins 5,0 voix"), EditKind::Substantive);
|
||||
assert_eq!(classify("1 5 jours", "15 jours"), EditKind::Substantive);
|
||||
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
|
||||
// Same letters, different meaning (independent reviews, 2026-10-21).
|
||||
for (old, new) in [
|
||||
("au moins 50 voix", "au moins 5,0 voix"),
|
||||
("1 5 jours", "15 jours"),
|
||||
("10 000 membres", "10,000 membres"),
|
||||
("un écart de -5", "un écart de 5"),
|
||||
("1/5 des voix", "1.5 des voix"),
|
||||
("50 % des voix", "50 ‰ des voix"),
|
||||
("avoir >= 5 certifications", "avoir <= 5 certifications"),
|
||||
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
|
||||
("182.625 jours", "182 625 jours"),
|
||||
("c²", "c2"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
// Punctuation around figures, list markers, dashes between words stay typographic.
|
||||
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
|
||||
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
|
||||
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Typographic);
|
||||
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,17 @@ use trustin_access::{Identity, IdentityKind};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "ld_session";
|
||||
const OAUTH_COOKIE: &str = "ld_oauth";
|
||||
|
||||
/// Over https, the prefixed names: a neighbouring site under the same parent
|
||||
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
|
||||
/// `__Secure-` one without https.
|
||||
fn session_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
|
||||
}
|
||||
|
||||
fn oauth_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
|
||||
}
|
||||
const SESSION_DAYS: i64 = 30;
|
||||
const INVITATION_DAYS: i64 = 14;
|
||||
|
||||
@@ -83,7 +94,7 @@ impl FromRequestParts<AppState> for Viewer {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let Some(token) = cookie_value(&parts.headers, SESSION_COOKIE) else {
|
||||
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
|
||||
return Ok(Self(None));
|
||||
};
|
||||
let row = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
@@ -124,7 +135,7 @@ async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderVa
|
||||
.bind(identity_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(cookie(state, SESSION_COOKIE, &token, "/", SESSION_DAYS * 86_400))
|
||||
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
|
||||
}
|
||||
|
||||
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
|
||||
@@ -212,7 +223,7 @@ pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>
|
||||
.append_pair("redirect_uri", &state.cfg.connect_callback())
|
||||
.append_pair("state", &csrf);
|
||||
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
|
||||
Ok(redirect(url.as_str(), vec![cookie(&state, OAUTH_COOKIE, &stash, "/api/auth/g1", 600)]))
|
||||
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -243,8 +254,8 @@ pub async fn g1_callback(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
) -> Response {
|
||||
let clear = cookie(&state, OAUTH_COOKIE, "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, OAUTH_COOKIE).unwrap_or_default();
|
||||
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
|
||||
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
|
||||
let back =
|
||||
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
|
||||
@@ -460,14 +471,14 @@ pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>)
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
if let Some(token) = cookie_value(&headers, SESSION_COOKIE) {
|
||||
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
|
||||
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||
.bind(util::sha256_hex(&token))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
let mut res = Json(json!({ "me": null })).into_response();
|
||||
res.headers_mut().append(SET_COOKIE, cookie(&state, SESSION_COOKIE, "", "/", 0));
|
||||
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -522,6 +533,8 @@ mod tests {
|
||||
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(None), "/groupes");
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,18 @@ async fn editing_is_not_voting() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
assert_eq!(v["error"], "substantive_in_force");
|
||||
// Flipping a relation in a seed text is not a typo, even with the same letters.
|
||||
let k1 = unit(&doc, "K1")["proposals"][0].clone();
|
||||
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
|
||||
Some(json!({ "title": k1["title"], "body": flipped })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||
assert_eq!(v["error"], "substantive_not_author");
|
||||
|
||||
// A member proposal: typo keeps votes, substance asks to reconfirm.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
|
||||
Reference in New Issue
Block a user