Make the scrub work off /usr/bin, and stop a log level deciding whether it runs
H3 — the pre-commit scrub was a silent no-op on any base image whose coreutils
are not under /usr/bin. Hardening against a PATH-planted `stat` shim by naming
every tool absolutely bought nothing the `PATH` reset on the first line had not
already bought — the shim lives in the persisted home volume, and uid 1000
cannot write /usr/bin or /bin — and it cost the whole feature on Alpine, which
Settings -> Docker -> Custom accepts. Measured on one seeded tree in a real
container: the absolute-path script printed `###TRIPLE-C-SCRUBBED 0` and left
every planted file in place; the PATH-resolved one reclaims 77824 bytes. The
default image is unchanged at 521038.
It was silent three times over, and all three are fixed:
* The script now probes for all six things it needs (`command -v` for the five
tools, plus the root device id reading back as a number) and, if any is
missing, prints `###TRIPLE-C-SCRUB-UNAVAILABLE <what>` and no total at all.
* `scrub_writable_layer` reads that marker first and returns a new
`ScrubOutcome::Unavailable`, warning with what the image is missing; a
genuine `Reclaimed(0)` now leaves a debug line rather than nothing.
* `commit_log_suffix` renders `Reclaimed(0)` as "ran and found nothing to drop"
rather than "0.00 MB dropped", which is what a scrub that could not run used
to look like.
Verified in real containers: the mount-at-the-match defence still holds with a
home-volume `stat` shim first on PATH (the volume survives; deleting the PATH
reset from the same script empties it, so the harness can tell the difference).
H2 — the pre-migration scrub had been folded into `log::info!`'s argument list
to satisfy `#[must_use]`. `log::info!` expands to `if Info <= max_level() { … }`,
so the awaited scrub lived inside the level check, and `logging::init`
tolerates `dispatch.apply()` failing — which returns before `set_max_level` and
leaves the process at `Off`. In that state the scrub never ran and the layer
was committed into the longest-lived snapshot the app takes. The outcome is
bound first now, `logging::init` restores the level on failure and says so on
stderr, and a test scans all four files for an `.await` inside any `log::*!`
argument list.
Also:
* `reconcile_migration` deferred a held project instead of dropping it. Its
only caller fires once per "Docker became available", so a project held at
that instant was never revisited for the session — phase un-normalised, no
resume or rollback offered, pin left `Claimed`. It now waits for the holder
to let go (20s x 90, one waiter per project) and reconciles then.
* The scrub's byte total counts what a partly failed `rm` removed, by
re-measuring rather than dropping the whole subtree on a non-zero exit —
which was exactly the `--one-file-system` case.
* The scrub exec blanks `LD_PRELOAD`, `LD_AUDIT` and `LD_LIBRARY_PATH`.
`LD_PRELOAD` is in none of the reserved env families, so a project's custom
env var reached a root exec and injected code into every tool the scrub runs,
`PATH` reset or not. Verified against Engine 29.7 that `docker exec -e` wins.
* The device test is described honestly: it is a mount test under `overlay2`
and not under `vfs`, where checks 1 and 2 are what still hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The level the dispatch is built with, and the level restored by hand if
|
||||
/// installing it fails — see the failure branch in [`init`] for why that
|
||||
/// matters more than it looks.
|
||||
const LOG_LEVEL: log::LevelFilter = log::LevelFilter::Info;
|
||||
|
||||
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||
@@ -33,7 +38,7 @@ pub fn init() {
|
||||
message
|
||||
))
|
||||
})
|
||||
.level(log::LevelFilter::Info)
|
||||
.level(LOG_LEVEL)
|
||||
.chain(std::io::stderr());
|
||||
|
||||
if let Some((_path, file)) = &log_file_path {
|
||||
@@ -41,7 +46,28 @@ pub fn init() {
|
||||
}
|
||||
|
||||
if let Err(e) = dispatch.apply() {
|
||||
eprintln!("Failed to initialise logger: {}", e);
|
||||
// H2's other half. `fern::Dispatch::apply` calls `log::set_boxed_logger`
|
||||
// and only then `log::set_max_level`, so a failure returns with the
|
||||
// global filter still at its default, `LevelFilter::Off`. That is not
|
||||
// merely "no log output": every `log::info!(…)` expands to
|
||||
// `if Info <= max_level() { … }`, so at `Off` the macro never evaluates
|
||||
// its own arguments. Anything a call site put in an argument list —
|
||||
// a function call, an `await`, a side effect — silently stops
|
||||
// happening, app-wide, because a logger could not be installed.
|
||||
//
|
||||
// Call sites must not put effects in log arguments (see the
|
||||
// pre-migration scrub in `migration_commands.rs`), but "the whole
|
||||
// program's log macros are dead and nothing said so" is its own
|
||||
// hazard, so the level this dispatch was configured with is restored
|
||||
// by hand. Nothing is listening — `log`'s default logger is a no-op —
|
||||
// but the macros evaluate, and the one thing that *is* guaranteed to
|
||||
// reach the user, the stderr line below, says what happened.
|
||||
eprintln!(
|
||||
"Failed to initialise logger: {}. Log output is disabled for this run; \
|
||||
log macros still evaluate their arguments.",
|
||||
e
|
||||
);
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
}
|
||||
|
||||
// Install a panic hook that writes to the log file so crashes are captured.
|
||||
@@ -71,3 +97,40 @@ pub fn init() {
|
||||
log::info!("Logging to {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_logger_that_could_not_be_installed_still_leaves_the_macros_evaluating() {
|
||||
// H2: `log::info!(…)` expands to `if Info <= max_level() { … }`, so at
|
||||
// `LevelFilter::Off` the arguments are never evaluated. `fern` returns
|
||||
// before `set_max_level` when `apply()` fails, which leaves exactly
|
||||
// that state — and a call site that folded an effect into an argument
|
||||
// list then stops performing it, app-wide, because a log file could not
|
||||
// be opened. The failure branch restores the level for that reason.
|
||||
//
|
||||
// Asserted on the level itself rather than by driving `init`, which
|
||||
// installs a process-global logger and a panic hook and can only run
|
||||
// once per process.
|
||||
assert_ne!(LOG_LEVEL, log::LevelFilter::Off);
|
||||
|
||||
// The property that makes the above worth asserting, demonstrated
|
||||
// against the macro itself: a side effect in an argument list runs only
|
||||
// while the level admits the record.
|
||||
let mut ran = false;
|
||||
let effect = |v: &mut bool| {
|
||||
*v = true;
|
||||
0
|
||||
};
|
||||
let previous = log::max_level();
|
||||
log::set_max_level(log::LevelFilter::Off);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(!ran, "the premise is wrong: arguments evaluated at LevelFilter::Off");
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(ran, "arguments did not evaluate at the level this module configures");
|
||||
log::set_max_level(previous);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user