diff --git a/judge/crates/sandbox/src/restore_copy/files.rs b/judge/crates/sandbox/src/restore_copy/files.rs index c2699f7..fbf0411 100644 --- a/judge/crates/sandbox/src/restore_copy/files.rs +++ b/judge/crates/sandbox/src/restore_copy/files.rs @@ -266,22 +266,20 @@ impl Roots { { return Err(Error::Identity); } - let current = output.metadata().map_err(|_| Error::Filesystem)?; - let changed = (current.uid(), current.gid()) != (uid, gid); + // This descriptor was exclusively created by this copy. Its initial + // owner/mode decides the required changes; the final fstat below still + // checks the complete resulting file after every write and mode change. + let changed = (empty.uid(), empty.gid()) != (uid, gid); if changed { rustix::fs::fchown(&output, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))) .map_err(|_| Error::Filesystem)?; } - let mode_changed = changed || current.mode() & 0o7777 != mode; + let mode_changed = changed || empty.mode() & 0o7777 != mode; if mode_changed { rustix::fs::fchmod(&output, Mode::from_raw_mode(mode)) .map_err(|_| Error::Filesystem)?; } - let final_info = if mode_changed { - output.metadata().map_err(|_| Error::Filesystem)? - } else { - current - }; + let final_info = output.metadata().map_err(|_| Error::Filesystem)?; member(&final_info, &row, uid, gid, mode)?; let witness = stamp(&final_info)?; drop(output); @@ -458,6 +456,13 @@ fn verify( data.extend_from_slice(&buffer[..count]); } total = end; + // A short read already delivered every declared byte. The caller + // checks the same descriptor/path full nine-field witness (including + // exact size) before and after verification, so a changed or longer + // file still fails. Exact buffer multiples still require an explicit EOF read. + if total as u64 == row.bytes && count < buffer.len() { + break; + } } if total as u64 != row.bytes || hasher.is_some_and(|h| format!("{:x}", h.finalize()) != row.sha256) diff --git a/judge/crates/sandbox/src/restore_copy/main.rs b/judge/crates/sandbox/src/restore_copy/main.rs index 7bbe250..1e8611c 100644 --- a/judge/crates/sandbox/src/restore_copy/main.rs +++ b/judge/crates/sandbox/src/restore_copy/main.rs @@ -527,6 +527,97 @@ mod tests { roots.unchanged().unwrap_or_else(|_| panic!("root anchors")); } + #[test] + fn exact_lengths_and_unchanged_create_mode_keep_complete_readback() { + let fixture = files::Fixture::new(); + let roots = Arc::new(fixture.roots()); + use std::os::unix::fs::MetadataExt; + let owner = fs::metadata(fixture.source()).unwrap(); + let mut rows = Vec::new(); + for (index, size) in [0, 13, 1024 * 1024, 1024 * 1024 + 13] + .into_iter() + .enumerate() + { + let mut data = vec![b'a'; size]; + if let Some(last) = data.last_mut() { + *last = b'z'; + } + let hash = format!("{:x}", Sha256::digest(&data)); + fs::write(fixture.source().join(&hash), &data).unwrap(); + fs::set_permissions( + fixture.source().join(&hash), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + rows.push(restore_io::RestoreFile { + path: format!("artifacts/length-{index}"), + uid: owner.uid(), + gid: owner.gid(), + mode: 0o600, + bytes: size as u64, + sha256: hash, + }); + } + let pool = Pool::new(Arc::clone(&roots)).unwrap_or_else(|_| panic!("pool")); + for _ in 0..2 { + let verified = pool + .materialize(rows.clone(), &roots, now().unwrap() + 10_000_000_000) + .unwrap_or_else(|_| panic!("complete lengths including existing files")); + assert_eq!(verified.len(), 4); + for pending in &verified { + roots + .final_file(pending) + .unwrap_or_else(|_| panic!("final witness")); + let name = pending.row.path.strip_prefix("artifacts/").unwrap(); + let data = fs::read(fixture.target().join(name)).unwrap(); + assert_eq!(data.len() as u64, pending.row.bytes); + assert_eq!(format!("{:x}", Sha256::digest(&data)), pending.row.sha256); + assert_eq!( + fs::metadata(fixture.target().join(name)) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o600 + ); + } + } + drop(pool); + roots.unchanged().unwrap_or_else(|_| panic!("root anchors")); + } + + #[test] + fn short_final_chunk_still_refuses_a_wrong_last_byte() { + for size in [13, 1024 * 1024 + 13] { + let fixture = files::Fixture::new(); + let roots = Arc::new(fixture.roots()); + let mut data = vec![b'a'; size]; + let expected = format!("{:x}", Sha256::digest(&data)); + *data.last_mut().unwrap() = b'z'; + let source = fixture.source().join(&expected); + fs::write(&source, data).unwrap(); + fs::set_permissions(&source, fs::Permissions::from_mode(0o600)).unwrap(); + use std::os::unix::fs::MetadataExt; + let owner = fs::metadata(&source).unwrap(); + let pool = Pool::new(Arc::clone(&roots)).unwrap_or_else(|_| panic!("pool")); + assert!(matches!( + pool.materialize( + vec![restore_io::RestoreFile { + path: "artifacts/wrong-last-byte".into(), + uid: owner.uid(), + gid: owner.gid(), + mode: 0o600, + bytes: size as u64, + sha256: expected, + }], + &roots, + now().unwrap() + 10_000_000_000 + ), + Err(Error::Bytes) + )); + } + } + #[test] fn request_line_requires_finite_complete_frame() { assert!(matches!( diff --git a/scripts/release/README.md b/scripts/release/README.md index 74fc599..88bb0c2 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -520,6 +520,14 @@ kernel to release clean cached pages for that restored file. Descriptor and path witnesses are still checked afterwards. This per-file streaming hint avoids retaining historical file contents in the fixed restore memory budget; it does not change file bytes, durability, limits or the live filesystem checks. +For an exclusively created destination, its initial descriptor owner and mode +select the necessary changes; an unconditional final descriptor check still +confirms the complete file size, owner, mode and all nine identity fields. +A short read that supplies every declared byte completes hashing or byte equality +without another EOF-only read. Full descriptor/path size and identity checks +still surround the readback; empty files and exact buffer multiples retain an +explicit EOF read. Source EOF checks, synchronization and final all-file witnesses +are unchanged. Old configuration and maintenance receipts go into inactive `recovery/ID/history/`. The new boot's Sandbox preparation owns cgroup recreation. Unknown/nonempty targets and differing existing files are refused; partial restores