Skip to content

Commit 9abb3bd

Browse files
pmaxhoganclaude
andcommitted
fix(chaos): central md5+blake3 s6.3 content invariant + close M3.7 recheck-1
Closes the still-open M3.7 recheck round-1 codex findings (baseline 60d3a1c; finding 1 disk-full was already resolved as an honest documented known-gap): - F2 (P1) central no-data-loss now enforces STRESS_HARNESS s6.3 in full: for every synced row the live object must exist, its md5 must match drive_md5, AND (unencrypted + retained-bytes) the bytes must hash to hash_blake3. Adds blake3 dep + InMemoryRemoteStore::object_content; gates the byte-hash on !encryption_enabled (ciphertext != plaintext hash) and skips oracle-backed huge files (md5 is their proof). Mirrors an md5 check into the drive_side per-category checker. - F3 (P1) drive-fileid-recycled asserts y.id == id_x so a regressed recycler fails instead of passing vacuously on the op-uuid check. - F4 (P1) distinct chaos-fake-drive CI job (3-OS) via run-all --fault-injection + registry::fault_injection_registry (ROADMAP M3.7 / STRESS_HARNESS s7). - F5 (P2) fake trash()/about() use content_len() so oracle-backed objects free their true logical size from bytes_stored. - F6 (P2) mutator-drive-daily-quota runs hermetically via the real with_daily_quota_after injector; drops the real-creds gate + SKIP early-return. - F7 (P2) capability probes target the target/chaos-fixtures volume, not temp. Gates: fmt; build; clippy -D warnings; test --workspace (all green); hermetic 56 PASS/29 SKIP/0 FAIL; fault-injection 27 PASS/0 FAIL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8meqeTo8bcZ3zjgKjBnJ4
1 parent 57e6b97 commit 9abb3bd

10 files changed

Lines changed: 263 additions & 79 deletions

File tree

.github/workflows/chaos.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ name: Chaos
1010
# the NTFS / Win32-shaped rows. None need real Drive or
1111
# elevation (the admin / VSS / real-Drive rows SKIP).
1212
# ROADMAP M3.7 acceptance: Linux + macOS + Windows.
13+
# chaos-fake-drive - every PR, 3-OS matrix. The dedicated fault-injection gate
14+
# (STRESS_HARNESS s7 / ROADMAP M3.7): runs ONLY the s3.7
15+
# Drive-side hazards + s4.2/s5 drive-side mutator faults
16+
# against InMemoryRemoteStore (`run-all --fault-injection`).
17+
# These rows are also in the hermetic sweep; this focused
18+
# gate gives the separately-named, faster fault-injection
19+
# check the acceptance requires.
1320
# chaos-real-drive - WIRED but SKIPPED until M4: gated on the
1421
# DRIVEN_E2E_REFRESH_TOKEN secret, which is absent until
1522
# the GoogleDriveStore (M4) lands. It skips cleanly, it
@@ -69,6 +76,31 @@ jobs:
6976
- name: chaos run-all (hermetic)
7077
run: cargo run -p driven-chaos -- run-all --hermetic
7178

79+
chaos-fake-drive:
80+
name: chaos fake-drive (${{ matrix.os }})
81+
# The dedicated fault-injection gate (ROADMAP M3.7 acceptance: a distinct
82+
# chaos-fake-drive job on Linux + macOS + Windows). Runs ONLY the s3.7 /
83+
# s4.2 / s5 fault-injection subset against InMemoryRemoteStore; capability
84+
# gated rows (the daily-quota real-Drive path) SKIP cleanly, never red.
85+
if: github.event_name != 'schedule'
86+
strategy:
87+
fail-fast: false
88+
matrix:
89+
os: [ubuntu-latest, macos-latest, windows-latest]
90+
runs-on: ${{ matrix.os }}
91+
steps:
92+
- uses: actions/checkout@v4
93+
- uses: dtolnay/rust-toolchain@stable
94+
- name: Install Linux Tauri deps
95+
if: runner.os == 'Linux'
96+
run: |
97+
sudo apt-get update
98+
sudo apt-get install -y libwebkit2gtk-4.1-dev libxdo-dev libssl-dev \
99+
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev javascriptcoregtk-4.1
100+
- uses: Swatinem/rust-cache@v2
101+
- name: chaos run-all (fault-injection)
102+
run: cargo run -p driven-chaos -- run-all --fault-injection
103+
72104
chaos-real-drive:
73105
name: chaos real-drive (M4-gated, skipped)
74106
# WIRED but SKIPPED until M4 (STRESS_HARNESS s7 "Gating on M4"): the

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/driven-chaos/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ uuid.workspace = true
3636
# oracle instead of buffering / downloading 10-50 GB; same md-5 crate the core
3737
# + fake use, imported as `md5`.
3838
md5.workspace = true
39+
# Central s6.3 content-integrity invariant: hash a synced object's retained
40+
# bytes to the recorded plaintext blake3 (same crate + version the core uses).
41+
blake3 = "1"
3942
# Hermetic per-scenario StateRepo files live under a throwaway tempdir.
4043
tempfile = "3"
4144

crates/driven-chaos/src/capabilities.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,17 @@ impl CapabilitySet {
163163
pub fn probe() -> Self {
164164
let real_drive_creds = std::env::var("DRIVEN_E2E_REFRESH_TOKEN").is_ok()
165165
&& std::env::var("DRIVEN_E2E_DEST_FOLDER_ID").is_ok();
166-
// The volume backing the harness temp dir is where every fixture is
167-
// materialised, so free-disk + filesystem-type probes target it.
168-
let temp_dir = std::env::temp_dir();
166+
// Every fixture is materialised under `target/chaos-fixtures` (see
167+
// runner::fixture_root_for), NOT the system temp dir, so the free-disk +
168+
// filesystem-type probes must target THAT volume. On a multi-volume host
169+
// `target/` and the temp dir can be different disks; a big-file gate
170+
// keyed to the wrong one would mis-skip or fill the workspace volume
171+
// (recheck P2-G).
172+
let fixture_root = fixture_root_dir();
169173
let admin = driven_vss::is_elevated();
170-
let free_disk_bytes = probe_free_disk_bytes(&temp_dir);
171-
let ntfs_volume = probe_ntfs_volume(&temp_dir);
172-
let case_sensitive_volume = probe_case_sensitive_volume(&temp_dir);
174+
let free_disk_bytes = probe_free_disk_bytes(&fixture_root);
175+
let ntfs_volume = probe_ntfs_volume(&fixture_root);
176+
let case_sensitive_volume = probe_case_sensitive_volume(&fixture_root);
173177
let long_paths_enabled = probe_long_paths_enabled();
174178
// VSS needs Windows + elevation (driven-vss only exposes the COM
175179
// sequence on an elevated Windows host).
@@ -196,6 +200,21 @@ impl CapabilitySet {
196200
}
197201
}
198202

203+
/// The volume root every chaos fixture is materialised under - the
204+
/// `target/chaos-fixtures` directory that matches `runner::fixture_root_for`.
205+
/// The free-disk and filesystem-type capability probes target this path rather
206+
/// than the system temp dir, so a multi-volume host gates the big-file rows on
207+
/// the disk the fixtures actually land on (recheck P2-G). It is created
208+
/// best-effort so the probes have an existing path to stat, and falls back to
209+
/// the temp dir when it cannot be created (for example, a read-only checkout).
210+
fn fixture_root_dir() -> std::path::PathBuf {
211+
let root = std::path::PathBuf::from("target/chaos-fixtures");
212+
match std::fs::create_dir_all(&root) {
213+
Ok(()) => root,
214+
Err(_) => std::env::temp_dir(),
215+
}
216+
}
217+
199218
/// Free bytes available to the current user on the volume backing `path`.
200219
/// Returns 0 (the conservative "no space" reading) on any probe failure.
201220
#[cfg(windows)]

crates/driven-chaos/src/dispatch.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub enum Command {
5050
},
5151
/// `scenario run-all` - run every scenario, respecting capability gates.
5252
ScenarioRunAll,
53+
/// `run-all --fault-injection` - run ONLY the fault-injection subset
54+
/// (STRESS_HARNESS s3.7 / s4.2 / s5) the dedicated `chaos-fake-drive` CI
55+
/// gate covers.
56+
ScenarioRunFaultInjection,
5357
/// `fuzz [--seed N --duration D]` - property-style soak run.
5458
Fuzz {
5559
/// Seed for the weighted mutation distribution; `None` = `now()`.
@@ -133,14 +137,22 @@ pub fn parse(args: &[String]) -> anyhow::Result<Command> {
133137
})
134138
}
135139
Some("run-all") => {
136-
// Accept and ignore a trailing `--hermetic` (the only mode there is).
140+
// `--hermetic` is a no-op (the harness is hermetic by construction).
141+
// `--fault-injection` narrows the run to the s3.7/s4.2/s5 fault
142+
// subset the dedicated `chaos-fake-drive` CI gate runs.
143+
let mut fault_injection = false;
137144
for flag in it {
138145
match flag {
139146
"--hermetic" => {}
147+
"--fault-injection" => fault_injection = true,
140148
other => anyhow::bail!("unknown run-all flag: {other}"),
141149
}
142150
}
143-
Ok(Command::ScenarioRunAll)
151+
if fault_injection {
152+
Ok(Command::ScenarioRunFaultInjection)
153+
} else {
154+
Ok(Command::ScenarioRunAll)
155+
}
144156
}
145157
Some("fuzz") => {
146158
let mut seed = None;
@@ -339,6 +351,17 @@ pub async fn run(command: Command, caps: &CapabilitySet) -> i32 {
339351
exit_code::OK
340352
}
341353
}
354+
Command::ScenarioRunFaultInjection => {
355+
let report = runner::run_all(registry::fault_injection_registry(), caps).await;
356+
print!("{}", report.render_json());
357+
print!("{}", report.render_human());
358+
persist_last_run(&report);
359+
if report.any_failed() {
360+
exit_code::FAIL
361+
} else {
362+
exit_code::OK
363+
}
364+
}
342365
Command::Fuzz {
343366
seed,
344367
duration_secs,

crates/driven-chaos/src/registry.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ pub fn registry() -> Vec<Box<dyn Scenario>> {
3131
all
3232
}
3333

34+
/// The fault-injection subset (STRESS_HARNESS s3.7 Drive-side hazards + s4.2/s5
35+
/// drive-side mutator faults) the dedicated `chaos-fake-drive` CI job runs
36+
/// (ROADMAP M3.7 acceptance: a distinct fake-drive gate on Linux + macOS +
37+
/// Windows that "adds the fault-injection scenarios" against
38+
/// `InMemoryRemoteStore`). These rows are ALSO covered by the full `run-all`
39+
/// hermetic sweep; this focused selection gives the separately-named,
40+
/// faster-feedback gate the acceptance requires.
41+
pub fn fault_injection_registry() -> Vec<Box<dyn Scenario>> {
42+
let mut all: Vec<Box<dyn Scenario>> = Vec::new();
43+
all.extend(scenarios::drive_side::scenarios());
44+
all.extend(scenarios::mutator::scenarios());
45+
all
46+
}
47+
3448
/// Look one scenario up by its stable name (the `scenario run <name>` and
3549
/// `fixture create <name>` argument). Returns `None` if unknown.
3650
pub fn find(name: &str) -> Option<Box<dyn Scenario>> {

crates/driven-chaos/src/scenarios/drive_side.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,18 @@ async fn check_invariants(
276276
entry.size, row.size
277277
));
278278
}
279+
// md5 content integrity (s6.3): the live object's md5 must equal the
280+
// recorded `drive_md5`. The central sweep (reporting::assert_invariants)
281+
// additionally byte-hashes blake3 for the categories that delegate to
282+
// it; the drive_side checker proves content via this md5 + the size
283+
// check above (the typed fake's byte accessor is not plumbed through
284+
// this per-category checker, which sees only the RemoteStore trait).
285+
if let Some(drive_md5) = row.drive_md5 {
286+
if entry.md5 != Some(drive_md5) {
287+
no_data_loss = false;
288+
notes.push(format!("data-loss: synced row {rel} drive md5 mismatch"));
289+
}
290+
}
279291
// The local file must still exist at the recorded size.
280292
let abs = std::path::Path::new(&source.local_path).join(rel.as_str());
281293
match std::fs::metadata(&abs) {
@@ -1123,6 +1135,15 @@ impl Scenario for DriveFileidRecycled {
11231135
.collect();
11241136
anyhow::ensure!(live.len() == 1, "exactly one live object (Y)");
11251137
let y = &live[0];
1138+
// The whole point of this scenario is that Y REUSED X's trashed file_id;
1139+
// assert the recycle actually happened, so a regression in
1140+
// `with_fileid_recycle()` that hands Y a fresh id fails here instead of
1141+
// passing vacuously on the op-uuid check below.
1142+
anyhow::ensure!(
1143+
y.id == id_x,
1144+
"file_id was not recycled: Y id {} != X id {id_x}",
1145+
y.id
1146+
);
11261147
let uuid_y = y
11271148
.app_properties
11281149
.get(CLIENT_OP_UUID_KEY)

crates/driven-chaos/src/scenarios/mutator.rs

Lines changed: 42 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,17 +1027,16 @@ impl DriveMutatorScenario {
10271027
base.with_quota_exhausted_after(*after_bytes)
10281028
}
10291029
DriveKind::DailyQuota => {
1030-
// The fake exposes the storage-quota builder; the daily path is
1031-
// exercised here via the same quota builder with a 0-byte
1032-
// budget, but classified daily by the executor only if the fake
1033-
// emits a "daily" message. The fake's quota builder emits
1034-
// "quota_exhausted" (storage), NOT "daily" - so a faithful
1035-
// daily injection is NOT available on the current fake.
1036-
// Returning the plain remote here would silently mis-assert, so
1037-
// this kind is handled as a capability gap below (see
1038-
// run_assertions): we DO NOT fake a daily code we cannot
1039-
// produce.
1040-
base
1030+
// The fake's `with_daily_quota_after(n)` trips a faithful
1031+
// `403 dailyLimitExceeded` after `n` WriteTarget requests, then
1032+
// LATCHES (the daily window stays closed for the run). Its
1033+
// message carries "daily", so the executor's
1034+
// `classify_drive_error` maps it to `DriveError::DailyQuota` ->
1035+
// `ErrorCode::DriveDailyQuotaExhausted` (distinct from storage
1036+
// `quota_exhausted`). `after(4)` trips MID-run (a couple files
1037+
// land first), matching the sibling request-counted faults and
1038+
// the s9 "no data loss for files done before the failure" check.
1039+
base.with_daily_quota_after(4)
10411040
}
10421041
DriveKind::RateLimitStorm { after_requests } => {
10431042
base.with_rate_limit_after(*after_requests)
@@ -1067,23 +1066,12 @@ impl Scenario for DriveMutatorScenario {
10671066
self.description
10681067
}
10691068
fn requires(&self) -> CapabilityRequirements {
1070-
// Most s4.2 scenarios run against the in-memory fake - no host
1071-
// capability needed. The daily-quota row is the exception: a faithful
1072-
// `dailyLimitExceeded` signal (distinct from `storageQuotaExceeded`)
1073-
// can ONLY be produced by real Drive - the InMemoryRemoteStore fault
1074-
// surface has no daily-vs-storage discriminator, and the executor
1075-
// classifies "daily" only from a message the fake does not emit. So,
1076-
// exactly like the sibling drive_side `daily-quota-exhausted` row, this
1077-
// gates on `cap:real_drive_creds` and SKIPs cleanly (with the missing
1078-
// capability recorded) until the live path is wired in M4. This is an
1079-
// honest capability-skip, NOT a faked or weakened code. Follow-up: add
1080-
// InMemoryRemoteStore::with_daily_quota_after to driven-drive so a fake
1081-
// variant becomes possible.
1082-
if matches!(self.kind, DriveKind::DailyQuota) {
1083-
CapabilityRequirements::of(vec![Capability::RealDriveCreds])
1084-
} else {
1085-
CapabilityRequirements::none()
1086-
}
1069+
// Every s4.2 scenario - including daily-quota - runs against the
1070+
// in-memory fake, so none need a host capability. The fake's
1071+
// `with_daily_quota_after` now emits a faithful `dailyLimitExceeded`
1072+
// (see `build_remote`), so the daily-quota row no longer gates on
1073+
// `cap:real_drive_creds`; it runs hermetically like its siblings.
1074+
CapabilityRequirements::none()
10871075
}
10881076

10891077
async fn setup(&self, ctx: &mut ScenarioContext) -> anyhow::Result<()> {
@@ -1092,26 +1080,11 @@ impl Scenario for DriveMutatorScenario {
10921080
}
10931081

10941082
async fn run_assertions(&self, _handle: &DrivenHandle) -> anyhow::Result<Outcome> {
1095-
// The current InMemoryRemoteStore fault surface cannot emit a faithful
1096-
// `daily` (vs storage) quota signal; the executor classifies "daily"
1097-
// only from a message the fake does not produce. Rather than assert a
1098-
// weakened/faked code, this scenario records the capability gap and
1099-
// returns a documented-skip outcome that the driver renders SKIPPED.
1100-
if matches!(self.kind, DriveKind::DailyQuota) {
1101-
return Ok(Outcome {
1102-
error_codes_seen: vec![],
1103-
final_drive_object_count: 0,
1104-
final_hash_matches_local: true,
1105-
notes: vec![
1106-
"SKIP-by-capability: InMemoryRemoteStore exposes no faithful dailyLimitExceeded injector (only storageQuotaExceeded via with_quota_exhausted_after); the executor's daily classification needs a 'daily' message the fake does not emit. Not faking the code. Follow-up: add InMemoryRemoteStore::with_daily_quota_after to driven-drive fault_injection.".to_string(),
1107-
"invariants: None - this is a capability SKIP early-return; no handle/source/remote was booted, so there is no single source+folder snapshot to sweep.".to_string(),
1108-
],
1109-
// Capability SKIP: nothing was synced, so there is no terminal
1110-
// source+remote state for the canonical s6.3 sweep to read.
1111-
invariants: None,
1112-
});
1113-
}
1114-
1083+
// Every kind - including daily-quota - now runs the same hermetic body:
1084+
// `build_remote` rigs the kind's fault on the fake (the daily-quota row
1085+
// uses the real `with_daily_quota_after` injector), and the run drives
1086+
// cycles, collects the surfaced codes, and sweeps the canonical s6.3
1087+
// invariants. No kind early-returns a capability SKIP any more.
11151088
let state_dir = tempfile::tempdir()?;
11161089
let root_dir = tempfile::tempdir()?;
11171090
// A modest multi-file population so a "after N bytes / N requests"
@@ -1667,27 +1640,38 @@ mod tests {
16671640
);
16681641
}
16691642

1670-
/// The daily-quota scenario honestly SKIPS (capability gap) rather than
1671-
/// faking a code the fake cannot emit.
1643+
/// The daily-quota scenario now runs hermetically against the fake's
1644+
/// `with_daily_quota_after` injector and surfaces the real
1645+
/// `DriveDailyQuotaExhausted` code (distinct from storage quota). It is no
1646+
/// longer a capability SKIP - the injector exists.
16721647
#[tokio::test]
1673-
async fn drive_daily_quota_skips_by_capability() {
1648+
async fn drive_daily_quota_surfaces_code() {
16741649
let scenario = DriveMutatorScenario::daily_quota_exhausted();
1650+
// It runs on a stock host - no real-Drive capability required.
1651+
assert!(
1652+
scenario.requires().required.is_empty(),
1653+
"daily-quota row needs no host capability now"
1654+
);
16751655
let state_dir = tempfile::tempdir().expect("state dir");
16761656
let remote = Arc::new(InMemoryRemoteStore::new());
16771657
let handle = boot_handle(state_dir.path(), remote)
16781658
.await
16791659
.expect("boot placeholder handle");
16801660
let outcome = scenario.run_assertions(&handle).await.expect("runs");
16811661
assert!(
1682-
outcome.error_codes_seen.is_empty(),
1683-
"no code faked for the unavailable daily-quota injector"
1662+
outcome
1663+
.error_codes_seen
1664+
.contains(&ErrorCode::DriveDailyQuotaExhausted),
1665+
"drive.daily_quota_exhausted surfaced; saw {:?}",
1666+
outcome.error_codes_seen
16841667
);
1668+
// The daily code is classified distinctly from storage quota.
16851669
assert!(
1686-
outcome
1687-
.notes
1688-
.iter()
1689-
.any(|n| n.contains("SKIP-by-capability")),
1690-
"records the capability gap reason"
1670+
!outcome
1671+
.error_codes_seen
1672+
.contains(&ErrorCode::DriveQuotaExhausted),
1673+
"daily quota is not misclassified as storage quota; saw {:?}",
1674+
outcome.error_codes_seen
16911675
);
16921676
}
16931677
}

0 commit comments

Comments
 (0)