From 648866b4449297b8abbf627fb04e1809123e0161 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 25 Jul 2026 23:23:15 -0700 Subject: [PATCH] feat: restore guest archive timestamps --- crates/host-api/src/lib.rs | 21 ++++ crates/linux-abi/src/lib.rs | 3 + crates/linux-runtime/src/lib.rs | 180 +++++++++++++++++++++++++++++++- crates/memory-fs/src/lib.rs | 105 ++++++++++++++++--- 4 files changed, 288 insertions(+), 21 deletions(-) diff --git a/crates/host-api/src/lib.rs b/crates/host-api/src/lib.rs index ab89f3f..0e5c106 100644 --- a/crates/host-api/src/lib.rs +++ b/crates/host-api/src/lib.rs @@ -222,6 +222,19 @@ pub trait HostFileSystem { /// Returns a stable filesystem error when the path cannot be inspected. fn metadata(&mut self, path: &[u8]) -> Result; + /// Set a regular file or directory's modification time, or advance it to + /// the filesystem's next deterministic time when `modified` is `None`. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the path is missing, immutable, + /// or the timestamp cannot be represented. + fn set_modified_time( + &mut self, + path: &[u8], + modified: Option, + ) -> Result<(), FileSystemError>; + /// Open a normalized absolute byte path and return an opaque host handle. /// /// # Errors @@ -319,6 +332,14 @@ impl HostFileSystem for NullFileSystem { Err(FileSystemError::Unsupported) } + fn set_modified_time( + &mut self, + _path: &[u8], + _modified: Option, + ) -> Result<(), FileSystemError> { + Err(FileSystemError::Unsupported) + } + fn open(&mut self, _path: &[u8], _options: FileOpenOptions) -> Result { Err(FileSystemError::Unsupported) } diff --git a/crates/linux-abi/src/lib.rs b/crates/linux-abi/src/lib.rs index 332b4e2..1044f17 100644 --- a/crates/linux-abi/src/lib.rs +++ b/crates/linux-abi/src/lib.rs @@ -30,6 +30,7 @@ pub enum Syscall { Readlinkat = 78, Newfstatat = 79, Fstat = 80, + Utimensat = 88, Ppoll = 73, Exit = 93, ExitGroup = 94, @@ -89,6 +90,7 @@ impl Syscall { 78 => Some(Self::Readlinkat), 79 => Some(Self::Newfstatat), 80 => Some(Self::Fstat), + 88 => Some(Self::Utimensat), 93 => Some(Self::Exit), 94 => Some(Self::ExitGroup), 96 => Some(Self::SetTidAddress), @@ -191,6 +193,7 @@ mod tests { assert_eq!(Syscall::from_number(67), Some(Syscall::Pread64)); assert_eq!(Syscall::from_number(71), Some(Syscall::Sendfile)); assert_eq!(Syscall::from_number(80), Some(Syscall::Fstat)); + assert_eq!(Syscall::from_number(88), Some(Syscall::Utimensat)); assert_eq!(Syscall::from_number(73), Some(Syscall::Ppoll)); assert_eq!(Syscall::from_number(93), Some(Syscall::Exit)); assert_eq!(Syscall::from_number(94), Some(Syscall::ExitGroup)); diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index 0bf52ae..71fdb52 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -11,7 +11,7 @@ use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_host_api::{ ClosedInput, DeterministicSystem, FileAccess, FileMetadata, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, FileType, HostClock, HostFileSystem, HostInput, HostSystem, - HostTerminal, NullFileSystem, TerminalInputRead, TerminalStream, + HostTerminal, HostTime, NullFileSystem, TerminalInputRead, TerminalStream, }; use binarrow_linux_abi::{Errno, Syscall}; use binarrow_loader::{ @@ -79,6 +79,8 @@ const SUPPORTED_OPEN_FLAGS: u64 = OPEN_ACCESS_MASK | OPEN_PATH; const AT_REMOVE_DIRECTORY: u64 = 0x200; const AT_SYMLINK_NOFOLLOW: u64 = 0x100; +const UTIME_NOW: i64 = 0x3fff_ffff; +const UTIME_OMIT: i64 = 0x3fff_fffe; const STAT_SIZE: usize = 128; const STAT_MODE_OFFSET: usize = 16; const STAT_LINK_COUNT_OFFSET: usize = 20; @@ -378,6 +380,13 @@ struct PendingInput { arguments: [u64; 6], } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ModifiedTimeUpdate { + Preserve, + Now, + Explicit(HostTime), +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PipeEnd { Read(u64), @@ -499,7 +508,8 @@ impl fmt::Display for SyscallEvent { | Syscall::Openat | Syscall::Getdents64 | Syscall::Readlinkat - | Syscall::Newfstatat), + | Syscall::Newfstatat + | Syscall::Utimensat), ) => format_vfs_syscall(formatter, syscall, self.arguments)?, Some(Syscall::Close) => write!(formatter, "close(fd={})", self.arguments[0])?, Some(Syscall::Dup3) => write!( @@ -824,6 +834,14 @@ fn format_vfs_syscall( arguments[2], arguments[3], ), + Syscall::Utimensat => write!( + formatter, + "utimensat(dirfd={}, path={:#x}, times={:#x}, flags={:#x})", + arguments[0].cast_signed(), + arguments[1], + arguments[2], + arguments[3], + ), _ => unreachable!("only VFS syscalls are delegated to the VFS formatter"), } } @@ -1234,6 +1252,7 @@ impl Process { Some(Syscall::Readlinkat) => self.dispatch_readlinkat(filesystem), Some(Syscall::Newfstatat) => self.dispatch_newfstatat(filesystem), Some(Syscall::Fstat) => self.dispatch_fstat(filesystem), + Some(Syscall::Utimensat) => self.dispatch_utimensat(filesystem), Some(Syscall::Ftruncate) => self.dispatch_ftruncate(filesystem), Some(Syscall::Fchmod) => { self.set_return(if self.file_handle(self.register(0)).is_some() { @@ -2088,6 +2107,95 @@ impl Process { self.set_return(0); } + fn dispatch_utimensat(&mut self, filesystem: &mut F) { + if !matches!(self.register(3), 0 | AT_SYMLINK_NOFOLLOW) { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let path = if self.register(1) == 0 { + let Ok(descriptor) = u32::try_from(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let Some(path) = self.descriptor_paths.get(&descriptor).cloned() else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + path + } else { + match self + .read_guest_path(GuestAddress::new(self.register(1))) + .and_then(|path| self.resolve_at_path(filesystem, self.register(0), path)) + { + Ok(path) => path, + Err(error) => { + self.set_return(error.return_value()); + return; + } + } + }; + let modified = match self.read_utimensat_modified_time() { + Ok(modified) => modified, + Err(error) => { + self.set_return(error.return_value()); + return; + } + }; + let result = match modified { + ModifiedTimeUpdate::Explicit(modified) => { + filesystem.set_modified_time(&path, Some(modified)) + } + ModifiedTimeUpdate::Now => filesystem.set_modified_time(&path, None), + ModifiedTimeUpdate::Preserve => filesystem.metadata(&path).map(|_| ()), + }; + self.set_return(match result { + Ok(()) => 0, + Err(error) => filesystem_error_return(error), + }); + } + + fn read_utimensat_modified_time(&self) -> Result { + let times = GuestAddress::new(self.register(2)); + if times == GuestAddress::NULL { + return Ok(ModifiedTimeUpdate::Now); + } + let mut bytes = [0; 32]; + self.memory + .read_exact(times, &mut bytes) + .map_err(|_| Errno::Fault)?; + for offset in [8, 24] { + let nanoseconds = i64::from_le_bytes( + bytes[offset..offset + 8] + .try_into() + .expect("timespec nanoseconds occupy eight bytes"), + ); + if !matches!(nanoseconds, 0..1_000_000_000 | UTIME_NOW | UTIME_OMIT) { + return Err(Errno::InvalidArgument); + } + } + let nanoseconds = i64::from_le_bytes( + bytes[24..32] + .try_into() + .expect("mtime nanoseconds occupy eight bytes"), + ); + if nanoseconds == UTIME_OMIT { + return Ok(ModifiedTimeUpdate::Preserve); + } + if nanoseconds == UTIME_NOW { + return Ok(ModifiedTimeUpdate::Now); + } + let seconds = i64::from_le_bytes( + bytes[16..24] + .try_into() + .expect("mtime seconds occupy eight bytes"), + ); + Ok(ModifiedTimeUpdate::Explicit(HostTime { + seconds, + nanoseconds: u32::try_from(nanoseconds) + .expect("validated nonnegative nanoseconds fit u32"), + })) + } + fn dispatch_fstat(&mut self, filesystem: &mut F) { let file_descriptor = self.register(0); let standard_descriptor = matches!( @@ -4236,8 +4344,8 @@ mod tests { OPEN_PATH, PAGE_SIZE, PIPE_CAPACITY_BYTES, PROT_EXECUTE, PROT_READ, PipeEnd, Process, SIGNAL_USER_1, STANDARD_OUTPUT, STAT_CHARACTER_MODE, STAT_FIFO_MODE, STAT_FILE_SIZE_OFFSET, STAT_MODE_OFFSET, STAT_MODIFIED_SECONDS_OFFSET, STAT_REGULAR_MODE, STAT_SIZE, - SUPPORTED_CLONE_FLAGS, SUPPORTED_FORK_FLAGS, SyscallEvent, SyscallOutcome, - load_process_with_guest_filesystem, + SUPPORTED_CLONE_FLAGS, SUPPORTED_FORK_FLAGS, SyscallEvent, SyscallOutcome, UTIME_NOW, + UTIME_OMIT, load_process_with_guest_filesystem, }; const MESSAGE: &[u8] = b"hello, world\n"; @@ -5126,6 +5234,70 @@ mod tests { assert_eq!(&executable, b"/hello"); } + #[test] + fn utimensat_sets_archive_times_and_supports_open_descriptors() { + let image = load_hello(ProcessConfig::default(), 1, MESSAGE_ADDRESS); + let mut process = Process::new(image).unwrap(); + let mut filesystem = MemoryFileSystem::new(1024); + let handle = filesystem + .open( + b"/project/archive-entry", + FileOpenOptions { + access: FileAccess::ReadWrite, + flags: FileOpenFlags::CREATE, + }, + ) + .unwrap(); + let path = process.state.sp().checked_sub(128).unwrap(); + let times = process.state.sp().checked_sub(64).unwrap(); + process + .memory + .write(path, b"/project/archive-entry\0") + .unwrap(); + let mut time_bytes = [0; 32]; + time_bytes[8..16].copy_from_slice(&UTIME_OMIT.to_le_bytes()); + time_bytes[16..24].copy_from_slice(&1_650_000_000_i64.to_le_bytes()); + time_bytes[24..32].copy_from_slice(&123_i64.to_le_bytes()); + process.memory.write(times, &time_bytes).unwrap(); + process.state.set_x(0, AT_FDCWD).unwrap(); + process.state.set_x(1, path.get()).unwrap(); + process.state.set_x(2, times.get()).unwrap(); + process.state.set_x(3, 0).unwrap(); + + process.dispatch_utimensat(&mut filesystem); + + assert_eq!(process.register(0), 0); + assert_eq!( + filesystem + .metadata(b"/project/archive-entry") + .unwrap() + .modified, + binarrow_host_api::HostTime { + seconds: 1_650_000_000, + nanoseconds: 123, + } + ); + + process.file_descriptors.insert(3, handle); + process + .descriptor_paths + .insert(3, b"/project/archive-entry".to_vec()); + time_bytes[24..32].copy_from_slice(&UTIME_NOW.to_le_bytes()); + process.memory.write(times, &time_bytes).unwrap(); + process.state.set_x(0, 3).unwrap(); + process.state.set_x(1, 0).unwrap(); + process.dispatch_utimensat(&mut filesystem); + assert_eq!(process.register(0), 0); + assert!( + filesystem + .metadata(b"/project/archive-entry") + .unwrap() + .modified + .seconds + > 1_650_000_000 + ); + } + #[test] fn openat_accepts_path_only_directory_descriptors() { let image = load_hello(ProcessConfig::default(), 1, MESSAGE_ADDRESS); diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs index e5567b4..e9f3e01 100644 --- a/crates/memory-fs/src/lib.rs +++ b/crates/memory-fs/src/lib.rs @@ -9,11 +9,12 @@ use binarrow_host_api::{ }; const SNAPSHOT_MAGIC_V2: &[u8; 8] = b"BNFS\x02\0\0\0"; -const SNAPSHOT_MAGIC: &[u8; 8] = b"BNFS\x03\0\0\0"; +const SNAPSHOT_MAGIC_V3: &[u8; 8] = b"BNFS\x03\0\0\0"; +const SNAPSHOT_MAGIC: &[u8; 8] = b"BNFS\x04\0\0\0"; const SNAPSHOT_DIRECTORY: u8 = 1; const SNAPSHOT_REGULAR_FILE: u8 = 2; -const INITIAL_MODIFICATION_TIME: u64 = 1; -const MODIFICATION_TIME_EPOCH_SECONDS: i64 = 1_700_000_000; +const MODIFICATION_TIME_STEP: u64 = 1_000_000_000; +const INITIAL_MODIFICATION_TIME: u64 = 1_700_000_000 * MODIFICATION_TIME_STEP; const PROC_CPUINFO: &[u8] = b"processor\t: 0\nmodel name\t: Binarrow virtual AArch64\n"; const PROC_SELF_STATUS: &[u8] = b"Name:\tbinarrow-guest\nState:\tR (running)\nThreads:\t1\n"; @@ -96,7 +97,7 @@ impl MemoryFileSystem { modified, open_handles: BTreeMap::new(), next_handle: 1, - next_modification_time: INITIAL_MODIFICATION_TIME + 1, + next_modification_time: INITIAL_MODIFICATION_TIME + MODIFICATION_TIME_STEP, stored_bytes: 0, byte_limit, } @@ -104,7 +105,9 @@ impl MemoryFileSystem { fn allocate_modification_time(&mut self) -> u64 { let modified = self.next_modification_time; - self.next_modification_time = self.next_modification_time.saturating_add(1); + self.next_modification_time = self + .next_modification_time + .saturating_add(MODIFICATION_TIME_STEP); modified } @@ -272,10 +275,12 @@ impl MemoryFileSystem { pub fn from_snapshot(byte_limit: u64, snapshot: &[u8]) -> Result { let mut reader = SnapshotReader::new(snapshot); let magic = reader.read(SNAPSHOT_MAGIC.len())?; - let has_modification_times = if magic == SNAPSHOT_MAGIC { - true + let snapshot_version = if magic == SNAPSHOT_MAGIC { + 4 + } else if magic == SNAPSHOT_MAGIC_V3 { + 3 } else if magic == SNAPSHOT_MAGIC_V2 { - false + 2 } else { return Err(SnapshotError::InvalidHeader); }; @@ -285,7 +290,7 @@ impl MemoryFileSystem { let entry_type = reader.read_u8()?; let path_length = reader.read_u32()?; let file_length = reader.read_u64()?; - let stored_modified = if has_modification_times { + let stored_modified = if snapshot_version >= 3 { Some(reader.read_u64()?) } else { None @@ -299,7 +304,13 @@ impl MemoryFileSystem { if normalized != path || !is_snapshot_path(&path) { return Err(SnapshotError::InvalidPath); } - let modified = stored_modified.unwrap_or_else(|| legacy_modification_time(&path)); + let modified = match (snapshot_version, stored_modified) { + (4, Some(modified)) => modified, + (3, Some(generation)) => INITIAL_MODIFICATION_TIME + .saturating_add(generation.saturating_mul(MODIFICATION_TIME_STEP)), + (2, None) => legacy_modification_time(&path), + _ => unreachable!("snapshot version determines timestamp encoding"), + }; let bytes = reader.read(file_length)?.to_vec(); match entry_type { SNAPSHOT_DIRECTORY if bytes.is_empty() => { @@ -344,7 +355,7 @@ impl MemoryFileSystem { .copied() .max() .unwrap_or(INITIAL_MODIFICATION_TIME) - .saturating_add(1); + .saturating_add(MODIFICATION_TIME_STEP); Ok(filesystem) } @@ -764,6 +775,36 @@ impl HostFileSystem for MemoryFileSystem { Err(FileSystemError::NotFound) } + fn set_modified_time( + &mut self, + path: &[u8], + modified: Option, + ) -> Result<(), FileSystemError> { + let path = normalize_path(path)?; + if !is_mutable_path(&path) { + return Err(FileSystemError::PermissionDenied); + } + if !self.directories.contains(&path) && !self.files.contains_key(&path) { + return Err(FileSystemError::NotFound); + } + let timestamp = match modified { + Some(modified) if modified.seconds >= 0 && modified.nanoseconds < 1_000_000_000 => { + u64::try_from(modified.seconds) + .ok() + .and_then(|seconds| seconds.checked_mul(MODIFICATION_TIME_STEP)) + .and_then(|seconds| seconds.checked_add(u64::from(modified.nanoseconds))) + .ok_or(FileSystemError::InvalidInput)? + } + Some(_) => return Err(FileSystemError::InvalidInput), + None => self.allocate_modification_time(), + }; + self.modified.insert(path, timestamp); + self.next_modification_time = self + .next_modification_time + .max(timestamp.saturating_add(MODIFICATION_TIME_STEP)); + Ok(()) + } + fn open(&mut self, path: &[u8], options: FileOpenOptions) -> Result { self.open_file(normalize_path(path)?, options) } @@ -1034,19 +1075,19 @@ impl HostFileSystem for MemoryFileSystem { } } -fn modification_time(generation: u64) -> HostTime { - let generation = i64::try_from(generation).unwrap_or(i64::MAX); +fn modification_time(timestamp: u64) -> HostTime { HostTime { - seconds: MODIFICATION_TIME_EPOCH_SECONDS.saturating_add(generation), - nanoseconds: 0, + seconds: i64::try_from(timestamp / MODIFICATION_TIME_STEP).unwrap_or(i64::MAX), + nanoseconds: u32::try_from(timestamp % MODIFICATION_TIME_STEP) + .expect("subsecond timestamp remainder fits u32"), } } fn legacy_modification_time(path: &[u8]) -> u64 { if path.starts_with(b"/project/target") || path.starts_with(b"/project/.cache/") { - INITIAL_MODIFICATION_TIME + 2 + INITIAL_MODIFICATION_TIME + 2 * MODIFICATION_TIME_STEP } else if path.starts_with(b"/project/") { - INITIAL_MODIFICATION_TIME + 1 + INITIAL_MODIFICATION_TIME + MODIFICATION_TIME_STEP } else { INITIAL_MODIFICATION_TIME } @@ -1361,6 +1402,36 @@ mod tests { assert!(source.seconds < output.seconds); } + #[test] + fn version_three_snapshot_generations_migrate_to_absolute_times() { + let path = b"/project/source.rs"; + let contents = b"source"; + let mut snapshot = b"BNFS\x03\0\0\0".to_vec(); + snapshot.extend_from_slice(&1_u32.to_le_bytes()); + snapshot.push(2); + snapshot.extend_from_slice(&u32::try_from(path.len()).unwrap().to_le_bytes()); + snapshot.extend_from_slice(&u64::try_from(contents.len()).unwrap().to_le_bytes()); + snapshot.extend_from_slice(&7_u64.to_le_bytes()); + snapshot.extend_from_slice(path); + snapshot.extend_from_slice(contents); + + let mut filesystem = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); + let modified = filesystem.metadata(path).unwrap().modified; + assert_eq!(modified.seconds, 1_700_000_007); + assert_eq!(modified.nanoseconds, 0); + + let canonical = filesystem.export_snapshot().unwrap(); + assert_eq!(&canonical[..SNAPSHOT_MAGIC.len()], SNAPSHOT_MAGIC); + assert_eq!( + MemoryFileSystem::from_snapshot(64, &canonical) + .unwrap() + .metadata(path) + .unwrap() + .modified, + modified + ); + } + #[test] fn atomically_replaces_file_contents() { let mut filesystem = MemoryFileSystem::new(12); -- 2.51.2