From d24a09074ab7be2edcb0f676f285c731bbde9e77 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:13:44 -0400 Subject: [PATCH] goblin: polish FR stale label + fix raw-key leaks on this branch FR wallets.tx_stale was 'En attente trop longtemps' (noun + adverb, not grammatical); now 'En attente trop longue', adjective agreeing with attente. Other five locales for tx_stale / receipt.stale_note read natural, left as-is. Also carries the two raw-key leak fixes (goblin.receipt.copied -> goblin.receive.copied, wallets.canceled -> wallets.tx_canceled) and the every_t_call_site_key_exists_in_en guard so this branch is clean standalone. --- locales/fr.yml | 2 +- src/gui/views/goblin/mod.rs | 2 +- src/gui/views/wallets/wallet/txs/content.rs | 2 +- tests/i18n_keys.rs | 103 ++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/locales/fr.yml b/locales/fr.yml index 88bac7c0..0434fde1 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -95,7 +95,7 @@ wallets: tx_finalizing: Finalisation tx_posting: Publication tx_confirmed: Confirmé - tx_stale: En attente trop longtemps + tx_stale: En attente trop longue txs: Transactions tx: Transaction messages: Messages diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index e5fb6bc8..f03e5a67 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -3503,7 +3503,7 @@ impl GoblinWalletView { RichText::new(format!( "{} {}", crate::gui::icons::CHECK, - t!("goblin.receipt.copied") + t!("goblin.receive.copied") )) .font(FontId::new(13.0, fonts::medium())) .color(t.pos), diff --git a/src/gui/views/wallets/wallet/txs/content.rs b/src/gui/views/wallets/wallet/txs/content.rs index 5c1a252c..020c6d7e 100644 --- a/src/gui/views/wallets/wallet/txs/content.rs +++ b/src/gui/views/wallets/wallet/txs/content.rs @@ -572,7 +572,7 @@ impl WalletTransactionsContent { ) } } - _ => format!("{} {}", X_CIRCLE, t!("wallets.canceled")), + _ => format!("{} {}", X_CIRCLE, t!("wallets.tx_canceled")), } }; diff --git a/tests/i18n_keys.rs b/tests/i18n_keys.rs index 6bc70c30..bfbd89ed 100644 --- a/tests/i18n_keys.rs +++ b/tests/i18n_keys.rs @@ -123,3 +123,106 @@ fn every_locale_has_all_goblin_keys() { problems.join("\n") ); } + +/// Every `t!("literal.key")` in the source must resolve to a key present in +/// en.yml. Guards against raw-key leaks (a call site typing a key that was +/// never added, or renamed out from under it) that render the dotted key +/// verbatim in the UI. The cross-locale check above only compares locale files +/// to each other; it cannot see the call sites. +#[test] +fn every_t_call_site_key_exists_in_en() { + // Full en.yml, all namespaces (t! is used for goblin.*, wallets.*, network.*, …). + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("locales") + .join("en.yml"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("invalid YAML in en.yml"); + let mut en = BTreeMap::new(); + flatten(&doc, "", &mut en); + let en_keys: BTreeSet<&String> = en.keys().collect(); + + // A localization key: dotted, lowercase-ish namespace segments. This filters + // out format strings, URIs, and other literals that happen to sit in a t! arg + // position but are not keys (there are none today, but keep the guard honest). + fn is_key_like(s: &str) -> bool { + s.contains('.') + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_') + && s.split('.').all(|seg| { + !seg.is_empty() + && seg + .bytes() + .next() + .map_or(false, |b| b.is_ascii_alphabetic()) + }) + } + + // Walk src/ collecting t!("…") literal keys with their location. + fn walk(dir: &Path, out: &mut Vec<(String, String, usize)>) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().and_then(|e| e.to_str()) == Some("rs") { + let Ok(src) = std::fs::read_to_string(&p) else { + continue; + }; + for (lineno, line) in src.lines().enumerate() { + collect_line(line, &p.display().to_string(), lineno + 1, out); + } + } + } + } + + // Find `t!("key"` occurrences on a line, honoring a word boundary so we do + // not match `format!("…")`, `print!("…")`, etc. + fn collect_line(line: &str, file: &str, lineno: usize, out: &mut Vec<(String, String, usize)>) { + let bytes = line.as_bytes(); + let mut i = 0; + while let Some(rel) = line[i..].find("t!(") { + let start = i + rel; + // word boundary before the `t` + let boundary = start == 0 + || !matches!(bytes[start - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_'); + let after = &line[start + 3..]; + let after_trim = after.trim_start(); + if boundary { + if let Some(rest) = after_trim.strip_prefix('"') { + if let Some(end) = rest.find('"') { + out.push((rest[..end].to_string(), file.to_string(), lineno)); + } + } + } + i = start + 3; + } + } + + let mut sites = Vec::new(); + walk( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut sites, + ); + + let mut leaks = Vec::new(); + for (key, file, lineno) in &sites { + if is_key_like(key) && !en_keys.contains(key) { + leaks.push(format!( + "{file}:{lineno}: t!(\"{key}\") — key not in en.yml" + )); + } + } + leaks.sort(); + leaks.dedup(); + + assert!( + leaks.is_empty(), + "raw-key leak: {} t! call site(s) reference a key absent from en.yml:\n{}", + leaks.len(), + leaks.join("\n") + ); +}