use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; use rand::RngCore; use rand::rngs::OsRng; use rusqlite::{Connection, OptionalExtension, params}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; #[derive(Debug)] pub struct Error(String); impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::error::Error for Error {} impl From for Error { fn from(e: rusqlite::Error) -> Self { Self(e.to_string()) } } impl From for Error { fn from(e: std::io::Error) -> Self { Self(e.to_string()) } } impl From for Error { fn from(e: String) -> Self { Self(e) } } pub struct Store { conn: Connection, msg_key: [u8; 32], } pub struct SelfIdentity { pub identity_sk: Vec, pub identity_pk: Vec, pub onion: String, pub prekey_sk: Vec, pub prekey_pk: Vec, } pub struct Friend { pub pubkey: Vec, pub fingerprint: String, pub petname: Option, pub onion: String, pub last_connect_ok: bool, pub prekey: Vec, } pub struct FriendProfile { pub display_name: String, pub bio: String, pub xmr_addr: String, pub updated_at: i64, } #[derive(Debug)] pub struct Message { pub dir: String, pub plaintext: Vec, } #[derive(Debug)] pub struct Payment { pub id: i64, pub dir: String, pub kind: String, pub amount_atomic: String, pub address: String, pub memo: String, pub txid: Option, pub verified: bool, pub created_at: i64, } pub struct PaymentWrite<'a> { pub dir: &'a str, pub kind: &'a str, pub amount_atomic: &'a str, pub address: &'a str, pub memo: &'a str, pub txid: Option<&'a str>, pub verified: bool, } impl Store { pub fn open() -> Result { Self::open_at(&home_dir()?) } pub fn home_dir() -> Result { home_dir() } pub fn open_at(home: &Path) -> Result { Self::open_at_with_passphrase(home, &passphrase_from_env()?) } pub fn open_at_with_passphrase(home: &Path, passphrase: &str) -> Result { if passphrase.is_empty() { return Err(Error("empty passphrase".into())); } mkdir_700(home)?; mkdir_700(&home.join("arti"))?; let db_path = home.join("onionwire.db"); let conn = Connection::open(&db_path)?; let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; if !journal.eq_ignore_ascii_case("wal") { return Err(Error(format!("journal_mode WAL failed: {journal}"))); } conn.execute_batch( " PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS friends ( id INTEGER PRIMARY KEY, pubkey BLOB NOT NULL UNIQUE, fingerprint TEXT NOT NULL, petname TEXT, onion TEXT NOT NULL, onion_updated_at INTEGER NOT NULL, added_at INTEGER NOT NULL, last_connect_ok INTEGER NOT NULL DEFAULT 1, prekey BLOB NOT NULL DEFAULT x'' ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, friend_id INTEGER NOT NULL REFERENCES friends(id), dir TEXT NOT NULL CHECK(dir IN ('in','out')), plaintext BLOB NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS self ( id INTEGER PRIMARY KEY CHECK (id = 1), identity_sk BLOB NOT NULL, identity_pk BLOB NOT NULL, onion TEXT NOT NULL, onion_rotated_at INTEGER NOT NULL, prekey_sk BLOB NOT NULL, prekey_pk BLOB NOT NULL, hs_nickname TEXT NOT NULL DEFAULT 'ow0' ); CREATE TABLE IF NOT EXISTS self_profile ( id INTEGER PRIMARY KEY CHECK (id = 1), display_name TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '', xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS friend_profiles ( pubkey BLOB PRIMARY KEY, display_name TEXT NOT NULL, bio TEXT NOT NULL, xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS store_meta ( id INTEGER PRIMARY KEY CHECK (id = 1), kdf_salt BLOB NOT NULL, wrapped_key BLOB NOT NULL ); CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY, friend_id INTEGER NOT NULL REFERENCES friends(id), dir TEXT NOT NULL CHECK(dir IN ('in','out')), kind TEXT NOT NULL CHECK(kind IN ('invoice','receipt')), amount_atomic TEXT NOT NULL, address TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', txid TEXT, verified INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); INSERT OR IGNORE INTO self_profile (id) VALUES (1); ", )?; let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; if check != "ok" { return Err(Error(format!("integrity_check: {check}"))); } let mut store = Self { conn, msg_key: [0u8; 32], }; store.migrate()?; store.ensure_self()?; store.unlock_messages(passphrase)?; Ok(store) } pub fn journal_mode(&self) -> Result { self.conn .query_row("PRAGMA journal_mode", [], |row| row.get(0)) .map_err(Into::into) } pub fn replace_identity_keys( &self, identity_sk: &[u8], identity_pk: &[u8], prekey_sk: &[u8], prekey_pk: &[u8], ) -> Result<()> { if identity_sk.len() != 32 || identity_pk.len() != 32 || prekey_sk.len() != 32 || prekey_pk.len() != 32 { return Err(Error("identity key length".into())); } self.conn.execute( "UPDATE self SET identity_sk = ?1, identity_pk = ?2, prekey_sk = ?3, prekey_pk = ?4 WHERE id = 1", params![identity_sk, identity_pk, prekey_sk, prekey_pk], )?; Ok(()) } fn migrate(&self) -> Result<()> { self.add_column_if_missing( "friends", "last_connect_ok", "ALTER TABLE friends ADD COLUMN last_connect_ok INTEGER NOT NULL DEFAULT 1", )?; self.add_column_if_missing( "friends", "prekey", "ALTER TABLE friends ADD COLUMN prekey BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "prekey_sk", "ALTER TABLE self ADD COLUMN prekey_sk BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "prekey_pk", "ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "hs_nickname", "ALTER TABLE self ADD COLUMN hs_nickname TEXT NOT NULL DEFAULT 'ow0'", )?; Ok(()) } fn unlock_messages(&mut self, passphrase: &str) -> Result<()> { let row: Option<(Vec, Vec)> = self .conn .query_row( "SELECT kdf_salt, wrapped_key FROM store_meta WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; match row { Some((salt, wrapped)) => { let wrap_key = crate::backup::kdf(passphrase, &salt)?; let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped) .map_err(|_| Error("wrong passphrase".into()))?; if raw.len() != 32 { return Err(Error("wrapped message key length".into())); } self.msg_key.copy_from_slice(&raw); Ok(()) } None => { let mut data_key = [0u8; 32]; OsRng.fill_bytes(&mut data_key); let mut salt = [0u8; 16]; OsRng.fill_bytes(&mut salt); let wrap_key = crate::backup::kdf(passphrase, &salt)?; let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?; self.conn.execute( "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", params![salt.as_slice(), wrapped], )?; self.msg_key = data_key; self.reencrypt_legacy_messages()?; Ok(()) } } } fn reencrypt_legacy_messages(&self) -> Result<()> { let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; let rows: Vec<(i64, Vec)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::>()?; drop(stmt); if rows.is_empty() { return Ok(()); } let tx = self.conn.unchecked_transaction()?; for (id, plain) in rows { let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; tx.execute( "UPDATE messages SET plaintext = ?1 WHERE id = ?2", params![blob, id], )?; } tx.commit()?; let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)"); Ok(()) } fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> { let sql = format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'"); let has: i64 = self.conn.query_row(&sql, [], |row| row.get(0))?; if has == 0 { self.conn.execute(ddl, [])?; } Ok(()) } fn ensure_self(&self) -> Result<()> { let exists: i64 = self.conn .query_row("SELECT COUNT(*) FROM self WHERE id = 1", [], |row| { row.get(0) })?; if exists == 0 { let signing = SigningKey::generate(&mut OsRng); let sk = signing.to_bytes().to_vec(); let pk = signing.verifying_key().to_bytes().to_vec(); let (psk, ppk) = gen_prekey(); self.conn.execute( "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk, hs_nickname) VALUES (1, ?1, ?2, '', 0, ?3, ?4, 'ow0')", params![sk, pk, psk, ppk], )?; } else { let empty: i64 = self.conn.query_row( "SELECT CASE WHEN length(prekey_sk) = 32 AND length(prekey_pk) = 32 THEN 0 ELSE 1 END FROM self WHERE id = 1", [], |row| row.get(0), )?; if empty != 0 { let (psk, ppk) = gen_prekey(); self.conn.execute( "UPDATE self SET prekey_sk = ?1, prekey_pk = ?2 WHERE id = 1", params![psk, ppk], )?; } } Ok(()) } pub fn self_identity(&self) -> Result { self.conn .query_row( "SELECT identity_sk, identity_pk, onion, prekey_sk, prekey_pk FROM self WHERE id = 1", [], |row| { Ok(SelfIdentity { identity_sk: row.get(0)?, identity_pk: row.get(1)?, onion: row.get(2)?, prekey_sk: row.get(3)?, prekey_pk: row.get(4)?, }) }, ) .map_err(Into::into) } pub fn friend_count(&self) -> Result { self.conn .query_row("SELECT COUNT(*) FROM friends", [], |row| row.get(0)) .map_err(Into::into) } /// Display fingerprint of our own identity key (identity = pubkey). pub fn self_fingerprint(&self) -> Result { Ok(fingerprint(&self.self_identity()?.identity_pk)) } /// Local petname only. Never part of the wire protocol. pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<()> { let n = self.conn.execute( "UPDATE friends SET petname = ?1 WHERE pubkey = ?2", params![petname, pubkey], )?; if n == 0 { return Err(Error("friend not found".into())); } Ok(()) } pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> { let now = unix_now(); let fp = fingerprint(pubkey); self.conn.execute( "INSERT INTO friends (pubkey, fingerprint, petname, onion, onion_updated_at, added_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) ON CONFLICT(pubkey) DO UPDATE SET onion = excluded.onion, onion_updated_at = excluded.onion_updated_at, petname = COALESCE(excluded.petname, friends.petname)", params![pubkey, fp, petname, onion, now], )?; Ok(()) } pub fn get_friend(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE pubkey = ?1", params![pubkey], friend_from_row, ) .optional() .map_err(Into::into) } pub fn list_friends(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends ORDER BY COALESCE(petname, fingerprint) COLLATE NOCASE", )?; let rows = stmt.query_map([], friend_from_row)?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } pub fn hs_nickname(&self) -> Result { self.conn .query_row("SELECT hs_nickname FROM self WHERE id = 1", [], |row| { row.get(0) }) .map_err(Into::into) } pub fn set_hs_nickname(&self, nickname: &str) -> Result<()> { self.conn.execute( "UPDATE self SET hs_nickname = ?1 WHERE id = 1", params![nickname], )?; Ok(()) } pub fn set_onion(&self, onion: &str) -> Result<()> { let now = unix_now(); self.conn.execute( "UPDATE self SET onion = ?1, onion_rotated_at = ?2 WHERE id = 1", params![onion, now], )?; Ok(()) } /// Apply a signed locator update. Bad sig / stale ts / unknown friend → no change. pub fn apply_loc(&self, pubkey: &[u8], onion: &str, ts: i64, sig: &[u8]) -> Result { let loc = crate::loc::Loc { onion: onion.to_string(), ts, sig: sig.to_vec(), }; if !crate::loc::verify(pubkey, &loc) { return Ok(false); } let Some(updated_at) = self .conn .query_row( "SELECT onion_updated_at FROM friends WHERE pubkey = ?1", params![pubkey], |row| row.get::<_, i64>(0), ) .optional()? else { return Ok(false); }; if ts <= updated_at { return Ok(false); } let n = self.conn.execute( "UPDATE friends SET onion = ?1, onion_updated_at = ?2 WHERE pubkey = ?3", params![onion, ts, pubkey], )?; Ok(n > 0) } pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?; let prev: i64 = self.conn.query_row( "SELECT updated_at FROM self_profile WHERE id = 1", [], |row| row.get(0), )?; let now = unix_now().max(prev + 1); self.conn.execute( "UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1", params![display_name, bio, xmr_addr, now], )?; Ok(()) } pub fn self_profile(&self) -> Result { self.conn .query_row( "SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1", [], profile_from_row, ) .map_err(Into::into) } /// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert. pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result { if !crate::profile::verify(pubkey, prf) { return Ok(false); } let exists: i64 = self.conn.query_row( "SELECT COUNT(*) FROM friends WHERE pubkey = ?1", params![pubkey], |row| row.get(0), )?; if exists == 0 { return Ok(false); } let prev: Option = self .conn .query_row( "SELECT updated_at FROM friend_profiles WHERE pubkey = ?1", params![pubkey], |row| row.get(0), ) .optional()?; if prev.is_some_and(|t| prf.ts <= t) { return Ok(false); } self.conn.execute( "INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(pubkey) DO UPDATE SET display_name = excluded.display_name, bio = excluded.bio, xmr_addr = excluded.xmr_addr, updated_at = excluded.updated_at", params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts], )?; Ok(true) } pub fn friend_profile(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( "SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1", params![pubkey], profile_from_row, ) .optional() .map_err(Into::into) } pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> { let n = self.conn.execute( "UPDATE friends SET prekey = ?1 WHERE pubkey = ?2", params![prekey, pubkey], )?; if n == 0 { return Err(Error("friend not found".into())); } Ok(()) } pub fn get_friend_by_prekey(&self, prekey: &[u8]) -> Result> { self.conn .query_row( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE prekey = ?1", params![prekey], friend_from_row, ) .optional() .map_err(Into::into) } pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result { if p.dir != "in" && p.dir != "out" { return Err(Error("dir must be in or out".into())); } if p.kind != "invoice" && p.kind != "receipt" { return Err(Error("kind must be invoice or receipt".into())); } crate::pay::parse_atomic(p.amount_atomic).map_err(|e| Error(e.to_string()))?; crate::pay::check_address(p.address).map_err(|e| Error(e.to_string()))?; let friend_id: i64 = self .conn .query_row( "SELECT id FROM friends WHERE pubkey = ?1", params![friend_pk], |row| row.get(0), ) .map_err(|_| Error("friend not found".into()))?; self.conn.execute( "INSERT INTO payments (friend_id, dir, kind, amount_atomic, address, memo, txid, verified, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ friend_id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, i64::from(p.verified), unix_now() ], )?; Ok(self.conn.last_insert_rowid()) } pub fn list_payments(&self, friend_pk: &[u8]) -> Result> { let mut stmt = self.conn.prepare( "SELECT p.id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, p.verified, p.created_at FROM payments p JOIN friends f ON f.id = p.friend_id WHERE f.pubkey = ?1 ORDER BY p.id", )?; let rows = stmt.query_map(params![friend_pk], payment_from_row)?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } pub fn mark_verified(&self, id: i64) -> Result { let n = self.conn.execute( "UPDATE payments SET verified = 1 WHERE id = ?1", params![id], )?; Ok(n > 0) } pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> { if dir != "in" && dir != "out" { return Err(Error("dir must be in or out".into())); } let friend_id: i64 = self .conn .query_row( "SELECT id FROM friends WHERE pubkey = ?1", params![friend_pk], |row| row.get(0), ) .map_err(|_| Error("friend not found".into()))?; let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; self.conn.execute( "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", params![friend_id, dir, blob, unix_now()], )?; Ok(()) } pub fn list_messages(&self, friend_pk: &[u8]) -> Result> { let mut stmt = self.conn.prepare( "SELECT m.dir, m.plaintext FROM messages m JOIN friends f ON f.id = m.friend_id WHERE f.pubkey = ?1 ORDER BY m.id", )?; let rows = stmt.query_map(params![friend_pk], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) })?; let mut out = Vec::new(); for row in rows { let (dir, blob) = row?; let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) .map_err(|_| Error("message decrypt failed".into()))?; out.push(Message { dir, plaintext }); } Ok(out) } /// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay. pub fn wipe_messages(&self) -> Result<()> { self.conn.execute( "UPDATE messages SET plaintext = zeroblob(length(plaintext))", [], )?; self.conn.execute("DELETE FROM messages", [])?; self.conn.execute_batch("VACUUM")?; Ok(()) } /// Delete the data dir. Next `open_at` is a new identity. Caller must drop Store first. pub fn wipe_all(home: &Path) -> Result<()> { if home.exists() { fs::remove_dir_all(home)?; } Ok(()) } } fn payment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Payment { id: row.get(0)?, dir: row.get(1)?, kind: row.get(2)?, amount_atomic: row.get(3)?, address: row.get(4)?, memo: row.get(5)?, txid: row.get(6)?, verified: row.get::<_, i64>(7)? != 0, created_at: row.get(8)?, }) } fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(FriendProfile { display_name: row.get(0)?, bio: row.get(1)?, xmr_addr: row.get(2)?, updated_at: row.get(3)?, }) } fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Friend { pubkey: row.get(0)?, fingerprint: row.get(1)?, petname: row.get(2)?, onion: row.get(3)?, last_connect_ok: row.get::<_, i64>(4)? != 0, prekey: row.get(5)?, }) } fn gen_prekey() -> (Vec, Vec) { let sk = StaticSecret::random_from_rng(OsRng); let pk = X25519Public::from(&sk); (sk.to_bytes().to_vec(), pk.to_bytes().to_vec()) } fn home_dir() -> Result { if let Some(p) = std::env::var_os("ONIONWIRE_HOME") { return Ok(PathBuf::from(p)); } let home = std::env::var_os("HOME").ok_or_else(|| Error("HOME not set".into()))?; Ok(PathBuf::from(home).join(".local/share/onionwire")) } fn passphrase_from_env() -> Result { match std::env::var("ONIONWIRE_STORE_PASSPHRASE") { Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())), Ok(p) => Ok(p), Err(_) => Err(Error("ONIONWIRE_STORE_PASSPHRASE required".into())), } } fn mkdir_700(path: &Path) -> Result<()> { fs::create_dir_all(path)?; let mut perms = fs::metadata(path)?.permissions(); perms.set_mode(0o700); fs::set_permissions(path, perms)?; Ok(()) } fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } fn fingerprint(pubkey: &[u8]) -> String { pubkey.iter().map(|b| format!("{b:02x}")).collect() } #[cfg(test)] mod at_rest { use super::*; #[test] fn first_unlock_reencrypts_legacy_plaintext_rows() { let dir = tempfile::tempdir().unwrap(); let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap(); let pk = [1u8; 32]; store.upsert_friend(&pk, "a.onion", None).unwrap(); store .append_message(&pk, "out", b"legacy-plain-xyz") .unwrap(); store.conn.execute("DELETE FROM store_meta", []).unwrap(); store .conn .execute( "UPDATE messages SET plaintext = ?1", params![b"legacy-plain-xyz".as_slice()], ) .unwrap(); let _ = store.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)"); drop(store); let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap(); let msgs = store.list_messages(&pk).unwrap(); assert_eq!(msgs[0].plaintext, b"legacy-plain-xyz"); drop(store); let needle = b"legacy-plain-xyz"; for name in ["onionwire.db", "onionwire.db-wal", "onionwire.db-shm"] { let path = dir.path().join(name); let Ok(bytes) = std::fs::read(&path) else { continue; }; assert!( !bytes.windows(needle.len()).any(|w| w == needle), "{name} still contains legacy plaintext" ); } } }