903 lines
36 KiB
Rust
903 lines
36 KiB
Rust
//! 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<bool, String> {
|
|
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<u32>,
|
|
held: impl FnOnce() -> Vec<ledger_vault::Tin>,
|
|
own_ein: Option<u32>,
|
|
) -> 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<char> = 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<Vec<u32>> = 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<Item = u32> + '_ {
|
|
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<u32>) -> 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<ledger_vault::Tin> {
|
|
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<u32> {
|
|
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<u8>, &'static str, &'static str)> {
|
|
let le: Vec<u8> = [0xFF, 0xFE]
|
|
.into_iter()
|
|
.chain(text.encode_utf16().flat_map(u16::to_le_bytes))
|
|
.collect();
|
|
let be: Vec<u8> = [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<u8> {
|
|
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<ledger_vault::Tin> { 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<u8> = 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<u8> = "x,291-12-2846"
|
|
.encode_utf16()
|
|
.flat_map(u16::to_le_bytes)
|
|
.collect();
|
|
let be: Vec<u8> = "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");
|
|
}
|
|
}
|