Keep the log after the session that produced it
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 fine when the person debugging is sitting at the machine. It is useless for the case NFR-OPS-1 actually describes, and the one Android makes normal: somebody reproduces a bug on a tablet, and then sends us a file. A large amount of Android behaviour has never run on a device — image intents read over JNI, an ExportProvider, a class loaded through the activity's class loader, memory-pressure eviction, lost-root recovery — and the single most likely failure of the lot, the activity's loader not resolving our classes from android_main's thread, produces one line that scrolls past. That line is now in a file, with the thread that emitted it named beside it. dr_plat::state answers "where does this platform keep state for this app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the entry point declares. Separate from configuration and from the catalog for the reason XDG separates them — state is the thing nobody backs up and the user may delete without consequence. dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case is a number rather than a discovery on a full phone; one line per write with no BufWriter anywhere, because on Android processes are killed rather than ended and a buffered log loses exactly the line it was kept for; and redaction applied at the sink rather than at the call sites, since a rule every author has to remember is not a rule. It tees the platform's own logger rather than replacing it, so logcat is unchanged — losing that while debugging would have made this a downgrade. Android logs to external_data_path, not internal. Both are app-private and both survive backgrounding; what separates them is that /data/data/<pkg>/files needs run-as against a debuggable build to read and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build. A log nobody can retrieve is not a diagnostic. The consequence is that anyone holding the tablet can read it, which is why the redaction is where it is, and why configuration stays on internal_data_path. What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and tokens, found by the keyword that nearly always sits next to them, plus the two forms that carry one with no keyword at all — an Authorization scheme and a URL's userinfo. What is deliberately not redacted is filesystem paths and the names of the user's photographs. They are in neither requirement's list, and "failed to decode <redacted>" is not a diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those better than scrubbing would, because it lets the user look. The over-redaction failure is tested as carefully as the under-redaction one. A scrubber that eats "using basic sRGB as the fallback" makes a log useless without ever being caught.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
//! TRACES: NFR-OPS-1
|
||||
//! Where this platform lets the application keep notes about itself.
|
||||
//!
|
||||
//! Not the catalog, not the photographs, not the user's configuration — those
|
||||
//! three already have homes (`dr_sync::account::config_dir`,
|
||||
//! `dr_ui::library::data_root`, and the sidecars beside the images). This is
|
||||
//! the fourth thing: the diagnostic residue an application leaves so that a
|
||||
//! failure yesterday can be read about today. A log, and — when the crash
|
||||
//! path lands — a crash record.
|
||||
//!
|
||||
//! # Why it is its own directory and not one of the other three
|
||||
//!
|
||||
//! XDG separates them for a reason that is not tidiness. `$XDG_CONFIG_HOME`
|
||||
//! is what the user has chosen and would miss; `$XDG_DATA_HOME` is what the
|
||||
//! application built and would have to rebuild; `$XDG_STATE_HOME` is
|
||||
//! "state that should persist between restarts but is not important or
|
||||
//! portable enough for the data directory" — which is exactly a log file. It
|
||||
//! is also the directory nobody backs up, and a log is the one file here we
|
||||
//! actively want a user to be able to delete without consequence.
|
||||
//!
|
||||
//! # Android has none of those variables, so the platform must say
|
||||
//!
|
||||
//! There is no `$HOME` on Android and no XDG anything (ARCH §6.9), so the
|
||||
//! guesses below resolve to a path the app cannot write. `dr_sync::account`
|
||||
//! learned this the expensive way — the session list went to a doomed path,
|
||||
//! nothing failed loudly, and backgrounding the app lost the sign-in — and the
|
||||
//! shape of the fix is copied here deliberately: the platform entry point
|
||||
//! declares the directory once, before anything opens a file in it.
|
||||
//!
|
||||
//! **Which Android directory is a diagnostics decision, and it belongs to the
|
||||
//! caller.** `AndroidApp` offers two, and they differ in precisely the way
|
||||
//! that matters here:
|
||||
//!
|
||||
//! * `internal_data_path` — `/data/data/<pkg>/files`. Private, durable, and
|
||||
//! unreachable: pulling a file out of it needs `run-as` against a debuggable
|
||||
//! build, or root.
|
||||
//! * `external_data_path` — `/sdcard/Android/data/<pkg>/files`. Same lifetime
|
||||
//! (the system deletes it with the app, not under storage pressure — that is
|
||||
//! the *cache* directory), needs no permission since API 19, and `adb pull`
|
||||
//! reads it from any build.
|
||||
//!
|
||||
//! A log nobody can retrieve is not a diagnostic, so `darkroom-android` points
|
||||
//! this at the external one. That choice has a consequence, and it is the
|
||||
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
|
||||
//! sites: everything written here is readable by anyone holding the device.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Declared once by the platform entry point; a guess otherwise.
|
||||
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Declare where this platform keeps application state.
|
||||
///
|
||||
/// Call it before anything opens a file — installing the logger is normally
|
||||
/// the very next line — because a later call is *ignored* rather than obeyed.
|
||||
/// That is deliberate: two callers disagreeing about the directory would
|
||||
/// otherwise split the log across two files depending on which ran first, and
|
||||
/// a silently-ignored second call leaves one log rather than two halves.
|
||||
///
|
||||
/// Desktop needs no call. The XDG resolution below is correct there.
|
||||
pub fn set_state_dir(dir: PathBuf) {
|
||||
let _ = STATE_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// The directory this application's state belongs in.
|
||||
///
|
||||
/// It is not created here. Whoever writes into it creates it, so that merely
|
||||
/// asking the question leaves nothing behind on a machine that never logs.
|
||||
pub fn state_dir() -> PathBuf {
|
||||
if let Some(dir) = STATE_DIR.get() {
|
||||
return dir.clone();
|
||||
}
|
||||
xdg_state_dir(
|
||||
std::env::var_os("XDG_STATE_HOME"),
|
||||
std::env::var_os("HOME"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The XDG resolution, as a function of its inputs rather than of the process
|
||||
/// environment, so it can be tested without `set_var` racing every other test
|
||||
/// in the binary.
|
||||
///
|
||||
/// Relative values are ignored rather than resolved against the working
|
||||
/// directory: the base-directory specification says so explicitly, and the
|
||||
/// alternative is a `darkroom/` directory appearing wherever the app was
|
||||
/// launched from.
|
||||
fn xdg_state_dir(xdg_state_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
|
||||
xdg_state_home
|
||||
.filter(|value| Path::new(value).is_absolute())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
home.filter(|value| Path::new(value).is_absolute())
|
||||
.map(|value| PathBuf::from(value).join(".local/state"))
|
||||
})
|
||||
// A container or a systemd unit with neither variable set. Writing a
|
||||
// log into `/tmp` is a poor outcome; refusing to log at all, on the
|
||||
// one kind of machine nobody is sitting in front of, is a worse one.
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("darkroom")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_log_goes_under_the_state_directory_the_user_named() {
|
||||
// NFR-OPS-1 says "the XDG state directory", and honouring
|
||||
// $XDG_STATE_HOME is the whole of what that means to a user who has
|
||||
// moved theirs.
|
||||
let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into()));
|
||||
assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_variable_it_is_the_specifications_default() {
|
||||
let dir = xdg_state_dir(None, Some("/home/someone".into()));
|
||||
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_value_is_ignored_rather_than_resolved() {
|
||||
// The specification requires this, and the failure it prevents is a
|
||||
// `darkroom/` directory appearing in whatever the working directory
|
||||
// happened to be — including, on a desktop launcher, `/`.
|
||||
let dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into()));
|
||||
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
||||
|
||||
let dir = xdg_state_dir(Some("".into()), Some("".into()));
|
||||
assert!(
|
||||
dir.is_absolute(),
|
||||
"an empty HOME must not produce a relative state directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_directory_is_declared_once() {
|
||||
// The property the entry points rely on: two callers cannot split the
|
||||
// log in half. Exercised on a fresh `OnceLock` rather than the global
|
||||
// one, which any other test in this binary may already have set.
|
||||
let cell: OnceLock<PathBuf> = OnceLock::new();
|
||||
assert!(cell.set(PathBuf::from("/first")).is_ok());
|
||||
assert!(cell.set(PathBuf::from("/second")).is_err());
|
||||
assert_eq!(cell.get(), Some(&PathBuf::from("/first")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user