diff --git a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md new file mode 100644 index 00000000000..ce874858eb5 --- /dev/null +++ b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# A FAT file's mtime reads finer through its writer than after a reopen + +A handle's `fstat` answers the mtime the handle holds (`kernel/src/object/ops.rs`), +which a write sets to `clock::mtime_now` in nanoseconds. A FAT volume stores a +write time in two-second units (`kernel/src/fat32_adapter.rs`'s +`stamp`), so the same file opened again answers the even second: one file, +two mtimes. + +**Exit condition.** For a FAT file, the mtime `fstat` answers through a handle +equals the one it answers after the file is closed and opened again, checked by +a test that writes, `fstat`s, reopens and `fstat`s again; or the kernel mounts +no FAT volume a process writes. + +**Untested.** `fat32_adapter.rs`'s flush stamps its own instant (`now()`), not +the flushing handle's `_mtime`, because the last handle to close may be a reader +that opened before the last write. No test closes a reader last and asserts the +stored time is the flush's: it waits on `epoch()` moving two seconds, never a +sleep. diff --git a/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md b/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md new file mode 100644 index 00000000000..ac8829d2d20 --- /dev/null +++ b/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md @@ -0,0 +1,19 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# An undated file on FAT reads back as 1980 + +A file written on a machine whose RTC never answered is undated, an mtime of 0 +(`toyos_abi::syscall::Stat::mtime`). FAT has no undated stamp: +`toyos-fat32`'s `FatTime::from_unix_secs` clamps 0 up to `FatTime::EPOCH`, +1980-01-01, and the mount answers that back as a date. So the file reads as +written in 1980, which std reports as an instant and not as undated. + +**Mechanism read off the code; not reproduced.** + +**Exit condition.** A FAT mount answers 0 for an entry stamped +`FatTime::EPOCH`, with a guest test on the `rtc-dead` machine that writes a +FAT file and finds it undated. diff --git a/issues/filesystem/std-calls-undated-what-it-did-not-stat.md b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md new file mode 100644 index 00000000000..db0efe3b950 --- /dev/null +++ b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# std calls undated what it did not stat, and sets no mtime it is asked to + +The fork's `library/std/src/sys/fs/toyos.rs` reads a file's mtime only off +`SYS_FSTAT`. `DirEntry::metadata` answers `mtime: 0` without asking, so +`Metadata::modified` reports a file the kernel has a stamp for as undated: a +program walking a tree through `read_dir` and comparing +`entry.metadata()?.modified()?` fails on every entry. `fs::metadata` of a +directory and `fs::symlink_metadata` of a symlink answer `mtime: 0` too +(`stat`'s `is_dir` arm and `lstat`'s `readlink` arm), so `modified()` on either +is `Err(Unsupported)`. + +And `File::set_times`, `fs::set_times` and `set_times_nofollow` return `Ok(())` +having set nothing, so a tool that stamps an output (`touch`, a build system's +restat) is told it did. + +**Exit condition.** `DirEntry::metadata`, a directory's `stat` and a link's +`lstat` answer the mtime the kernel keeps for that name, and a time-setting +call sets the stamp through the kernel or is refused as `Unsupported`. diff --git a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md new file mode 100644 index 00000000000..ac699d7d08e --- /dev/null +++ b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# The last handle to close stamps the file with its own mtime, not the last write's + +An mtime lives on the handle (`kernel/src/object/file.rs`'s +`OpenFileState::mtime`): it is the file's stored stamp at the open, and a write +or `ftruncate` through that handle moves it (`kernel/src/object/ops.rs`). A +close that leaves another handle open enqueues nothing +(`file_cache::release_to_writeback` answers `StillHeld`), and the last close +enqueues the flush with *its* handle's mtime (`kernel/src/writeback.rs`). + +So a file opened for reading at `t0`, written through a second handle at `t1`, +closed by the writer and then by the reader, is flushed with `t0`: the stored +mtime says the file has not changed since before the write, and a build tool +that compares mtimes does not rebuild from it. + +**Mechanism read off the code; not reproduced.** + +**Exit condition.** The mtime is the file's and not a handle's — each write +moves the one stamp every flush of the file stores — with a guest test that +writes through one handle, closes a reader last, and reads the write's stamp +back after a reopen. diff --git a/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md b/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md new file mode 100644 index 00000000000..58930bd1c52 --- /dev/null +++ b/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# Userland's wall clock has whole seconds and a file's mtime has nanoseconds + +The kernel stamps a file with `clock::mtime_now` (`kernel/src/clock.rs`), the +RTC's second carried on by the counter, so a write inside a second carries the +nanoseconds past it. Userland reads the wall clock only through +`SYS_CLOCK_EPOCH`, which answers whole seconds: std's `SystemTime::now` (the +fork's `library/std/src/sys/time/toyos.rs`) and libc's `clock_gettime(CLOCK_REALTIME)` +and `gettimeofday` (`userland/libc/src/time.rs`) all round down to the second. + +So a file written a moment ago reads as up to a second in the future against +the program's own "now", which a tool comparing at nanoseconds — GNU make's +"modification time in the future" check — reports as clock skew. And a file +server in userland can stamp only what it can read — a stamp it takes off +`SYS_CLOCK_EPOCH` is a whole second, so two writes inside one second carry one +mtime, which a build tool reads as "not newer". + +**Exit condition.** Userland reads the wall clock at the resolution the kernel +stamps at — the boot's UTC anchor published where a process reads the +monotonic clock (`toyos_abi::clock`'s page), or a syscall that answers +nanoseconds — and std, libc and every file server stamp and compare off it. diff --git a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md index b19b428e9e9..782580e1450 100644 --- a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md +++ b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md @@ -12,18 +12,15 @@ Three things that record carried are not covered by either refusal, and they went out of the tracker with it. They are one file because they are one residual: what the refusals do **not** reach. -## The identity cannot see a same-size rewrite on a FAT32 mount +## The identity cannot see a same-size rewrite -`vfs::BackingId` is size plus the mount's mtime. On `/home` (bcachefs) the mtime -is `nanos_since_boot` at the flush, so two writes are always apart — -`so_cache_policy`'s `stale-mtime` arm asserts exactly that. +`vfs::BackingId` is size plus the mount's mtime. `/log` is FAT32, mounted `UserAccess::ReadWrite` (`kernel/src/main.rs:461`), and **FAT stores seconds in units of two**: `toyos-fat32/src/time.rs:5-15` states the encoding's three lossy properties, `dir.rs:92` passes 0 for the tenths -field, and `kernel/src/fat32_adapter.rs:849` stamps whatever `now()` gives. So -two writes of the same length inside one 2-second bucket carry one mtime, and -the second load is served the first image — the staleness the refusal exists to +field. So two writes of the same length inside one 2-second bucket carry one +mtime, and the second load is served the first image — the staleness the refusal exists to prevent, on the one writable FAT mount a process can reach. **Mechanism read off the code; not reproduced.** Planting it needs a same-size @@ -31,9 +28,14 @@ rewrite of a library inside 2 s on `/log`, and a 1.9 MB write to the USB-backed log volume takes about 5.8 s, so the window closes before the second write lands. +On a machine whose RTC never answered every write stamps 0 +(`kernel/src/clock.rs`'s `mtime_now`), so there a same-size rewrite of a +library on any writable mount carries the identity it had. + *Exit condition:* an identity that does not rest on a clock — a content hash, a per-file generation the mount bumps on every write, or a `FileId` plus a write -counter — or a demonstration that no library can be reached on a FAT mount. +counter — with a test that rewrites a library at the same size on the +`rtc-dead` machine and loads the second image. ## The budget is a machine-wide, boot-permanent denial diff --git a/kernel/src/clock.rs b/kernel/src/clock.rs index b02d7940274..019dfeb65c8 100644 --- a/kernel/src/clock.rs +++ b/kernel/src/clock.rs @@ -149,15 +149,31 @@ pub fn init_wall(century_reg: Option) { } }; - BOOT_SECS.store(civil.to_unix_secs().saturating_sub(nanos_since_boot() / 1_000_000_000), Relaxed); + BOOT_SECS.store(civil.to_unix_secs().saturating_sub(nanos_since_boot() / NANOS_PER_SEC), Relaxed); WALL_KNOWN.store(true, Release); log!("clock: the RTC reads {civil} UTC"); } -/// Unix seconds, now. `None` if the RTC never answered. +/// Unix seconds, now — what `SYS_CLOCK_EPOCH` serves: the whole seconds of +/// [`utc_nanos`]. `None` if the RTC never answered. pub fn utc_secs() -> Option { + utc_nanos().map(|nanos| nanos / NANOS_PER_SEC) +} + +pub const NANOS_PER_SEC: u64 = 1_000_000_000; + +/// Nanoseconds since the Unix epoch, UTC: the RTC's whole-second reading carried +/// on by the counter, so its resolution is the counter's and its accuracy the +/// RTC's second. +pub fn utc_nanos() -> Option { WALL_KNOWN .load(Acquire) - .then(|| BOOT_SECS.load(Relaxed) + nanos_since_boot() / 1_000_000_000) + .then(|| BOOT_SECS.load(Relaxed).saturating_mul(NANOS_PER_SEC).saturating_add(nanos_since_boot())) +} + +/// What a file written now is stamped with (`toyos_abi::syscall::Stat::mtime`): +/// [`utc_nanos`], and 0 — undated — on a machine whose RTC never answered. +pub fn mtime_now() -> u64 { + utc_nanos().unwrap_or(0) } diff --git a/kernel/src/fat32_adapter.rs b/kernel/src/fat32_adapter.rs index 4309b398e18..1753bc70615 100644 --- a/kernel/src/fat32_adapter.rs +++ b/kernel/src/fat32_adapter.rs @@ -653,11 +653,15 @@ pub struct FatFs { repair_named: RepairNotice, } -/// What to stamp on an entry: reads `clock` directly — the VFS's `mtime` is -/// nanoseconds since boot, not a time of day. FAT specifies local time; this -/// stamps UTC because the owner ruled the hardware clock is UTC. +/// A VFS `mtime` as FAT stores it: whole seconds, and the two-second field of a +/// write time drops the odd one. FAT specifies local time; this stamps UTC +/// because the owner ruled the hardware clock is UTC. +fn stamp(mtime: u64) -> FatTime { + FatTime::from_unix_secs(mtime / crate::clock::NANOS_PER_SEC) +} + fn now() -> FatTime { - crate::clock::utc_secs().map_or(FatTime::EPOCH, FatTime::from_unix_secs) + stamp(crate::clock::mtime_now()) } /// What one of `toyos-fat32`'s errors means to the [`FileSystem`] caller; @@ -918,7 +922,7 @@ impl FileSystem for FatFs { let role = self.role; self.fs .metadata(name) - .map(|m| m.modified_unix) + .map(|m| m.modified_unix.saturating_mul(crate::clock::NANOS_PER_SEC)) .map_err(|e| refused(role, &self.fs, &mut self.repair_named, "metadata", name, e)) } @@ -946,12 +950,12 @@ impl FileSystem for FatFs { Ok((file_id, Some(backing))) } - fn create(&mut self, name: &str, _mtime: u64) -> Result { + fn create(&mut self, name: &str, mtime: u64) -> Result { if let Some(&file_id) = self.by_name.get(name) { return Ok(file_id); } let role = self.role; - let time = now(); + let time = stamp(mtime); self.ensure_parent(name, time)?; let file = match self.fs.create(name, time) { Ok(file) => file, @@ -1047,6 +1051,8 @@ impl FileSystem for FatFs { _mtime: u64, ) -> Result<(), SyscallError> { let role = self.role; + // The flush's own instant, not the flushing handle's `mtime`: the last + // handle to close may be a reader that opened before the last write. let time = now(); let name = { let known = self.open.get(&file_id).ok_or(SyscallError::NotFound)?; diff --git a/kernel/src/leak_selftest.rs b/kernel/src/leak_selftest.rs index ffef8cfd6c0..23ece90cfe6 100644 --- a/kernel/src/leak_selftest.rs +++ b/kernel/src/leak_selftest.rs @@ -14,7 +14,7 @@ fn fat_reopen_census() { const PATH: &str = "/log/lrfile"; - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let id = match vfs::lock().create_file(PATH, mtime) { Ok(id) => id, Err(e) => { diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs index 3808154fb29..40f6143e77b 100644 --- a/kernel/src/object/ops.rs +++ b/kernel/src/object/ops.rs @@ -111,7 +111,7 @@ pub fn open(table: &mut HandleTable, path: &str, flags: OpenFlags) -> u64 { } let built = if truncate && create { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); // `NotFound` is not a failure: truncating past a name that was not there is fine. // Any `vfs.delete` error other than `NotFound` is propagated, not swallowed: truncating past it could silently create a file over one the mount could not confirm was missing. match vfs.delete(target.as_str()) { @@ -129,7 +129,7 @@ pub fn open(table: &mut HandleTable, path: &str, flags: OpenFlags) -> u64 { (file_id, mtime, position) }), Err(SyscallError::NotFound) if create => { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); vfs.create_file(target.as_str(), mtime).map(|file_id| (file_id, mtime, 0)) } Err(e) => Err(e), @@ -523,7 +523,7 @@ pub fn try_write(object: &KObjectRef, buf: &UserBytes) -> Option { } state.position += written; // Dirty state lives in the cache now, set by `write_page`; the handle keeps only the mtime. - state.mtime = crate::clock::nanos_since_boot(); + state.mtime = crate::clock::mtime_now(); Some(written as u64) }), KObjectRef::PipeWrite(w) => write_pipe(w.id(), buf), @@ -818,7 +818,7 @@ pub fn ftruncate(object: &KObjectRef, size: u64) -> u64 { } // The seek pointer is not touched (POSIX ftruncate): a shrink leaves it past EOF. file.with(|state| { - state.mtime = crate::clock::nanos_since_boot(); + state.mtime = crate::clock::mtime_now(); 0 }) } diff --git a/kernel/src/revoke_selftest.rs b/kernel/src/revoke_selftest.rs index d44a28763f7..6617b5eeebc 100644 --- a/kernel/src/revoke_selftest.rs +++ b/kernel/src/revoke_selftest.rs @@ -20,7 +20,7 @@ fn fail(path: &str, step: &str) { } fn probe(path: &str) { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let id = match vfs::lock().create_file(path, mtime) { Ok(id) => id, Err(e) => return fail(path, &alloc::format!("create: {e:?}")), diff --git a/kernel/src/vfs.rs b/kernel/src/vfs.rs index efd94d853a2..1a83e089ae4 100644 --- a/kernel/src/vfs.rs +++ b/kernel/src/vfs.rs @@ -67,7 +67,8 @@ pub trait FileSystem: Send { /// the mount's — a bcachefs answer of no reads the whole tree. fn is_dir(&mut self, dir: &str) -> Result; - /// When `name` was last written, in whatever epoch the mount keeps. + /// When `name` was last written, as `toyos_abi::syscall::Stat::mtime` says, + /// to the precision the mount stores. fn file_mtime(&mut self, name: &str) -> Result; /// What `name` points at; `Ok(None)` for a non-link or an absent name, never for a device that would not answer. @@ -720,7 +721,7 @@ impl Vfs { }; if let Some(file_id) = dirty { // The flush's own instant: no one handle's last write is the file's. - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let owner = String::from(target.as_str()); self.flush_file(&owner, file_id, mtime)?; } diff --git a/rust b/rust index 9c3eea441d8..90697f1401a 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 9c3eea441d8eefb4211a4e88a1a8958e7fff2c18 +Subproject commit 90697f1401aa68b389134fd42c7fadeb15e3474b diff --git a/tests/common/wallclock.rs b/tests/common/wallclock.rs index 7046dfc9223..566685c35bc 100644 --- a/tests/common/wallclock.rs +++ b/tests/common/wallclock.rs @@ -426,3 +426,169 @@ pub fn century_from_the_register( eprintln!(" [clock] century register 0x21: {}, a century past the staged clock", only.name); Ok(()) } + +/// Where [`file_mtime_survives_a_reboot`] writes, on DATA: the one volume a +/// file outlives its boot on. +const MTIME_PATH: &str = "/home/file-mtime.bin"; + +/// The second boot's RTC, a day past [`RTC_BASE`]: a stamp taken again at the +/// mount or the open would carry this day, so an unchanged one was carried. +const RTC_NEXT_DAY: &str = "2033-03-08T09:14:25"; + +/// What `file_mtime` printed for [`MTIME_PATH`], in nanoseconds. +fn printed_mtime(result: &qemu::TestResult) -> Result { + let head = format!("file-mtime: {MTIME_PATH} mtime="); + result + .stdout + .lines() + .find_map(|l| l.trim().strip_prefix(head.as_str())) + .and_then(|n| n.parse().ok()) + .ok_or_else(|| { + format!( + "`{}` printed no {head:?} line (exit {:?})\n{}{}{}", + result.name, result.exit_code, result.before, result.stdout, result.serial + ) + }) +} + +/// One boot of the image `data` carries DATA on, with the RTC at `rtc_base`, +/// running `file_mtime MTIME_PATH`, then shut down. +fn mtime_boot( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], + data: &Path, + rtc_base: &'static str, + mode: &str, +) -> Result<(u64, Duration), String> { + // Before the launch, so the RTC the guest reads has run no longer than this. + let launched = std::time::Instant::now(); + let mut qemu = QemuInstance::boot_with_options( + test_config, + c_bins, + rust_bins, + BootOptions { + nvme_image: Some(data.to_path_buf()), + rtc_base: Some(rtc_base), + ..Default::default() + }, + ); + let boot = qemu.boot_log().to_string(); + if boot.contains("are a tmpfs") { + return Err(format!("/home fell back to tmpfs, so no file of it outlives the boot:\n{boot}")); + } + let result = qemu.run_test(&format!("test_rs_file_mtime {mode} {MTIME_PATH}"), Duration::from_secs(60)); + let printed = printed_mtime(&result); + writeln!(qemu.stdin_mut(), "run shutdown").expect("write to QEMU stdin"); + qemu.flush_stdin(); + let tail = qemu.drain_serial(Duration::from_secs(20)); + drop(qemu); + for bad in ["PANIC:", "panicked at"] { + if tail.contains(bad) { + return Err(format!("{bad:?} on the way down\n{tail}")); + } + } + if result.exit_code != Some(0) { + return Err(format!( + "`file_mtime {mode}` failed:\n{}\nkernel log while it ran:\n{}{}", + result.stdout, result.before, result.serial + )); + } + printed.map(|n| (n, launched.elapsed())) +} + +/// A file's mtime is the wall clock at its write, and a reboot carries it +/// unchanged. +/// +/// The oracle is the instant the host staged with `-rtc base=`: the guest's +/// stamp for a file on DATA lies within [`after_the_base`] of it, the +/// DATA volume read off the image by the host's own `bcachefs` reader holds +/// that same stamp, and a second boot with the clock a day on reads it back +/// unchanged. A stamp since boot is decades short of the instant. +pub fn file_mtime_survives_a_reboot( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], +) -> Result<(), String> { + const NANOS_PER_SEC: u64 = 1_000_000_000; + + let data = super::lane::dir().join("file-mtime-data.img"); + toyos_build::build::create_sparse(&data, qemu::NVME_SMALL); + + let (written, lived) = mtime_boot(test_config, c_bins, rust_bins, &data, RTC_BASE, "write")?; + let drift = (written / NANOS_PER_SEC) as i64 - RTC_BASE_SECS; + if !after_the_base(drift, lived) { + return Err(format!( + "{MTIME_PATH} is stamped {written} ns, {drift} s from the {RTC_BASE} the host set the \ + RTC to" + )); + } + + let io = super::storage::FileBlocks::open(&data)?; + let fs = bcachefs::Mounted::<_, bcachefs::ReadOnly>::open(io) + .map_err(|e| format!("the DATA volume does not mount on the host: {e:?}"))?; + let on_device = fs + .file_mtime(MTIME_PATH.trim_start_matches('/')) + .map_err(|e| format!("reading {MTIME_PATH}'s mtime off the image: {e:?}"))?; + drop(fs); + if on_device != Some(written) { + return Err(format!( + "the guest read {written} ns for {MTIME_PATH} and the device holds {on_device:?}" + )); + } + + let (read, _) = mtime_boot(test_config, c_bins, rust_bins, &data, RTC_NEXT_DAY, "read")?; + if read != written { + return Err(format!( + "{MTIME_PATH} was stamped {written} ns and reads {read} ns after a reboot with the RTC \ + at {RTC_NEXT_DAY}" + )); + } + let _ = std::fs::remove_file(&data); + eprintln!( + " [clock] {MTIME_PATH} stamped {drift} s past the staged RTC, the same {written} ns on \ + the device and after a reboot a day on" + ); + Ok(()) +} + +/// On a machine whose RTC never answered, a file's mtime is undated — 0, which +/// std reports as an error — and never 1970 plus the boot's uptime. +pub fn file_mtime_undated( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], +) -> Result<(), String> { + const SAID: &str = "file-mtime: /tmp/file-mtime-undated is undated"; + let mut qemu = QemuInstance::boot_with_options( + test_config, + c_bins, + rust_bins, + BootOptions { kernel_params: &["rtc-dead"], ..Default::default() }, + ); + let boot = qemu.boot_log().to_string(); + if !boot.contains("clock: this machine will not say what time it is") { + return Err(format!( + "with rtc-dead armed the kernel never refused the clock\n{}", + clock_lines(&boot) + )); + } + let result = qemu.run_test("test_rs_file_mtime undated", Duration::from_secs(60)); + writeln!(qemu.stdin_mut(), "run shutdown").expect("write to QEMU stdin"); + qemu.flush_stdin(); + let tail = qemu.drain_serial(Duration::from_secs(20)); + drop(qemu); + for bad in ["PANIC:", "panicked at"] { + if tail.contains(bad) { + return Err(format!("{bad:?} on the way down\n{tail}")); + } + } + if result.exit_code != Some(0) || !result.stdout.contains(SAID) { + return Err(format!( + "`file_mtime undated` exited {:?}:\n{}\nkernel log while it ran:\n{}{}", + result.exit_code, result.stdout, result.before, result.serial + )); + } + eprintln!(" [clock] rtc-dead: a file written in /tmp is undated"); + Ok(()) +} diff --git a/tests/testcases/LICENSE b/tests/testcases/LICENSE index 8eb68294c2e..1bb2732ad04 100644 --- a/tests/testcases/LICENSE +++ b/tests/testcases/LICENSE @@ -22,9 +22,9 @@ against tinycc upstream at 64552b3faa39ee7948a9ea21bfcc11045b90c70d (repo.or.cz/tinycc.git, 2026-08-05), allowing for the `-` → `_` renames this project made to some filenames. - tinycc/ 316 files: 239 byte-identical to tinycc's `tests/tests2`, + tinycc/ 318 files: 239 byte-identical to tinycc's `tests/tests2`, 17 tinycc files this project modified, - 60 not upstream at all — 59 cases written here, plus + 62 not upstream at all — 61 cases written here, plus `fred.txt`, which is output `40_stdio.c` writes when it runs and which was committed by accident. diff --git a/tests/testcases/tinycc/202_stat_mtime.c b/tests/testcases/tinycc/202_stat_mtime.c new file mode 100644 index 00000000000..dc5647ac7b9 --- /dev/null +++ b/tests/testcases/tinycc/202_stat_mtime.c @@ -0,0 +1,52 @@ +/* `struct stat` as the C library fills it: the size and the mtime a C caller + reads are the file's, which they are only if libc's layout is the header's. */ +#include +#include +#include +#include +#include + +#define PATH "/tmp/202_stat_mtime" +#define BYTES 17 + +static void judge(const char *how, const struct stat *st, time_t before, time_t after) { + printf("%s st_size %ld\n", how, (long)st->st_size); + if (before <= st->st_mtime && st->st_mtime <= after) + printf("%s st_mtime within the write\n", how); + else + printf("%s st_mtime %ld outside [%ld, %ld]\n", how, (long)st->st_mtime, (long)before, + (long)after); + if (0 <= st->st_mtim.tv_nsec && st->st_mtim.tv_nsec < 1000000000L) + printf("%s tv_nsec below a second\n", how); + else + printf("%s tv_nsec %ld\n", how, (long)st->st_mtim.tv_nsec); +} + +int main(void) { + struct stat st; + time_t before, after; + int fd; + + printf("sizeof(struct stat) %zu\n", sizeof(struct stat)); + + before = time(NULL); + fd = open(PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0 || write(fd, "seventeen bytes!\n", BYTES) != BYTES) { + printf("could not write " PATH "\n"); + return 1; + } + if (fstat(fd, &st) != 0) { + printf("fstat failed\n"); + return 1; + } + close(fd); + after = time(NULL); + judge("fstat", &st, before, after); + + if (stat(PATH, &st) != 0) { + printf("stat failed\n"); + return 1; + } + judge("stat", &st, before, after); + return 0; +} diff --git a/tests/testcases/tinycc/202_stat_mtime.expect b/tests/testcases/tinycc/202_stat_mtime.expect new file mode 100644 index 00000000000..26e64796357 --- /dev/null +++ b/tests/testcases/tinycc/202_stat_mtime.expect @@ -0,0 +1,7 @@ +sizeof(struct stat) 120 +fstat st_size 17 +fstat st_mtime within the write +fstat tv_nsec below a second +stat st_size 17 +stat st_mtime within the write +stat tv_nsec below a second diff --git a/tests/toyos-rust-tests/src/bin/file_mtime.rs b/tests/toyos-rust-tests/src/bin/file_mtime.rs new file mode 100644 index 00000000000..d371d818018 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/file_mtime.rs @@ -0,0 +1,141 @@ +//! A file's mtime is the wall clock at its write, in nanoseconds since the Unix +//! epoch (`toyos_abi::syscall::Stat::mtime`). +//! +//! With no arguments, on the shared boot, it judges `/tmp`: each stamp lies +//! between two `SYS_CLOCK_EPOCH` readings taken around what made it — a write, +//! a create with truncation, a create of a missing file, a truncation — and a +//! later write's stamp is later, which a clock of whole seconds cannot say. +//! Then `/log`: FAT keeps the whole seconds of its flush, read back after a +//! reopen. +//! `write ` makes the first judgement on `path` and `read ` only +//! prints what it finds there. `undated` runs on a machine whose RTC never +//! answered and never asks the time: a file written there is undated, which +//! std reports as an error and not as 1970. + +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Write}; +use std::time::UNIX_EPOCH; + +const NANOS_PER_SEC: u64 = 1_000_000_000; + +fn mtime(path: &str) -> u64 { + let modified = fs::metadata(path) + .unwrap_or_else(|e| panic!("stat {path}: {e}")) + .modified() + .unwrap_or_else(|e| panic!("{path} has no mtime: {e}")); + let since = modified + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| panic!("{path}'s mtime is before the epoch")); + u64::try_from(since.as_nanos()).expect("an mtime past 2554") +} + +fn epoch() -> u64 { + toyos::system::clock_epoch().expect("SYS_CLOCK_EPOCH: this machine will not say what time it is") +} + +fn write(path: &str, bytes: &[u8]) { + let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); + f.write_all(bytes).unwrap_or_else(|e| panic!("write {path}: {e}")); + f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); +} + +/// Runs `act` and returns `path`'s stamp after it, judged against the wall +/// clock read around it. +fn judged(path: &str, what: &str, act: impl FnOnce()) -> u64 { + let before = epoch(); + act(); + let after = epoch(); + let stamp = mtime(path); + // `SYS_CLOCK_EPOCH` is the whole seconds of the clock that stamps, so the + // stamp is at or past `before` and short of the second after `after`. + assert!( + before * NANOS_PER_SEC <= stamp && stamp < (after + 1) * NANOS_PER_SEC, + "{path} is stamped {stamp} ns by {what}, and the wall clock read {before} s before it \ + and {after} s after it", + ); + stamp +} + +fn write_judged(path: &str, bytes: &[u8]) -> u64 { + judged(path, "a write", || write(path, bytes)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + match args.as_slice() { + [_] => { + let first = write_judged("/tmp/file-mtime-first", b"first"); + let second = write_judged("/tmp/file-mtime-second", b"second"); + assert!( + second > first, + "a write after another is stamped {second} ns and the one before it {first} ns" + ); + + const TRUNCATED: &str = "/tmp/file-mtime-truncated"; + let created = judged(TRUNCATED, "a create with truncation", || { + fs::File::create(TRUNCATED).unwrap_or_else(|e| panic!("create {TRUNCATED}: {e}")); + }); + + const MISSING: &str = "/tmp/file-mtime-missing"; + assert!(fs::metadata(MISSING).is_err(), "{MISSING} exists before this creates it"); + judged(MISSING, "a create of a missing file", || { + OpenOptions::new() + .write(true) + .create(true) + .open(MISSING) + .unwrap_or_else(|e| panic!("create {MISSING}: {e}")); + }); + + let resized = judged(TRUNCATED, "a truncation", || { + let f = OpenOptions::new() + .write(true) + .open(TRUNCATED) + .unwrap_or_else(|e| panic!("reopen {TRUNCATED}: {e}")); + f.set_len(1).unwrap_or_else(|e| panic!("ftruncate {TRUNCATED}: {e}")); + f.sync_all().unwrap_or_else(|e| panic!("fsync {TRUNCATED}: {e}")); + }); + assert!( + resized > created, + "{TRUNCATED} was created at {created} ns and a later truncation stamped it \ + {resized} ns" + ); + + // The flush stamps FAT, which keeps whole seconds and drops an odd one. + const FAT: &str = "/log/file-mtime"; + let before = epoch(); + write(FAT, b"on FAT"); + let fat = mtime(FAT); + let after = epoch(); + assert!( + (before - 1) * NANOS_PER_SEC <= fat + && fat <= after * NANOS_PER_SEC + && fat % NANOS_PER_SEC == 0, + "{FAT} reads back {fat} ns after a reopen, and the wall clock read {before} s \ + before its write and {after} s after", + ); + fs::remove_file(FAT).unwrap_or_else(|e| panic!("remove {FAT}: {e}")); + println!("file-mtime: /tmp stamps {first} then {second}, /log {fat}"); + } + [_, mode] if mode == "undated" => { + const UNDATED: &str = "/tmp/file-mtime-undated"; + write(UNDATED, b"no clock answered"); + let meta = fs::metadata(UNDATED).unwrap_or_else(|e| panic!("stat {UNDATED}: {e}")); + match meta.modified() { + Err(e) if e.kind() == ErrorKind::Unsupported => {} + other => panic!( + "{UNDATED} was written on a machine whose RTC never answered, and its mtime \ + reads {other:?}" + ), + } + println!("file-mtime: {UNDATED} is undated"); + } + [_, mode, path] if mode == "write" => { + let stamp = write_judged(path, b"stamped at its write"); + println!("file-mtime: {path} mtime={stamp}"); + } + [_, mode, path] if mode == "read" => { + println!("file-mtime: {path} mtime={}", mtime(path)); + } + _ => panic!("usage: file_mtime [undated | write | read ], got {args:?}"), + } +} diff --git a/tests/toyos.rs b/tests/toyos.rs index ac4a04a7399..1c8e65a00ad 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -461,6 +461,8 @@ const DRIVEN_AND_SHARED: &[&str] = &[ // The log-stream arms drive it for the kernel's `exit:` record about it, // not for anything it does: it is the cheapest process this tree starts. "empty_dir_stat", + // Its shared run judges `/tmp`'s and `/log`'s stamps; its other modes are machine tests'. + "file_mtime", "hierarchy_paths", "nvme_home_roundtrip", "sched_stress", @@ -1230,6 +1232,8 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("wall_clock_no_century", Sched::Parallel, Tier::Weekly), ("wall_clock_century_register", Sched::Parallel, Tier::Weekly), ("wall_clock_utc", Sched::Parallel, Tier::Weekly), + ("file_mtime_survives_a_reboot", Sched::Parallel, Tier::Nightly), + ("file_mtime_undated", Sched::Parallel, Tier::Nightly), // `xhci_slow_connect`'s shape against the disk's port, but its actuator // masks the port until `BOOT_SCAN_DONE` — a kernel event, not a duration — // so what it stages is an ordering with no wall-clock margin on either @@ -1482,6 +1486,8 @@ const CARRIES: &[(&str, &[&str])] = &[ ("wall_clock_no_century", &["test_rs_wall_clock_now"]), ("wall_clock_century_register", &["test_rs_wall_clock_now"]), ("wall_clock_utc", &["test_rs_wall_clock_now"]), + ("file_mtime_survives_a_reboot", &["test_rs_file_mtime"]), + ("file_mtime_undated", &["test_rs_file_mtime"]), ("screen_console_clear", &["test_rs_test_screen_graffiti"]), ("screen_console_scroll", &["test_rs_test_screen_churn"]), ("screen_console_panic", &["test_rs_test_panic_child"]), @@ -10090,6 +10096,10 @@ fn run_machine_test( common::wallclock::century_from_the_register(test_config, c_bins, rust_bins) } "wall_clock_utc" => common::wallclock::rtc_is_utc(test_config, c_bins, rust_bins), + "file_mtime_survives_a_reboot" => { + common::wallclock::file_mtime_survives_a_reboot(test_config, c_bins, rust_bins) + } + "file_mtime_undated" => common::wallclock::file_mtime_undated(test_config, c_bins, rust_bins), "late_storage_connect" => common::volumes::late_storage_connect(test_config, c_bins, rust_bins), "root_candidate_malformed" => { common::volumes::root_candidate_malformed(test_config, c_bins, rust_bins) diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 62e20a024b0..180d50fe034 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -686,7 +686,11 @@ pub struct RealTime { pub struct Stat { pub file_type: FileType, pub size: u64, - /// Last modification time (nanoseconds since boot). + /// When the file was last written: nanoseconds since the Unix epoch, UTC, + /// off the wall clock at the write, to the resolution its mount keeps — + /// the nanosecond on `/tmp` and DATA, two seconds at the flush on FAT. 0 is + /// undated: written on a machine whose RTC never answered, or shipped in + /// ROOT's reproducible image. pub mtime: u64, } diff --git a/toyos-fat32/src/dir.rs b/toyos-fat32/src/dir.rs index f2aa38837be..653f7c687db 100644 --- a/toyos-fat32/src/dir.rs +++ b/toyos-fat32/src/dir.rs @@ -605,3 +605,22 @@ impl Fat32 { self.write_entry_at(offset, &raw) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A write time is the two-second field alone — the odd second a + /// [`FatTime`] carries in `tenths` is a creation time's — so what a volume + /// answers for when a file was written is the even second at or below it. + #[test] + fn a_write_time_keeps_the_even_second() { + // 2033-03-07 09:14:25. + let odd = 1_993_799_665; + let mut raw = RawEntry::zeroed(); + raw.set_write_time(FatTime::from_unix_secs(odd)); + assert_eq!(raw.write_time().to_unix_secs(), odd - 1); + raw.set_write_time(FatTime::from_unix_secs(odd - 1)); + assert_eq!(raw.write_time().to_unix_secs(), odd - 1); + } +} diff --git a/userland/libc/include/sys/stat.h b/userland/libc/include/sys/stat.h index 8c02bb5a37f..bf24651b0b5 100644 --- a/userland/libc/include/sys/stat.h +++ b/userland/libc/include/sys/stat.h @@ -15,11 +15,15 @@ struct stat { off_t st_size; blksize_t st_blksize; blkcnt_t st_blocks; - time_t st_atime; - time_t st_mtime; - time_t st_ctime; + struct timespec st_atim; + struct timespec st_mtim; + struct timespec st_ctim; }; +#define st_atime st_atim.tv_sec +#define st_mtime st_mtim.tv_sec +#define st_ctime st_ctim.tv_sec + #define S_IFMT 0170000 #define S_IFSOCK 0140000 #define S_IFLNK 0120000 diff --git a/userland/libc/src/posix_io.rs b/userland/libc/src/posix_io.rs index 2857e3838d7..d593d57696f 100644 --- a/userland/libc/src/posix_io.rs +++ b/userland/libc/src/posix_io.rs @@ -7,6 +7,8 @@ use core::ptr; use toyos_abi::RawHandle; use toyos_abi::syscall::{self, OpenFlags, SeekFrom}; +use crate::time::Timespec; + // Constants (matching POSIX / Linux values) const O_RDONLY: i32 = 0; @@ -145,7 +147,10 @@ pub unsafe extern "C" fn fstat(raw_fd: i32, buf: *mut Stat) -> i32 { ptr::write_bytes(buf, 0, 1); let s = &mut *buf; s.st_size = st.size as i64; - s.st_mtime = st.mtime as i64; + s.st_mtim = Timespec { + tv_sec: (st.mtime / NANOS_PER_SEC) as i64, + tv_nsec: (st.mtime % NANOS_PER_SEC) as i64, + }; s.st_mode = match st.file_type { syscall::FileType::File => S_IFREG | 0o644, syscall::FileType::Pipe => S_IFIFO | 0o644, @@ -480,18 +485,20 @@ pub struct Stat { pub st_dev: u64, pub st_ino: u64, pub st_mode: u32, - pub st_nlink: u32, + pub st_nlink: u64, pub st_uid: u32, pub st_gid: u32, pub st_rdev: u64, pub st_size: i64, pub st_blksize: i64, pub st_blocks: i64, - pub st_atime: i64, - pub st_mtime: i64, - pub st_ctime: i64, + pub st_atim: Timespec, + pub st_mtim: Timespec, + pub st_ctim: Timespec, } +const NANOS_PER_SEC: u64 = 1_000_000_000; + // mmap/munmap (real implementations using toyos-abi) #[no_mangle]