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:
Yvv
2026-10-21 04:54:30 +02:00
co-authored by Claude Opus 5.5
parent 94907afb20
commit ac9c904e7e
11 changed files with 152 additions and 28 deletions
+51 -11
View File
@@ -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]
+20 -7
View File
@@ -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");
}
+12
View File
@@ -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();