//! Files that carry full TINs never enter the books (rules.md B-26). //! //! ```text //! GOAL: a file holding a full TIN (a W-9, a 1099 worksheet, a CPA //! package or a file from one) is never filed as a receipt or //! document, where books.get_receipt would hand its bytes to any //! agent. //! SOURCE: rules.md B-26 (no tool output holds a full TIN); DECISION (P0 //! review round 2, 2026-10-16): one registry, on disk, of every //! TIN-bearing file the server reads or writes, and a scan of //! every text file on its way in. //! INVARIANTS: //! 1. The registry holds the SHA-256 of every file the server read or //! wrote with full TINs: the W-9 books.set_w9 reads (recorded once //! the call has passed every check, before the vault keeps it: a //! refused call marks nothing), each books.get_w9 output, a //! books.vendor_1099 worksheet with TINs, and a CPA package with //! TINs together with each file inside it that holds one. //! 2. It is one file beside the vault, so every session and every later //! server for these books shares it; hashes are not secret. A file //! is recorded before it is written out, and a failure to record it //! refuses the call. //! 3. Every way bytes are filed into the books refuses a recorded hash, //! and refuses text (by its type, its name, or because it decodes as //! UTF-8 or UTF-16, with a byte-order mark or NUL-interleaved //! without one) holding a full TIN, read as either: //! a. a TIN the vault holds for a party of the books, anywhere in the //! text, written as nine digits in a row, as 3-2-4 digits joined //! by hyphens, spaces, dots or commas, or as 2-7 digits joined by //! a hyphen, a space or a comma (compared on the server; the //! vault's TINs are never returned or logged). A vault that can't //! be opened leaves (b); //! b. a number printed the way an SSN or an EIN is, standing alone: //! 3-2-4 digits joined by hyphens or commas (123-45-6789, //! 123,45,6789) or 2-7 digits joined by a hyphen or a comma //! (12-3456789, 12,3456789), with no digit, and no hyphen or dot //! followed by a digit, on either side. //! Except the company's own EIN, as the books' settings hold it, //! printed 2-7 (DECISION, owner, 2026-10-17: every company //! document goes into the books, and its formation papers, //! plans and letters print its EIN; the settings already hold it //! in full, rules.md B-29). It is the company's, no person's: (a) //! still refuses it if the vault holds it for a party. //! Full-width digits and Unicode hyphens and spaces read as their //! ASCII kin. Refused bytes are never stored. //! 4. No probing (DECISION, P0 review, 2026-10-16): (a) answers for at //! most 2 candidates per filing. Before the vault is opened, text //! holding more than 2 distinct TIN-shaped numbers (any rendering //! (a) reads, the same number written twice counting once) that end //! in the last four digits of a TIN the books know (a party's TIN or //! W-9 facts, read from the books, not the vault) is refused with //! TIN_PROBE, whether or not one of them is a TIN the vault holds; //! the vault's own last fours, once it is opened, count too (a //! record the books' facts don't name). The last four is no secret //! (the tools show it), and the answer to such text never depends on //! the rest of the TIN, so finding one of the 100,000 TINs with a //! known last four takes on the order of 50,000 filings, each one //! refused and seen. //! 5. Ordinary text passes: a bank export's routing and account numbers, //! long references, a date next to an amount (DECISION, owner, //! 2026-10-16: bank exports are filed for reconciliation, so the //! text check is narrow; the broad has_full_tin check stays on what //! the server says). //! 6. Not detected: a changed copy of a binary file (a re-saved or //! re-scanned W-9 PDF), and a TIN the vault doesn't hold written //! other than (b). TINs cross the MCP on their way into the vault by //! design (plan D-28), the vault stays v1 (D-48), and (a) still //! answers for up to 2 candidates a filing (4): an agent that files //! 50,000 refused files can find a TIN, so the access log records //! every one of those calls and the probe bound keeps each answer //! from telling more than two guesses apart (rules.md B-26). //! ``` use std::path::Path; /// The registry's file, in the vault's folder. pub const FILE_NAME: &str = "tin-files.sha256"; /// The refusal for a file in the registry. pub const TIN_FILE: &str = "that file is one the books server read or wrote with full TINs (a W-9, a 1099 worksheet, a CPA package or a file from one): it never goes into the books"; /// The refusal for text holding a TIN the vault holds (INVARIANT 3a). pub const TIN_HELD: &str = "that file's text holds a full TIN the books keep in the TIN vault: a file with a full TIN never goes into the books"; /// The refusal for text holding more than [`PROBE_BOUND`] TIN-shaped /// numbers that end in a known TIN's last four (INVARIANT 4): the same /// whether or not one of them is that TIN. pub const TIN_PROBE: &str = "that file's text holds more than two numbers shaped like a TIN (nine digits, alone or grouped 3-2-4 or 2-7) that end in the last four digits of a TIN the books keep, so it isn't filed, whether or not one of them is that TIN: ask the human to save the original document as a PDF and file that"; /// How many TIN-shaped numbers ending in a known last four a filed text /// may hold for (a) to compare against the vault (INVARIANT 4). pub const PROBE_BOUND: usize = 2; /// The refusal for text holding a number printed as an SSN or EIN is /// (INVARIANT 3b). pub const TIN_PRINTED: &str = "that file's text holds a number printed the way an SSN or EIN is (three, two and four digits joined by hyphens, or two and seven), which reads as a full TIN: a file with a full TIN never goes into the books. If the number is no TIN (a reference number printed that way), file the original document as its source issued it; never convert or retype a file to get it past this check"; /// Record that `bytes` carry full TINs (INVARIANT 2). pub fn record(vault_dir: &Path, bytes: &[u8]) -> Result<(), String> { use std::io::Write; let sha = ledger_store::journal::sha256_hex(bytes); if holds_sha(vault_dir, &sha)? { return Ok(()); } let fail = |e: std::io::Error| { format!( "the books server couldn't record a file with full TINs: {}", e.kind() ) }; std::fs::create_dir_all(vault_dir).map_err(fail)?; let mut opts = std::fs::OpenOptions::new(); opts.append(true).create(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } let mut f = opts.open(vault_dir.join(FILE_NAME)).map_err(fail)?; f.write_all(format!("{sha}\n").as_bytes()) .and_then(|()| f.sync_all()) .map_err(fail) } /// Whether the registry holds `sha`. fn holds_sha(vault_dir: &Path, sha: &str) -> Result { match std::fs::read_to_string(vault_dir.join(FILE_NAME)) { Ok(text) => Ok(text.lines().any(|l| l.trim() == sha)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), // Fail closed: a registry that can't be read refuses the file. Err(e) => Err(format!( "the books server couldn't check the file against the ones it wrote with full TINs: {}", e.kind() )), } } /// Refuse `bytes` on their way into the books if they carry a full TIN /// (INVARIANT 3): a recorded file, or text holding one. `mime_type` and /// `file_name` say whether to read the bytes as text even when they /// aren't UTF-8. Asked for only when the bytes are text: `known_last4` /// gives the last four digits of every TIN the books know of, from the /// books (INVARIANT 4), and `held`, asked for only when the text holds /// no more than [`PROBE_BOUND`] candidates, the vault's TINs (none when /// the vault can't be opened). `own_ein` is the company's EIN, as its /// settings hold it: printed 2-7, it passes (b). pub fn refuse( vault_dir: &Path, bytes: &[u8], mime_type: &str, file_name: &str, known_last4: impl FnOnce() -> Vec, held: impl FnOnce() -> Vec, own_ein: Option, ) -> Result<(), String> { use zeroize::Zeroizing; if holds_sha(vault_dir, &ledger_store::journal::sha256_hex(bytes))? { return Err(TIN_FILE.to_string()); } let Some(text) = ledger_import::extract::text_of(bytes, mime_type, file_name) else { return Ok(()); }; let text: Vec = text.chars().map(fold).collect(); let mut last4 = known_last4(); // Before the vault is opened (INVARIANT 4). if probes(&text, &last4) { return Err(TIN_PROBE.to_string()); } let mut tins: Zeroizing> = Zeroizing::new( held() .iter() .filter_map(|t| t.digits().parse().ok()) .collect(), ); // The vault's own last fours count too. last4.extend(tins.iter().map(|t| t % 10_000)); if probes(&text, &last4) { return Err(TIN_PROBE.to_string()); } tins.sort_unstable(); if holds_held_tin(&text, &tins) { return Err(TIN_HELD.to_string()); } if holds_printed_tin(&text, own_ein) { return Err(TIN_PRINTED.to_string()); } Ok(()) } /// A character as the scan reads it: full-width digits as ASCII ones, /// Unicode hyphens and dashes as `-`, a full-width full stop as `.`, /// no-break and other fixed spaces as ` ` (INVARIANT 3). fn fold(c: char) -> char { match c { '\u{FF10}'..='\u{FF19}' => char::from_digit(u32::from(c) - 0xFF10, 10).expect("a digit"), '\u{2010}'..='\u{2013}' | '\u{2212}' | '\u{FE63}' | '\u{FF0D}' => '-', '\u{FF0E}' => '.', '\u{A0}' | '\u{2007}' | '\u{202F}' | '\u{3000}' => ' ', c => c, } } /// Read `groups` runs of ASCII digits from `text[at..]`, of the lengths /// given, joined by one character from `joins`: the digits as a number /// and the index after the last, or None. fn digits_at(text: &[char], at: usize, groups: &[usize], joins: &[char]) -> Option<(u32, usize)> { let mut value = 0u32; let mut i = at; for (n, &len) in groups.iter().enumerate() { if n > 0 { if !joins.contains(text.get(i)?) { return None; } i += 1; } for _ in 0..len { // to_digit(10) reads ASCII digits only. value = value * 10 + text.get(i)?.to_digit(10)?; i += 1; } } Some((value, i)) } /// Every TIN-shaped number in `text`, at every place, in every rendering /// (a) reads: nine digits in a row, 3-2-4 joined by hyphens, spaces, dots /// or commas, 2-7 joined by a hyphen, a space or a comma (INVARIANT 3a). fn tin_shaped(text: &[char]) -> impl Iterator + '_ { const RENDERINGS: [(&[usize], &[char]); 3] = [ (&[9], &[]), (&[3, 2, 4], &['-', ' ', '.', ',']), (&[2, 7], &['-', ' ', ',']), ]; (0..text.len()).flat_map(move |at| { RENDERINGS .iter() .filter_map(move |(groups, joins)| digits_at(text, at, groups, joins).map(|(v, _)| v)) }) } /// Whether `text` holds more than [`PROBE_BOUND`] distinct TIN-shaped /// numbers ending in one of `last4` (INVARIANT 4). fn probes(text: &[char], last4: &[u32]) -> bool { let mut seen = std::collections::BTreeSet::new(); tin_shaped(text) .filter(|v| last4.contains(&(v % 10_000))) .any(|v| { seen.insert(v); seen.len() > PROBE_BOUND }) } /// Whether `text` holds one of `tins` (sorted), anywhere, in a common /// rendering (INVARIANT 3a). fn holds_held_tin(text: &[char], tins: &[u32]) -> bool { tin_shaped(text).any(|v| tins.binary_search(&v).is_ok()) } /// Whether `text` holds a number printed as an SSN (3-2-4 digits joined /// by hyphens or commas) or an EIN (2-7 joined by a hyphen or a comma) /// is, standing alone: no digit beside it, and no hyphen or dot that a /// digit follows on; the company's own EIN printed 2-7 aside /// (INVARIANT 3b). fn holds_printed_tin(text: &[char], own_ein: Option) -> bool { let digit = |i: usize| text.get(i).is_some_and(|c| c.is_numeric()); let joins_on = |join: usize, digit_at: usize| { text.get(join).is_some_and(|c| matches!(c, '-' | '.')) && digit(digit_at) }; (0..text.len()).any(|at| { let alone_before = at == 0 || !(digit(at - 1) || at >= 2 && joins_on(at - 1, at - 2)); alone_before && [&[3, 2, 4][..], &[2, 7][..]].iter().any(|groups| { digits_at(text, at, groups, &['-', ',']).is_some_and(|(value, end)| { let own = groups.len() == 2 && Some(value) == own_ein; !(own || digit(end) || joins_on(end, end + 1)) }) }) }) } #[cfg(test)] mod tests { use super::*; use ledger_import::extract::nul_interleaved; /// A test's dir, removed when the test ends, pass or panic. struct TestDir(std::path::PathBuf); impl std::ops::Deref for TestDir { type Target = std::path::Path; fn deref(&self) -> &std::path::Path { &self.0 } } impl Drop for TestDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); TestDir(d) } /// INVARIANTS 1-2: a recorded file is refused from then on, by this /// registry or any other reading the same folder (another session, a /// later server); recording twice keeps one line. #[test] fn a_recorded_tin_file_is_refused_by_every_reader_of_the_registry() { let vault = dir("registry"); let w9 = b"\x89PNG binary W-9 scan".to_vec(); assert!(refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new, None).is_ok()); record(&vault, &w9).unwrap(); record(&vault, &w9).unwrap(); assert_eq!( refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new, None), Err(TIN_FILE.into()) ); let text = std::fs::read_to_string(vault.join(FILE_NAME)).unwrap(); assert_eq!(text.lines().count(), 1); // Only the hash is kept. assert!(!text.contains("PNG")); } /// The registry's folder for a check (it never exists) and no vault. fn check(bytes: &[u8], mime: &str, name: &str) -> Result<(), String> { refuse(&dir("check"), bytes, mime, name, Vec::new, Vec::new, None) } /// The vault's TINs for the checks: an SSN and an EIN. fn vault() -> Vec { use ledger_vault::{Tin, TinType}; vec![ Tin::new("291122846", TinType::Ssn).unwrap(), Tin::new("20-5200615", TinType::Ein).unwrap(), ] } /// Their last fours, as the books know them. fn known() -> Vec { vec![2846, 615] } /// The same, with the vault holding an SSN and an EIN the books know /// the last fours of. fn check_held(bytes: &[u8], mime: &str, name: &str) -> Result<(), String> { refuse(&dir("check"), bytes, mime, name, known, vault, None) } /// `text` in each encoding and label a filed file can come as: UTF-8, /// UTF-16 either way round, and (for a text type or name) bytes that /// aren't UTF-8. fn encodings(text: &str) -> Vec<(Vec, &'static str, &'static str)> { let le: Vec = [0xFF, 0xFE] .into_iter() .chain(text.encode_utf16().flat_map(u16::to_le_bytes)) .collect(); let be: Vec = [0xFE, 0xFF] .into_iter() .chain(text.encode_utf16().flat_map(u16::to_be_bytes)) .collect(); let mut latin1 = b"Caf\xe9,".to_vec(); latin1.extend_from_slice(text.as_bytes()); // UTF-16 without a byte-order mark, either way round: valid UTF-8 // when the text is ASCII, its digits split by NULs. With a Latin // letter (é is E9 00 or 00 E9), not UTF-8 at all. let bare = |text: &str, le: bool| -> Vec { text.encode_utf16() .flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }) .collect() }; let accented = format!("Caf\u{e9},{text}"); vec![ (bare(text, true), "application/octet-stream", "export.bin"), (bare(text, false), "application/octet-stream", "export.bin"), (bare(&accented, true), "application/octet-stream", "x.bin"), (bare(&accented, false), "text/plain", "x.bin"), (text.as_bytes().to_vec(), "text/csv", "k1-members.csv"), ( text.as_bytes().to_vec(), "application/octet-stream", "x.bin", ), (le, "application/octet-stream", "export.csv"), (be, "text/plain", "export.txt"), (latin1.clone(), "text/csv", "export.csv"), (latin1.clone(), "text/plain", "x.bin"), (latin1.clone(), "application/json", "x.bin"), (latin1.clone(), "application/ld+json", "x.bin"), (latin1, "application/octet-stream", "export.tsv"), ] } /// INVARIANT 3a: a TIN the vault holds is refused anywhere in the /// text, in every rendering, however the text is encoded or labeled; /// the vault is consulted for text only. #[test] fn text_holding_a_vault_tin_is_refused_in_every_rendering() { for tin in [ "291122846", "291-12-2846", "291 12 2846", "291.12.2846", "291-12 2846", "29-1122846", "29 1122846", "205200615", "205-20-0615", "205 20 0615", "205.20.0615", "20-5200615", "20 5200615", // Inside a longer number, and in Unicode's forms. "ref 902911228461", "\u{FF12}\u{FF19}\u{FF11}\u{FF11}\u{FF12}\u{FF12}\u{FF18}\u{FF14}\u{FF16}", "291\u{2013}12\u{2011}2846", "291\u{FF0E}12\u{FF0E}2846", "291\u{A0}12\u{3000}2846", "20\u{2212}5200615", ] { let text = format!("vendor,tin\nAcme,{tin}\n"); for (bytes, mime, name) in encodings(&text) { assert_eq!( check_held(&bytes, mime, name), Err(TIN_HELD.into()), "{tin:?} as {name}" ); } // Without the vault, only the printed shapes are refused. let printed = [ "291-12-2846", "29-1122846", "205-20-0615", "20-5200615", "291\u{2013}12\u{2011}2846", "20\u{2212}5200615", ] .contains(&tin); assert_eq!( check(text.as_bytes(), "text/csv", "x.csv").is_err(), printed, "{tin:?} with the vault locked" ); } // Binary bytes never open the vault. assert!( refuse( &dir("check"), b"%PDF-1.4\n\xff 291122846", "application/pdf", "scan.pdf", || panic!("the last fours were asked for a binary file"), || panic!("the vault was opened for a binary file"), None, ) .is_ok() ); } /// INVARIANT 3b: a number printed as an SSN or EIN is, standing alone, /// is refused wherever it stands, however it is encoded, with no vault. #[test] fn a_printed_ssn_or_ein_is_refused() { for text in [ "123-45-6789", "12-3456789", "name,ssn\nDana,187-05-1907\n", "2026-10-01,187-05-1907,12.50", "SSN:187-05-1907.", "EIN 98-7814321-", "x187-05-1907y", "-187-05-1907", "x-187-05-1907", "1234x-187-05-1907", ".12-3456789", "1-x12-3456789", "\u{FF11}87\u{2010}05\u{FF0D}1907", ] { for (bytes, mime, name) in encodings(text) { assert_eq!( check(&bytes, mime, name), Err(TIN_PRINTED.into()), "{text:?} as {name}" ); } } } /// INVARIANT 3b (P7): a TIN written with commas between its groups /// is refused like one written with hyphens, in every encoding, with /// the vault locked or open. #[test] fn a_file_with_a_comma_separated_tin_is_refused() { for tin in [ "187,05,1907", // 3-2-4 "12,3456789", // 2-7 ] { let text = format!("payee,tin\nDana,{tin}\n"); for (bytes, mime, name) in encodings(&text) { assert_eq!( check(&bytes, mime, name), Err(TIN_PRINTED.into()), "{tin:?} as {name}, vault locked" ); assert!( check_held(&bytes, mime, name).is_err(), "{tin:?} as {name}, vault open" ); } } // A comma-grouped amount is no TIN (1-3-3-2): it goes in. assert!( check( b"date,amount\n2026-10-01,\"1,234,567.89\"\n", "text/csv", "x.csv" ) .is_ok() ); } /// INVARIANT 3b: an SSN or EIN shape inside a longer number (a digit, /// or a hyphen or dot and a digit, beside it) is no TIN. #[test] fn a_longer_number_holding_a_tin_shape_passes() { for text in [ "1123-45-6789", "123-45-67890", "0123-45-67890", "1-123-45-6789", "1.123-45-6789", "123-45-6789-0", "123-45-6789.5", "112-3456789", "12-34567890", "2026-12-3456789", "12-3456789.01", "\u{0661}123-45-6789", ] { assert!( check(text.as_bytes(), "text/csv", "x.csv").is_ok(), "{text:?}" ); } } /// INVARIANT 5: a realistic bank export passes, whether or not the /// vault holds TINs: routing and account numbers, long references, a /// date run into an amount, phone numbers, card numbers. #[test] fn an_ordinary_bank_export_passes() { let csv = "\ Date,Description,Amount,Running Bal.,Reference 10/01/2026,Beginning balance,,\"1,234,567.89\", 10/01/2026,ACH CREDIT APPLE INC 021000021 ID 9878143210123456,1499.00,\"1,236,066.89\",20261001000123456789 10/02/2026,WIRE IN FROM 026009593 ACCT 4417123456789,250000.00,1486066.89,FT26275987814321 10/03/2026,POS PURCHASE 2026-10-03 12345.67 MERCHANT 801-555-1234,-12.50,1486054.39,1002345678 10/04/2026 021000021,ZELLE TO 5551234567 CONF 1234-5678-9012,-40.00,1486014.39,000123456789012 10/05/2026,CARD 4111 1111 1111 1111 TRACE 0210000210012345,-9.99,1486004.40,2026100512.50 10/06/2026,CHECK 1042 SEQ 026 00 9593,-100.00,1485904.40,ROUTING 228620358 10/07/2026,INTEREST 2050 2026-01-31,0.42,1485904.82,84101-1234 "; for (bytes, mime, name) in encodings(csv) { assert!(check(&bytes, mime, name).is_ok(), "{name}"); assert!(check_held(&bytes, mime, name).is_ok(), "{name}, vault open"); } assert!(check(b"date,amount\n2026-10-01,12.50\n", "text/csv", "bank.csv").is_ok()); assert!(!dir("check").exists(), "a check never creates the registry"); } /// INVARIANT 3b: the company's own EIN, printed 2-7, passes; printed /// any other way, or any other EIN, or with no EIN in the settings, it /// is refused; and (a) still refuses it when the vault holds it. #[test] fn the_company_s_own_ein_printed_as_an_ein_passes() { const OWN: u32 = 123_456_789; let own = |text: &str| { refuse( &dir("own-ein"), text.as_bytes(), "text/markdown", "plan.md", Vec::new, Vec::new, Some(OWN), ) }; assert_eq!(own("# Plan\n\nEIN: 12-3456789. Formed in Utah.\n"), Ok(())); assert_eq!(own("EIN 12-3456789 and 12-3456789 again"), Ok(())); assert_eq!( own("EIN\u{2011}12\u{2010}3456789"), Ok(()), "Unicode hyphens fold" ); assert_eq!( own("EIN \u{FF11}\u{FF12}-3456789"), Ok(()), "full-width digits fold" ); for other in [ "EIN 98-7814321", "ours 12-3456789, theirs 98-7814321", "as an SSN: 123-45-6789", "one off: 21-7516788", ] { assert_eq!(own(other), Err(TIN_PRINTED.into()), "{other}"); } assert_eq!( check(b"EIN: 12-3456789", "text/plain", "x.txt"), Err(TIN_PRINTED.into()), "no EIN in the settings: no exemption" ); // (a) first: the vault holding that number for a party refuses it. let held = || vec![ledger_vault::Tin::new("12-3456789", ledger_vault::TinType::Ein).unwrap()]; assert_eq!( refuse( &dir("own-ein"), b"EIN: 12-3456789", "text/plain", "x.txt", Vec::new, held, Some(OWN), ), Err(TIN_HELD.into()) ); } /// INVARIANT 3, failing closed: a registry that is there but can't be /// read refuses every file, saying so. #[test] fn an_unreadable_registry_refuses_every_file() { let vault = dir("unreadable"); std::fs::create_dir_all(vault.join(FILE_NAME)).unwrap(); let why = refuse( &vault, b"%PDF a receipt", "application/pdf", "r.pdf", Vec::new, Vec::new, None, ) .expect_err("a registry that can't be read let a file in"); assert!(why.contains("couldn't check the file"), "{why}"); assert!(record(&vault, b"bytes").is_err()); } /// INVARIANT 4: text holding more than two TIN-shaped numbers ending /// in a known last four is refused before the vault is opened, with /// the same words whether one of them is the vault's TIN or none is, /// whether the vault is open or locked, however the text is encoded. #[test] fn text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it() { let no_vault = || -> Vec { panic!("the vault was opened for a probe") }; for (with_tin, without) in [ // Bare, as an SSN, as an EIN, and in each grouping. ( "291122846\n806812846\n832582846\n", "348072846\n806812846\n832582846\n", ), ( "291-12-2846,806-81-2846,832-58-2846", "348-07-2846,806-81-2846,832-58-2846", ), ( "20-5200615 10-0000615 38-9500615", "31-8640615 10-0000615 38-9500615", ), ( "291 12 2846;80 6812846;832.58.2846", "348 07 2846;80 6812846;832.58.2846", ), // Across both last fours. ( "205200615,806812846,100000615", "389500615,806812846,100000615", ), // Inside one long run of digits, and in Unicode's forms. ( "ref 291122846832582846348072846", "ref 806812846832582846348072846", ), ( "\u{FF12}\u{FF19}\u{FF11}\u{FF11}\u{FF12}\u{FF12}\u{FF18}\u{FF14}\u{FF16} 806\u{2013}81\u{2011}2846 83\u{2212}2582846", "348\u{A0}07\u{3000}2846 806\u{2013}81\u{2011}2846 83\u{2212}2582846", ), ] { for text in [with_tin, without] { for (bytes, mime, name) in encodings(text) { let refusals = [ refuse(&dir("check"), &bytes, mime, name, known, vault, None), refuse(&dir("check"), &bytes, mime, name, known, no_vault, None), // The vault locked. refuse(&dir("check"), &bytes, mime, name, known, Vec::new, None), ]; for r in refusals { assert_eq!(r, Err(TIN_PROBE.into()), "{text:?} as {name}"); } } } // Byte for byte the same answer. assert_eq!( check_held(with_tin.as_bytes(), "text/csv", "x.csv") .unwrap_err() .as_bytes(), check_held(without.as_bytes(), "text/csv", "x.csv") .unwrap_err() .as_bytes(), ); } // A vault TIN whose last four the books don't know: once the vault // is opened, its last four counts too. for text in [ "291122846 806812846 832582846", "348072846 806812846 832582846", ] { assert_eq!( refuse( &dir("check"), text.as_bytes(), "text/csv", "x.csv", Vec::new, vault, None, ), Err(TIN_PROBE.into()), "{text:?}" ); } } /// INVARIANT 4: up to two candidates are compared against the vault: /// none of them the TIN goes in, one that is is refused as a held TIN. /// The same number written many ways counts once, and numbers ending /// in another last four never count. #[test] fn one_or_two_candidates_are_compared_against_the_vault() { for (text, held) in [ ("806812846", false), ("291122846", true), ("806812846,832582846", false), ("806812846,291-12-2846", true), ("205200615 806812846", true), ("100000615 806812846", false), // One number, written four ways (the printed shapes are (b)'s). ("806812846 806 81 2846 80 6812846 806.81.2846", false), ( "291122846 291-12-2846 291 12 2846 29-1122846 291.12.2846", true, ), // Other last fours, many of them. ( "100001111 100002222 100003333 100004444 100005555 806812846", false, ), ( "100001111 100002222 100003333 100004444 100005555 291122846", true, ), ] { let want = if held { Err(TIN_HELD.into()) } else { Ok(()) }; for (bytes, mime, name) in encodings(text) { assert_eq!(check_held(&bytes, mime, name), want, "{text:?} as {name}"); } // The vault's last fours alone do the same. assert_eq!( refuse( &dir("check"), text.as_bytes(), "text/csv", "x.csv", Vec::new, vault, None, ), want, "{text:?}, the books knowing no last four" ); } } /// INVARIANT 3: binary bytes aren't text, so they aren't scanned. #[test] fn binary_bytes_are_not_scanned() { let mut bytes = b"name,tin\nCaf\xe9,291-12-2846\n".to_vec(); bytes.push(0xFF); assert!(check_held(&bytes, "application/pdf", "scan.pdf").is_ok()); assert_eq!( check_held(&bytes, "text/csv", "scan.csv"), Err(TIN_HELD.into()) ); } /// UTF-16 with a byte-order mark is read as UTF-16 whatever it holds, /// NULs or not: text of full-width digits and CJK has no NUL byte at /// all, either way round, and is still scanned. #[test] fn utf16_with_a_mark_is_read_whatever_it_holds() { let text = "\u{652F}\u{6255}\u{FF12}\u{FF19}\u{FF11}\u{2011}\u{FF11}\u{FF12}\u{2011}\u{FF12}\u{FF18}\u{FF14}\u{FF16}"; for le in [true, false] { let mut bytes: Vec = if le { vec![0xFF, 0xFE] } else { vec![0xFE, 0xFF] }; bytes.extend( text.encode_utf16() .flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }), ); assert!(!bytes.contains(&0), "no NUL to find it by"); assert_eq!( check_held(&bytes, "application/octet-stream", "x.bin"), Err(TIN_HELD.into()), "le {le}" ); } } /// NULs scattered through text (no UTF-16 at all) don't hide a TIN: /// the text is also read with them taken out. #[test] fn nuls_scattered_in_text_hide_no_tin() { let bytes = b"payee,tin\nDana,634\x00-28-1907\n"; assert_eq!(check(bytes, "text/csv", "p.csv"), Err(TIN_PRINTED.into())); assert!(check(b"payee,note\nDana,\x00none\n", "text/csv", "p.csv").is_ok()); } /// Binary bytes with NULs scattered through them (a PDF, an image) are /// not read as UTF-16, and neither are bytes that are mostly NULs on /// both sides of each pair; NUL-interleaved bytes are, either way. #[test] fn only_nul_interleaved_bytes_read_as_utf16_without_a_mark() { let mut pdf = b"%PDF-1.4\n\x00\x00\x00\x00 291-12-2846 stream\n".to_vec(); pdf.push(0xFF); assert!(!nul_interleaved(&pdf)); assert!(check(&pdf, "application/pdf", "scan.pdf").is_ok()); let mut zeros = vec![0u8; 64]; zeros.extend_from_slice(b"291-12-2846"); zeros.push(0xFF); assert!(!nul_interleaved(&zeros)); assert!(check(&zeros, "image/bmp", "scan.bmp").is_ok()); assert!(!nul_interleaved(b"")); assert!(!nul_interleaved(b"\x00")); let le: Vec = "x,291-12-2846" .encode_utf16() .flat_map(u16::to_le_bytes) .collect(); let be: Vec = "x,291-12-2846" .encode_utf16() .flat_map(u16::to_be_bytes) .collect(); for bytes in [&le, &be] { assert!(nul_interleaved(bytes)); assert_eq!( check(bytes, "application/octet-stream", "x.bin"), Err(TIN_PRINTED.into()) ); } // Exactly at the bounds: half the pairs NUL on one side, an eighth // on the other. let mut edge = Vec::new(); for i in 0..16 { edge.push(b'a'); edge.push(if i < 8 { 0 } else { b'b' }); } assert!(nul_interleaved(&edge)); edge[1] = b'b'; assert!(!nul_interleaved(&edge), "fewer than half"); let mut other = Vec::new(); for i in 0..16 { other.push(if i < 2 { 0 } else { b'c' }); other.push(0); } assert!(nul_interleaved(&other), "an eighth on the other side"); other[4] = 0; assert!(!nul_interleaved(&other), "more than an eighth"); let mut be_edge = Vec::new(); for i in 0..16 { be_edge.push(if i < 8 { 0 } else { b'b' }); be_edge.push(b'a'); } assert!(nul_interleaved(&be_edge)); // Half NUL on the first side, three of sixteen (over an eighth) on // the other: not UTF-16. let mut mixed = Vec::new(); for i in 0..16 { mixed.push(if i < 8 { 0 } else { b'x' }); mixed.push(if (8..11).contains(&i) { 0 } else { b'y' }); } assert!(!nul_interleaved(&mixed)); mixed[17] = b'y'; mixed[19] = b'y'; assert!(nul_interleaved(&mixed), "one of sixteen is under an eighth"); } }