Files
dtourolle 872e35670c Log like a debug build on macOS, where a Mac user can find it
Nobody working on DarkRoom has a Mac, so every macOS build is in the hands
of someone who can send a log and cannot attach a debugger. Three changes
make that log worth sending:

- The desktop's default filter on macOS is `debug` for every `dr_*` crate,
  the desktop crate and `onnxruntime` (the runtime's own session log).
- The state directory — the log and crash records — is `~/Library/Logs`
  on macOS rather than the `~/.local/state` Finder hides; Console.app
  lists it. Config and data keep the Unix rules.
- A `diagnostic` cargo profile: release plus line tables, so a crash
  record's backtrace reads file:line. On macOS the tables are in the
  `.dSYM` beside the executable, which the bundle must keep.
2026-10-03 16:49:58 -04:00

744 lines
30 KiB
Rust

//! TRACES: NFR-OPS-1 | NFR-SEC-2
//! A log the photographer still has tomorrow.
//!
//! Until this existed, everything this application knew about a failure went
//! to stderr on the desktop and to logcat on Android, and both are gone the
//! moment the terminal closes or the ring buffer wraps. That is tolerable when
//! the person debugging is sitting at the machine. It is useless for the case
//! this module is built for and which NFR-OPS-1 describes: **somebody
//! reproduces a bug on a tablet, and then sends us a file.**
//!
//! Android makes that the *normal* case rather than an awkward one. A
//! `NativeActivity` has no console. `adb logcat` requires a cable, developer
//! mode, and — crucially — that you were already watching when it happened.
//! The failures most worth catching are the ones that are one line long and
//! scroll past: a class the activity's loader could not find, a permission
//! that was refused, a root the system revoked while the app was backgrounded.
//! A file survives all three.
//!
//! # The shape of it
//!
//! One [`LogFile`] in [`crate::state::state_dir`], teed with whatever logger
//! the platform already had rather than replacing it — logcat stays exactly as
//! it was, which matters because losing it while debugging would make this a
//! downgrade. [`install`] is the whole of the wiring.
//!
//! Four properties, each of which is a test below — the first of them only
//! half-testable off a device, and the test says which half:
//!
//! * **Retrievable.** Created with a mode that the way *this* platform gets a
//! file off the machine can actually open: `FILE_MODE`, the one number here
//! that differs by platform, and the only one whose reasoning is about the
//! directory above the file rather than about the file. Get it wrong and the
//! other three properties are worth nothing, because nobody ever reads the
//! log.
//! * **Capped.** Two files of [`MAX_FILE_BYTES`], so the worst case is stated
//! rather than discovered on a full phone.
//! * **Redacted at the sink** ([`redact`]), not at the call sites. A rule
//! every author has to remember is not a rule.
//! * **Durable per line.** No `BufWriter` anywhere, which is deliberate and
//! costs a syscall per record: the line worth having is the last one before
//! the process died, and on Android processes do not die politely — the
//! system kills them, with no unwinding and no `Drop`. A buffered log loses
//! precisely the evidence it was kept for.
//!
//! # Where the file lands
//!
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
//! `~/.local/state/darkroom/darkroom.log`.
//! * macOS: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
//! `~/Library/Logs/darkroom/darkroom.log`, where Console.app lists it.
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
//! which `adb pull` reads from an ordinary release build. See
//! [`crate::state`] for why not the internal directory, and
//! [`redact`] for the consequence.
//!
//! # How crash capture should reach this
//!
//! It should not open its own file. A panic hook that calls `log::error!`
//! lands here like anything else, and lands *completely*, because writes are
//! unbuffered and per record — that is the property NFR-OPS-2 needs from
//! NFR-OPS-1 and the reason the two are not the same file. For a crash record
//! written separately, [`log_path`] says which log belongs with it and
//! [`redact::redact`] is public so the record gets the same treatment as the
//! lines around it.
pub mod bundle;
pub mod redact;
use std::fmt::Write as _;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use log::{LevelFilter, Log, Metadata, Record};
/// The current log, and — after one rotation — `darkroom.log.1` beside it.
const FILE_NAME: &str = "darkroom.log";
/// The stated cap, per file. With [`RETAINED_GENERATIONS`] the application
/// occupies at most 8 MiB of the state directory, ever.
///
/// Chosen against the retrieval, not against the disk: this is roughly forty
/// thousand lines at `info`, which is a long session's worth of context, and
/// it is small enough that `adb pull` over USB is instant and a user can
/// attach it to a message without thinking about it. A larger cap would buy
/// history nobody reads at the price of a file nobody sends.
pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
/// How many rotated files are kept behind the current one.
///
/// One. The reason to keep any is that the interesting event often precedes
/// the rotation that a burst of retry logging then caused; the reason not to
/// keep more is that the worst case has to be a number this module can state.
pub const RETAINED_GENERATIONS: usize = 1;
/// The longest one record may be before it is cut short.
///
/// A guard against a single `{:?}` on something large — a decoded buffer, a
/// face embedding, an entire WebDAV response — turning the whole cap into one
/// unreadable line and evicting everything that led up to it. Generous enough
/// that no real message reaches it.
pub const MAX_LINE_BYTES: usize = 8 * 1024;
const TRUNCATION_MARK: &str = "…[truncated]";
/// Set by [`install`] once, so crash capture can name the file without
/// re-deriving the directory and possibly disagreeing about it.
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
/// What [`install`] managed, said as data rather than as a log line — because
/// at the moment it is decided there is no logger to say it with.
///
/// The caller logs it as the first line of the session, which is how the file
/// ends up naming itself to anyone reading logcat over the user's shoulder.
#[derive(Debug, Clone)]
pub enum Installed {
/// The tee is live and this is the file to ask the user for.
ToFile(PathBuf),
/// Logging works, but only where it always did, and this is why. A
/// read-only or missing state directory is not a reason to start the
/// application without a logger.
ConsoleOnly(String),
}
/// Where this session is logging, if it is logging to a file at all.
pub fn log_path() -> Option<PathBuf> {
LOG_PATH.get().cloned()
}
/// Install the file sink alongside the logger the platform already provides.
///
/// `console` is the platform's own logger — `env_logger`'s on the desktop,
/// `android_logger`'s on a device — and it keeps receiving every record it
/// would have received. `level` is the maximum level to let through globally;
/// pass whatever the console logger was built to filter at, so the two agree.
///
/// Call it exactly once, first thing, and *after*
/// [`crate::state::set_state_dir`] on any platform that needs to declare a
/// directory.
pub fn install(console: Box<dyn Log>, level: LevelFilter) -> Installed {
let dir = crate::state::state_dir();
let (file, outcome) = match LogFile::open_in(&dir, MAX_FILE_BYTES) {
Ok(file) => {
let path = file.path().to_path_buf();
let _ = LOG_PATH.set(path.clone());
(Some(file), Installed::ToFile(path))
}
Err(e) => (
None,
Installed::ConsoleOnly(format!("{} is not writable: {e}", dir.display())),
),
};
if log::set_boxed_logger(Box::new(Tee { console, file })).is_err() {
// Something installed a logger before us. Saying so is the only useful
// response: whatever it was is still logging, and the file is not.
return Installed::ConsoleOnly("a logger was already installed".to_string());
}
log::set_max_level(level);
outcome
}
/// The platform's logger and ours, both fed from every record.
struct Tee {
console: Box<dyn Log>,
file: Option<LogFile>,
}
impl Log for Tee {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
self.console.enabled(metadata)
}
fn log(&self, record: &Record<'_>) {
// The console first, and unconditionally: it applies its own filter,
// and on Android it is the thing a developer is watching live.
self.console.log(record);
if let Some(file) = &self.file {
// The same gate the console applies, so the file is a transcript
// of logcat rather than a different log with different contents.
// It can be a slight superset — `env_logger`'s message-regex
// filter is applied inside its `log` and not by `enabled` — and a
// superset is the right way round for a file nobody is watching.
if self.console.enabled(record.metadata()) {
file.write_line(&format_record(record));
}
}
}
fn flush(&self) {
self.console.flush();
// Nothing to do for the file: there is no buffer to flush, which is
// the point. See the module documentation.
}
}
/// The log file itself, with its rotation.
///
/// Cheap to construct and safe to share: every write takes a mutex, and the
/// formatting and redaction that precede it happen outside the lock, so a
/// thread logging cannot hold up an unrelated one for longer than a `write`.
pub struct LogFile {
path: PathBuf,
/// The single retained generation. Named once here rather than derived at
/// each rotation, so the two cannot drift.
previous: PathBuf,
cap: u64,
open: Mutex<Open>,
}
struct Open {
file: File,
/// Bytes in the *current* file. Seeded from the file's length on open
/// rather than from zero — otherwise an application restarted often enough
/// would append past the cap forever and NFR-OPS-1's "size-capped" would
/// hold only within a single run.
written: u64,
}
impl LogFile {
/// Open (or create) the log in `dir`, creating the directory if needed.
///
/// `cap` is per file; the directory holds at most `cap * 2`. Public with
/// an explicit cap because the tests need a small one — a rotation test
/// that had to write 4 MiB would be a rotation test nobody runs.
pub fn open_in(dir: &Path, cap: u64) -> io::Result<Self> {
fs::create_dir_all(dir)?;
let path = dir.join(FILE_NAME);
let file = open_append(&path)?;
// A failure to stat a file we just opened is not worth refusing to log
// over; treating it as empty costs at most one late rotation.
let written = file.metadata().map(|m| m.len()).unwrap_or(0);
Ok(Self {
path,
previous: dir.join(format!("{FILE_NAME}.1")),
cap,
open: Mutex::new(Open { file, written }),
})
}
/// The file to ask the user to send.
pub fn path(&self) -> &Path {
&self.path
}
/// Redact, cap, and append one record.
///
/// Infallible by design. A logger that panicked, or that tried to report
/// its own failure through `log`, would take the application down or
/// recurse; a log line lost to a full disk is a smaller problem than
/// either.
pub fn write_line(&self, line: &str) {
// Both before the lock: neither can block, and neither should make
// another thread's `write` wait.
let mut line = redact::redact(line);
truncate_to(&mut line, MAX_LINE_BYTES);
line.push('\n');
// A poisoned mutex means another thread panicked mid-write. The log is
// exactly what is wanted at that moment, so carry on with whatever
// state it left: the worst case is one interleaved line.
let mut open = self.open.lock().unwrap_or_else(|e| e.into_inner());
let len = line.len() as u64;
if open.written > 0 && open.written + len > self.cap {
// A failed rotation is not a reason to drop the record. The only
// way `rename` fails on a directory we opened a file in is that
// the directory has gone, in which case the write below fails too
// and the line is lost either way.
let _ = self.rotate(&mut open);
}
if open.file.write_all(line.as_bytes()).is_ok() {
open.written += len;
}
}
/// `darkroom.log` becomes `darkroom.log.1`; the previous `.1` is dropped.
///
/// Rename rather than copy-and-truncate, so a reader holding the old file
/// keeps reading a whole file and never sees it shrink under them. The old
/// descriptor stays valid against the renamed inode until it is replaced
/// on the next line.
fn rotate(&self, open: &mut Open) -> io::Result<()> {
let _ = fs::remove_file(&self.previous);
fs::rename(&self.path, &self.previous)?;
open.file = open_append(&self.path)?;
open.written = 0;
Ok(())
}
}
/// The mode the log is created with, on the desktop.
///
/// `$XDG_STATE_HOME/darkroom` is inside a home directory on a machine that may
/// have other accounts on it, and this file names the user's photographs and
/// the shape of their library. Nothing about the directory above it stops
/// another local user reading a world-readable file, so the owner is the only
/// reader who has any business with it.
#[cfg(all(unix, not(target_os = "android")))]
const FILE_MODE: u32 = 0o600;
/// The mode the log is created with, on Android — deliberately not the
/// desktop's, because "who may read this" has a different answer here.
///
/// **The directory is the access control, and the file being readable does not
/// widen it.** `/sdcard/Android/data` is `drwxrws--x media_rw:ext_data_rw`: no
/// other application can enumerate or enter this application's subdirectory,
/// and anyone who can traverse it is holding the unlocked tablet, which
/// already gets them the photographs the log merely names.
///
/// What the group and other read bits buy is the entire reason
/// [`crate::state`] puts the file on external storage rather than in
/// `/data/data`: **`adb pull` runs as `shell`**, which can traverse a `--x`
/// directory but must then open the file as *other*. At `0o600` it cannot —
/// and `run-as` is refused on a build that is not debuggable, which a release
/// build is not — so every route off the device is closed at once and the log
/// is a diagnostic nobody can be sent. That is the one outcome the choice of
/// directory exists to prevent.
#[cfg(target_os = "android")]
const FILE_MODE: u32 = 0o644;
fn open_append(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(FILE_MODE);
}
let file = options.open(path)?;
// And then again, explicitly, because the line above is a *request*: the
// kernel ANDs it with the process umask, and an Android application
// process inherits `0o077` from the zygote. Asking for `0o644` there
// creates `0o600`, silently, and `OpenOptions::mode` is ignored outright
// on a file that already exists — between them, that is how a log written
// for `adb pull` ended up unreadable by it. `fchmod` is subject to
// neither.
//
// Ignored on failure rather than refused. A file this process does not
// own, or a filesystem with no modes to set, is a reason to log without
// the mode; it is not a reason to log nowhere.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = file.set_permissions(fs::Permissions::from_mode(FILE_MODE));
}
Ok(file)
}
/// One record as one line.
///
/// The thread name is here and not in `env_logger`'s default format because of
/// what it costs to leave out on Android: the failure this module was written
/// to catch is a class the activity's loader will not resolve, and whether the
/// call came from `android_main`'s thread or from a worker is the entire
/// diagnosis. Unnamed threads report their id instead of nothing, so two
/// interleaved workers can still be told apart.
fn format_record(record: &Record<'_>) -> String {
let mut line = String::with_capacity(128);
push_timestamp(&mut line, SystemTime::now());
let thread = std::thread::current();
let _ = match thread.name() {
Some(name) => write!(line, " {:<5} [{name}]", record.level()),
None => write!(line, " {:<5} [{:?}]", record.level(), thread.id()),
};
let _ = write!(line, " {}: {}", record.target(), record.args());
line
}
/// `2026-08-30T12:34:56.789Z`, in UTC.
///
/// Hand-rolled rather than reached for from a crate. `chrono` and `jiff` are
/// both already in the lock file, and pulling either into `dr-plat` would put
/// a new dependency in the Android cross-compile for twenty lines of
/// arithmetic that cannot change — which is the same reasoning the workspace
/// manifest applies to TLS, SQLite and lens profiles, at a much smaller scale.
///
/// UTC and not local time, deliberately: a log is read by somebody other than
/// the person who produced it, and a timestamp with no zone in it is a
/// timestamp that has to be guessed at. Milliseconds are there to line the
/// file up against a logcat capture of the same session.
fn push_timestamp(out: &mut String, now: SystemTime) {
let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = since_epoch.as_secs() as i64;
let millis = since_epoch.subsec_millis();
// `div_euclid`, not `/`: the truncating division would put every instant
// in the six hours before 1970 on the wrong day. A clock that has not been
// set yet reports one of them.
let (year, month, day) = civil_from_days(secs.div_euclid(86_400));
let second_of_day = secs.rem_euclid(86_400);
let _ = write!(
out,
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
second_of_day / 3600,
(second_of_day % 3600) / 60,
second_of_day % 60,
);
}
/// Days since 1970-01-01 to a proleptic Gregorian date.
///
/// Howard Hinnant's `civil_from_days`, which is the algorithm every date
/// library uses underneath: it shifts the epoch to 0000-03-01 so that the
/// leap day falls at the end of the year and the 400-year cycle divides
/// evenly, which is what removes every branch.
fn civil_from_days(days: i64) -> (i64, i64, i64) {
let z = days + 719_468;
// Split out rather than written inline: `if a { x } else { y } / n` is a
// parse hazard in Rust, and getting it wrong here would be a silent
// off-by-a-century for dates before 1970 rather than a compile error.
let toward_negative_infinity = if z >= 0 { z } else { z - 146_096 };
let era = toward_negative_infinity / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_shifted = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_shifted + 2) / 5 + 1;
let month = if month_shifted < 10 {
month_shifted + 3
} else {
month_shifted - 9
};
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Cut a line to `max` bytes without splitting a character in half.
fn truncate_to(line: &mut String, max: usize) {
if line.len() <= max {
return;
}
let mut cut = max.saturating_sub(TRUNCATION_MARK.len());
while cut > 0 && !line.is_char_boundary(cut) {
cut -= 1;
}
line.truncate(cut);
line.push_str(TRUNCATION_MARK);
}
#[cfg(test)]
mod tests {
use super::*;
/// An empty directory of this test's own. The convention elsewhere in the
/// workspace, and it matters more here: two
/// tests sharing a log directory would rotate each other's files.
fn a_log_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("darkroom-diagnostics-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("a writable temp directory");
dir
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_default()
}
/// Long enough that a handful of them cross a small cap, short enough that
/// the arithmetic in each test stays readable.
fn a_line(n: usize) -> String {
format!("{n:04} scanning /home/duncan/Pictures/2026/Iceland for images")
}
/// The permission bits of a file, as an octal number to compare against.
#[cfg(unix)]
fn mode_of(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt as _;
fs::metadata(path).expect("a log").permissions().mode() & 0o777
}
#[cfg(unix)]
#[test]
fn the_log_is_created_with_the_mode_its_platform_can_retrieve_it_by() {
let dir = a_log_dir("mode");
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
log.write_line("a line the user will be asked for");
// Written as a literal rather than compared against `FILE_MODE`: a
// test that reads the same constant the code writes agrees with
// whatever the constant says, which is not a test of anything.
//
// **Only one of these two arms can ever run.** The Android one is the
// one that matters — `0o600` there closes `adb pull` and `run-as` at
// once, which is the failure this test exists for — and it cannot run
// under `cargo test`, because the behaviour is a property of a
// filesystem and a umask that only exist on a device. What the desktop
// arm does prove is the other half, and it is not nothing: the log
// must not become group- or world-readable in a home directory
// because somebody made one mode serve both platforms.
#[cfg(target_os = "android")]
assert_eq!(
mode_of(&dir.join(FILE_NAME)),
0o644,
"a log only the app can read cannot be sent to anyone (NFR-OPS-1)"
);
#[cfg(not(target_os = "android"))]
assert_eq!(
mode_of(&dir.join(FILE_NAME)),
0o600,
"the log names the user's library, and every other account on \
this machine can now read it"
);
}
#[cfg(unix)]
#[test]
fn a_log_left_by_an_older_build_is_reopened_with_this_mode() {
// The regression test for the actual bug, and the only one that can
// fail on the host: `OpenOptions::mode` applies at *creation* and is
// ANDed with the umask even then, so on its own it neither fixes an
// existing file nor delivers what it asked for in a process whose
// umask is `0o077`, which every Android application process inherits
// from the zygote.
//
// The explicit `set_permissions` is what makes the mode the one the
// platform needs rather than the one the umask happened to allow, and
// deleting it makes this assertion fail: the file already exists, so
// nothing else here can change it.
use std::os::unix::fs::PermissionsExt as _;
let dir = a_log_dir("mode-existing");
let path = dir.join(FILE_NAME);
fs::write(&path, "written by a build that chose a different mode\n").expect("writes");
fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).expect("chmods");
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
log.write_line("this session");
assert_eq!(
mode_of(&path),
FILE_MODE,
"the mode of an existing log is left as whoever created it left it"
);
}
#[test]
fn the_log_stays_under_its_stated_cap() {
// **This is NFR-OPS-1's "size-capped".** Without rotation a phone left
// running fills its state directory and the failure lands on the user
// as "storage full", days after the logging that caused it.
let dir = a_log_dir("cap");
let cap = 4096;
let log = LogFile::open_in(&dir, cap).expect("opens");
for n in 0..500 {
log.write_line(&a_line(n));
}
let current = fs::metadata(dir.join(FILE_NAME))
.expect("a current log")
.len();
assert!(
current <= cap,
"the current log is {current} bytes, over {cap}"
);
let previous = fs::metadata(dir.join(format!("{FILE_NAME}.1")))
.expect("a rotated log, since 500 lines cannot fit in 4 KiB")
.len();
assert!(
previous <= cap,
"the rotated log is {previous} bytes, over {cap}"
);
assert!(
!dir.join(format!("{FILE_NAME}.2")).exists(),
"only {RETAINED_GENERATIONS} generation is kept; a second would make the \
worst case unstatable"
);
assert!(
current + previous <= cap * 2,
"the whole directory must fit in twice the cap"
);
}
#[test]
fn rotation_keeps_the_newest_lines_and_drops_the_oldest() {
// Which half survives is the whole value of the feature: the user
// reports a bug and the interesting lines are the last ones.
let dir = a_log_dir("newest");
let log = LogFile::open_in(&dir, 4096).expect("opens");
for n in 0..500 {
log.write_line(&a_line(n));
}
let current = read(&dir.join(FILE_NAME));
let previous = read(&dir.join(format!("{FILE_NAME}.1")));
assert!(
current.contains(&a_line(499)),
"the last line written must be in the current log"
);
let oldest = a_line(0);
assert!(
!current.contains(&oldest) && !previous.contains(&oldest),
"the oldest lines are what a cap gives up"
);
}
#[test]
fn a_line_survives_a_process_restart() {
// The requirement in one test. Everything else about this module is an
// implementation detail of "the user reproduces it, quits, and sends
// us the file".
let dir = a_log_dir("restart");
{
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
log.write_line("the failure the user is about to report");
} // the process ends here
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("reopens");
log.write_line("the next launch");
let text = read(&dir.join(FILE_NAME));
assert!(
text.contains("the failure the user is about to report"),
"a restart truncated the log: {text}"
);
assert!(text.contains("the next launch"));
assert!(
text.find("the failure") < text.find("the next launch"),
"the second session must append, not prepend"
);
}
#[test]
fn the_cap_survives_a_restart_too() {
// The bug this exists to prevent: seeding `written` from zero on open
// rather than from the file's length. Nothing looks wrong until an
// application that restarts often has a log of unbounded size, which
// is the one failure mode a cap is for.
let dir = a_log_dir("restart-cap");
let cap = 2048;
for _ in 0..40 {
let log = LogFile::open_in(&dir, cap).expect("opens");
for n in 0..5 {
log.write_line(&a_line(n));
}
}
let current = fs::metadata(dir.join(FILE_NAME))
.expect("a current log")
.len();
assert!(
current <= cap,
"forty restarts appended {current} bytes into a {cap}-byte file"
);
}
#[test]
fn a_credential_never_reaches_the_file() {
// **NFR-SEC-2 at the sink**, which is the placement the requirement
// depends on: the call site here has done nothing wrong that a
// reviewer would catch, and the credential still does not land.
let dir = a_log_dir("redaction");
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
log.write_line(r#"login response {"loginName":"duncan","appPassword":"wYh3K8mLpQr2"}"#);
log.write_line("PUT https://duncan:hunter2@cloud.example/remote.php/dav failed");
let text = read(&dir.join(FILE_NAME));
assert!(
!text.contains("wYh3K8mLpQr2"),
"an app password reached the log: {text}"
);
assert!(
!text.contains("hunter2"),
"a URL password reached the log: {text}"
);
assert!(
text.contains("cloud.example") && text.contains("duncan"),
"the diagnostic part must survive the redaction: {text}"
);
}
#[test]
fn one_enormous_record_cannot_evict_the_whole_log() {
// A `{:?}` on something large — a decoded buffer, an embedding — must
// cost a line rather than the file.
let dir = a_log_dir("truncation");
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
log.write_line("a line worth keeping");
log.write_line(&"x".repeat(MAX_LINE_BYTES * 4));
let text = read(&dir.join(FILE_NAME));
assert!(text.contains("a line worth keeping"));
assert!(
text.contains(TRUNCATION_MARK),
"the oversized record should have been cut short"
);
assert!(
text.len() < MAX_LINE_BYTES * 2,
"the oversized record was written whole: {} bytes",
text.len()
);
}
#[test]
fn truncation_never_splits_a_character() {
let mut line = "é".repeat(64);
truncate_to(&mut line, 40);
// The assertion is that this is still a `String` at all — the failure
// is a panic inside `truncate` on a non-boundary index — plus that it
// says it was cut.
assert!(line.ends_with(TRUNCATION_MARK));
assert!(line.len() <= 40);
}
#[test]
fn the_timestamp_is_utc_and_sortable() {
let mut out = String::new();
let an_instant = UNIX_EPOCH + std::time::Duration::from_millis(1_700_000_000_123);
push_timestamp(&mut out, an_instant);
assert_eq!(out, "2023-11-14T22:13:20.123Z");
let mut epoch = String::new();
push_timestamp(&mut epoch, UNIX_EPOCH);
assert_eq!(epoch, "1970-01-01T00:00:00.000Z");
// Lexicographic order is chronological order, which is what makes
// `sort` and a plain `grep` on a date prefix work.
assert!(epoch < out);
}
#[test]
fn the_calendar_handles_the_cases_that_are_usually_wrong() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
// 2024 is a leap year; 2100 will not be, and the century rule is the
// one every hand-rolled calendar gets wrong.
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}
}