Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions issues/filesystem/std-calls-undated-what-it-did-not-stat.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,30 @@ 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
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

Expand Down
22 changes: 19 additions & 3 deletions kernel/src/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,31 @@ pub fn init_wall(century_reg: Option<u8>) {
}
};

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<u64> {
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<u64> {
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)
}

20 changes: 13 additions & 7 deletions kernel/src/fat32_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -946,12 +950,12 @@ impl FileSystem for FatFs {
Ok((file_id, Some(backing)))
}

fn create(&mut self, name: &str, _mtime: u64) -> Result<FileId, SyscallError> {
fn create(&mut self, name: &str, mtime: u64) -> Result<FileId, SyscallError> {
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,
Expand Down Expand Up @@ -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)?;
Expand Down
2 changes: 1 addition & 1 deletion kernel/src/leak_selftest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 4 additions & 4 deletions kernel/src/object/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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),
Expand Down Expand Up @@ -523,7 +523,7 @@ pub fn try_write(object: &KObjectRef, buf: &UserBytes) -> Option<u64> {
}
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),
Expand Down Expand Up @@ -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
})
}
Expand Down
2 changes: 1 addition & 1 deletion kernel/src/revoke_selftest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}")),
Expand Down
5 changes: 3 additions & 2 deletions kernel/src/vfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, SyscallError>;

/// 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<u64, SyscallError>;

/// What `name` points at; `Ok(None)` for a non-link or an absent name, never for a device that would not answer.
Expand Down Expand Up @@ -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)?;
}
Expand Down
2 changes: 1 addition & 1 deletion rust
Loading
Loading