diff --git a/crates/host-api/src/lib.rs b/crates/host-api/src/lib.rs index 70e9241..ab89f3f 100644 --- a/crates/host-api/src/lib.rs +++ b/crates/host-api/src/lib.rs @@ -195,6 +195,7 @@ pub struct DirectoryEntry { pub struct FileMetadata { pub file_type: FileType, pub size: u64, + pub modified: HostTime, } /// Filesystem failures that map deterministically onto Linux errno values. diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index cedcc03..0bf52ae 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -85,6 +85,12 @@ const STAT_LINK_COUNT_OFFSET: usize = 20; const STAT_FILE_SIZE_OFFSET: usize = 48; const STAT_BLOCK_SIZE_OFFSET: usize = 56; const STAT_BLOCK_COUNT_OFFSET: usize = 64; +const STAT_ACCESS_SECONDS_OFFSET: usize = 72; +const STAT_ACCESS_NANOSECONDS_OFFSET: usize = 80; +const STAT_MODIFIED_SECONDS_OFFSET: usize = 88; +const STAT_MODIFIED_NANOSECONDS_OFFSET: usize = 96; +const STAT_CHANGED_SECONDS_OFFSET: usize = 104; +const STAT_CHANGED_NANOSECONDS_OFFSET: usize = 112; const STAT_REGULAR_MODE: u32 = 0o100_644; const STAT_DIRECTORY_MODE: u32 = 0o040_755; const STAT_CHARACTER_MODE: u32 = 0o020_620; @@ -4049,6 +4055,22 @@ fn linux_stat_bytes(metadata: FileMetadata, identity: &[u8]) -> [u8; STAT_SIZE] let block_count = metadata.size.saturating_add(511) / 512; bytes[STAT_BLOCK_COUNT_OFFSET..STAT_BLOCK_COUNT_OFFSET + 8] .copy_from_slice(&block_count.to_le_bytes()); + let seconds = metadata.modified.seconds.to_le_bytes(); + let nanoseconds = u64::from(metadata.modified.nanoseconds).to_le_bytes(); + for offset in [ + STAT_ACCESS_SECONDS_OFFSET, + STAT_MODIFIED_SECONDS_OFFSET, + STAT_CHANGED_SECONDS_OFFSET, + ] { + bytes[offset..offset + 8].copy_from_slice(&seconds); + } + for offset in [ + STAT_ACCESS_NANOSECONDS_OFFSET, + STAT_MODIFIED_NANOSECONDS_OFFSET, + STAT_CHANGED_NANOSECONDS_OFFSET, + ] { + bytes[offset..offset + 8].copy_from_slice(&nanoseconds); + } bytes } @@ -4213,8 +4235,9 @@ mod tests { OPEN_CLOEXEC, OPEN_DIRECTORY, OPEN_LARGEFILE, OPEN_NOCTTY, OPEN_NOFOLLOW, OPEN_NONBLOCK, 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_REGULAR_MODE, STAT_SIZE, SUPPORTED_CLONE_FLAGS, - SUPPORTED_FORK_FLAGS, SyscallEvent, SyscallOutcome, load_process_with_guest_filesystem, + 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, }; const MESSAGE: &[u8] = b"hello, world\n"; @@ -5054,6 +5077,18 @@ mod tests { ), 5 ); + assert_eq!( + i64::from_le_bytes( + status[STAT_MODIFIED_SECONDS_OFFSET..STAT_MODIFIED_SECONDS_OFFSET + 8] + .try_into() + .unwrap() + ), + filesystem + .metadata(b"/project/python/lib/python3.12/os.py") + .unwrap() + .modified + .seconds + ); let file_inode = u64::from_le_bytes(status[8..16].try_into().unwrap()); process diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs index 2ee278a..e5567b4 100644 --- a/crates/memory-fs/src/lib.rs +++ b/crates/memory-fs/src/lib.rs @@ -5,12 +5,15 @@ use std::fmt; use binarrow_host_api::{ DirectoryEntry, FileAccess, FileMetadata, FileOpenFlags, FileOpenOptions, FileSeekFrom, - FileSystemError, FileType, HostFileSystem, + FileSystemError, FileType, HostFileSystem, HostTime, }; -const SNAPSHOT_MAGIC: &[u8; 8] = b"BNFS\x02\0\0\0"; +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_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 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"; @@ -63,8 +66,10 @@ enum OpenHandle { pub struct MemoryFileSystem { directories: BTreeSet>, files: BTreeMap, Vec>, + modified: BTreeMap, u64>, open_handles: BTreeMap, next_handle: u64, + next_modification_time: u64, stored_bytes: u64, byte_limit: u64, } @@ -72,23 +77,42 @@ pub struct MemoryFileSystem { impl MemoryFileSystem { #[must_use] pub fn new(byte_limit: u64) -> Self { + let directories = BTreeSet::from([ + b"/".to_vec(), + b"/dev".to_vec(), + b"/proc".to_vec(), + b"/proc/self".to_vec(), + b"/project".to_vec(), + b"/tmp".to_vec(), + ]); + let modified = directories + .iter() + .cloned() + .map(|path| (path, INITIAL_MODIFICATION_TIME)) + .collect(); Self { - directories: BTreeSet::from([ - b"/".to_vec(), - b"/dev".to_vec(), - b"/proc".to_vec(), - b"/proc/self".to_vec(), - b"/project".to_vec(), - b"/tmp".to_vec(), - ]), + directories, files: BTreeMap::new(), + modified, open_handles: BTreeMap::new(), next_handle: 1, + next_modification_time: INITIAL_MODIFICATION_TIME + 1, stored_bytes: 0, byte_limit, } } + fn allocate_modification_time(&mut self) -> u64 { + let modified = self.next_modification_time; + self.next_modification_time = self.next_modification_time.saturating_add(1); + modified + } + + fn touch(&mut self, path: &[u8]) { + let modified = self.allocate_modification_time(); + self.modified.insert(path.to_vec(), modified); + } + #[must_use] pub const fn stored_bytes(&self) -> u64 { self.stored_bytes @@ -160,6 +184,7 @@ impl MemoryFileSystem { return Err(self.missing_directory_error(parent)); } self.directories.insert(path.to_vec()); + self.touch(path); Ok(()) } @@ -195,6 +220,7 @@ impl MemoryFileSystem { .ok_or(FileSystemError::NoSpace)?; self.files.insert(path.to_vec(), contents.to_vec()); self.stored_bytes = next_size; + self.touch(path); Ok(()) } @@ -220,9 +246,18 @@ impl MemoryFileSystem { if let Some(bytes) = self.files.remove(&removed) { self.stored_bytes -= bytes.len() as u64; } + self.modified.remove(&removed); + } + let removed_directories = self + .directories + .iter() + .filter(|candidate| **candidate == normalized || is_descendant(candidate, &normalized)) + .cloned() + .collect::>(); + for removed in removed_directories { + self.directories.remove(&removed); + self.modified.remove(&removed); } - self.directories - .retain(|candidate| *candidate != normalized && !is_descendant(candidate, &normalized)); Ok(()) } @@ -236,15 +271,25 @@ impl MemoryFileSystem { /// over-budget entries. pub fn from_snapshot(byte_limit: u64, snapshot: &[u8]) -> Result { let mut reader = SnapshotReader::new(snapshot); - if reader.read(SNAPSHOT_MAGIC.len())? != SNAPSHOT_MAGIC { + let magic = reader.read(SNAPSHOT_MAGIC.len())?; + let has_modification_times = if magic == SNAPSHOT_MAGIC { + true + } else if magic == SNAPSHOT_MAGIC_V2 { + false + } else { return Err(SnapshotError::InvalidHeader); - } + }; let entry_count = reader.read_u32()?; let mut filesystem = Self::new(byte_limit); for _ in 0..entry_count { 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 { + Some(reader.read_u64()?) + } else { + None + }; let path_length = usize::try_from(path_length).map_err(|_| SnapshotError::HostSizeUnsupported)?; let file_length = @@ -254,16 +299,18 @@ 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 bytes = reader.read(file_length)?.to_vec(); match entry_type { SNAPSHOT_DIRECTORY if bytes.is_empty() => { let parent = parent_path(&path).ok_or(SnapshotError::InvalidPath)?; if !filesystem.directories.contains(parent) || filesystem.files.contains_key(&path) - || !filesystem.directories.insert(path) + || !filesystem.directories.insert(path.clone()) { return Err(SnapshotError::DuplicatePath); } + filesystem.modified.insert(path, modified); } SNAPSHOT_REGULAR_FILE => { let parent = parent_path(&path).ok_or(SnapshotError::InvalidPath)?; @@ -278,10 +325,11 @@ impl MemoryFileSystem { return Err(SnapshotError::CapacityExceeded); } if filesystem.directories.contains(&path) - || filesystem.files.insert(path, bytes).is_some() + || filesystem.files.insert(path.clone(), bytes).is_some() { return Err(SnapshotError::DuplicatePath); } + filesystem.modified.insert(path, modified); filesystem.stored_bytes = next_size; } _ => return Err(SnapshotError::InvalidPath), @@ -290,6 +338,13 @@ impl MemoryFileSystem { if !reader.is_finished() { return Err(SnapshotError::TrailingBytes); } + filesystem.next_modification_time = filesystem + .modified + .values() + .copied() + .max() + .unwrap_or(INITIAL_MODIFICATION_TIME) + .saturating_add(1); Ok(filesystem) } @@ -326,6 +381,14 @@ impl MemoryFileSystem { .to_le_bytes(), ); snapshot.extend_from_slice(&0_u64.to_le_bytes()); + snapshot.extend_from_slice( + &self + .modified + .get(path.as_slice()) + .copied() + .unwrap_or(INITIAL_MODIFICATION_TIME) + .to_le_bytes(), + ); snapshot.extend_from_slice(path); } for (path, bytes) in files { @@ -340,6 +403,14 @@ impl MemoryFileSystem { .map_err(|_| SnapshotError::HostSizeUnsupported)? .to_le_bytes(), ); + snapshot.extend_from_slice( + &self + .modified + .get(path.as_slice()) + .copied() + .unwrap_or(INITIAL_MODIFICATION_TIME) + .to_le_bytes(), + ); snapshot.extend_from_slice(path); snapshot.extend_from_slice(bytes); } @@ -365,6 +436,7 @@ impl MemoryFileSystem { return Err(SnapshotError::DuplicatePath); } merged.directories.insert(directory.clone()); + merged.touch(directory); } for (path, bytes) in image.files { if merged.directories.contains(&path) { @@ -379,8 +451,9 @@ impl MemoryFileSystem { if next_size > merged.byte_limit { return Err(SnapshotError::CapacityExceeded); } - merged.files.insert(path, bytes); + merged.files.insert(path.clone(), bytes); merged.stored_bytes = next_size; + merged.touch(&path); } *self = merged; Ok(()) @@ -428,6 +501,7 @@ impl MemoryFileSystem { return Ok(handle); } let exists = self.files.contains_key(&path); + let mut changed = false; if exists && options.access.can_write() && !is_mutable_path(&path) { return Err(FileSystemError::PermissionDenied); } @@ -449,6 +523,7 @@ impl MemoryFileSystem { return Err(FileSystemError::PermissionDenied); } self.files.insert(path.clone(), Vec::new()); + changed = true; } if options.flags.contains(FileOpenFlags::TRUNCATE) { if !options.access.can_write() { @@ -460,6 +535,10 @@ impl MemoryFileSystem { .expect("an existing or newly created file is present"); self.stored_bytes -= file.len() as u64; file.clear(); + changed = true; + } + if changed { + self.touch(&path); } let handle = self.allocate_handle()?; self.open_handles.insert( @@ -580,6 +659,8 @@ impl MemoryFileSystem { } file[host_position..end].copy_from_slice(source); *position = end as u64; + let modified_path = path.clone(); + self.touch(&modified_path); Ok(source.len()) } @@ -653,18 +734,31 @@ impl HostFileSystem for MemoryFileSystem { return Ok(FileMetadata { file_type: FileType::Directory, size: 0, + modified: modification_time( + self.modified + .get(&path) + .copied() + .unwrap_or(INITIAL_MODIFICATION_TIME), + ), }); } if let Some(bytes) = self.files.get(&path) { return Ok(FileMetadata { file_type: FileType::Regular, size: bytes.len() as u64, + modified: modification_time( + self.modified + .get(&path) + .copied() + .unwrap_or(INITIAL_MODIFICATION_TIME), + ), }); } if is_synthetic_file(&path) { return Ok(FileMetadata { file_type: FileType::Regular, size: synthetic_file_contents(&path).map_or(0, |bytes| bytes.len() as u64), + modified: modification_time(INITIAL_MODIFICATION_TIME), }); } Err(FileSystemError::NotFound) @@ -717,6 +811,7 @@ impl HostFileSystem for MemoryFileSystem { .ok_or(FileSystemError::NoSpace)?; file.resize(host_length, 0); self.stored_bytes = next_stored_bytes; + self.touch(&path); Ok(()) } @@ -739,7 +834,8 @@ impl HostFileSystem for MemoryFileSystem { if !self.directories.contains(parent) { return Err(self.missing_directory_error(parent)); } - self.directories.insert(path); + self.directories.insert(path.clone()); + self.touch(&path); Ok(()) } @@ -811,14 +907,21 @@ impl HostFileSystem for MemoryFileSystem { return Err(self.missing_directory_error(new_parent)); } if let Some(bytes) = self.files.remove(&old_path) { + let modified = self + .modified + .remove(&old_path) + .unwrap_or(INITIAL_MODIFICATION_TIME); if self.directories.contains(&new_path) { - self.files.insert(old_path, bytes); + self.files.insert(old_path.clone(), bytes); + self.modified.insert(old_path, modified); return Err(FileSystemError::IsDirectory); } if let Some(replaced) = self.files.remove(&new_path) { self.stored_bytes -= replaced.len() as u64; } + self.modified.remove(&new_path); self.files.insert(new_path.clone(), bytes); + self.modified.insert(new_path.clone(), modified); self.replace_open_paths(&old_path, &new_path); return Ok(()); } @@ -844,6 +947,7 @@ impl HostFileSystem for MemoryFileSystem { return Err(FileSystemError::NotEmpty); } self.directories.remove(&new_path); + self.modified.remove(&new_path); } let moved_directories = self .directories @@ -869,6 +973,19 @@ impl HostFileSystem for MemoryFileSystem { self.files .insert(replace_prefix(&path, &old_path, &new_path), bytes); } + let moved_modification_times = self + .modified + .iter() + .filter(|(path, _)| **path == old_path || is_descendant(path, &old_path)) + .map(|(path, modified)| (path.clone(), *modified)) + .collect::>(); + for (path, _) in &moved_modification_times { + self.modified.remove(path); + } + for (path, modified) in moved_modification_times { + self.modified + .insert(replace_prefix(&path, &old_path, &new_path), modified); + } self.replace_open_paths(&old_path, &new_path); Ok(()) } @@ -901,6 +1018,7 @@ impl HostFileSystem for MemoryFileSystem { return Err(FileSystemError::NotEmpty); } self.directories.remove(&path); + self.modified.remove(&path); return Ok(()); } if self.directories.contains(&path) { @@ -910,11 +1028,30 @@ impl HostFileSystem for MemoryFileSystem { return Err(FileSystemError::PermissionDenied); } let bytes = self.files.remove(&path).ok_or(FileSystemError::NotFound)?; + self.modified.remove(&path); self.stored_bytes -= bytes.len() as u64; Ok(()) } } +fn modification_time(generation: u64) -> HostTime { + let generation = i64::try_from(generation).unwrap_or(i64::MAX); + HostTime { + seconds: MODIFICATION_TIME_EPOCH_SECONDS.saturating_add(generation), + nanoseconds: 0, + } +} + +fn legacy_modification_time(path: &[u8]) -> u64 { + if path.starts_with(b"/project/target") || path.starts_with(b"/project/.cache/") { + INITIAL_MODIFICATION_TIME + 2 + } else if path.starts_with(b"/project/") { + INITIAL_MODIFICATION_TIME + 1 + } else { + INITIAL_MODIFICATION_TIME + } +} + fn normalize_path(path: &[u8]) -> Result, FileSystemError> { if path.first() != Some(&b'/') || path.contains(&0) { return Err(FileSystemError::InvalidInput); @@ -1081,7 +1218,7 @@ mod tests { HostFileSystem, }; - use super::{MemoryFileSystem, SnapshotError}; + use super::{MemoryFileSystem, SNAPSHOT_MAGIC, SnapshotError}; const CREATE_READ_WRITE: FileOpenOptions = FileOpenOptions { access: FileAccess::ReadWrite, @@ -1173,7 +1310,15 @@ mod tests { let mut filesystem = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); assert_eq!(filesystem.read_file(path), Some(&contents[..])); - assert_eq!(filesystem.export_snapshot().unwrap(), snapshot); + let canonical = filesystem.export_snapshot().unwrap(); + assert_eq!(&canonical[..SNAPSHOT_MAGIC.len()], SNAPSHOT_MAGIC); + assert_eq!( + MemoryFileSystem::from_snapshot(64, &canonical) + .unwrap() + .export_snapshot() + .unwrap(), + canonical + ); assert_eq!( filesystem.open( path, @@ -1186,6 +1331,36 @@ mod tests { ); } + #[test] + fn legacy_snapshots_order_toolchains_sources_and_build_outputs() { + fn push_entry(snapshot: &mut Vec, kind: u8, path: &[u8], contents: &[u8]) { + snapshot.push(kind); + 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(path); + snapshot.extend_from_slice(contents); + } + + let mut snapshot = b"BNFS\x02\0\0\0".to_vec(); + snapshot.extend_from_slice(&6_u32.to_le_bytes()); + push_entry(&mut snapshot, 1, b"/usr", b""); + push_entry(&mut snapshot, 1, b"/usr/bin", b""); + push_entry(&mut snapshot, 1, b"/project/target-old", b""); + push_entry(&mut snapshot, 2, b"/usr/bin/rustc", b"compiler"); + push_entry(&mut snapshot, 2, b"/project/src.rs", b"source"); + push_entry(&mut snapshot, 2, b"/project/target-old/output", b"output"); + + let mut filesystem = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); + let compiler = filesystem.metadata(b"/usr/bin/rustc").unwrap().modified; + let source = filesystem.metadata(b"/project/src.rs").unwrap().modified; + let output = filesystem + .metadata(b"/project/target-old/output") + .unwrap() + .modified; + assert!(compiler.seconds < source.seconds); + assert!(source.seconds < output.seconds); + } + #[test] fn atomically_replaces_file_contents() { let mut filesystem = MemoryFileSystem::new(12); @@ -1216,6 +1391,42 @@ mod tests { ); } + #[test] + fn snapshots_preserve_deterministic_modification_order() { + let mut filesystem = MemoryFileSystem::new(64); + filesystem + .replace_project_file_contents(b"/project/source.rs", b"source") + .unwrap(); + let source_time = filesystem.metadata(b"/project/source.rs").unwrap().modified; + filesystem + .replace_project_file_contents(b"/project/output", b"output") + .unwrap(); + let output_time = filesystem.metadata(b"/project/output").unwrap().modified; + assert!(output_time.seconds > source_time.seconds); + + let snapshot = filesystem.export_snapshot().unwrap(); + let mut restored = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); + assert_eq!( + restored.metadata(b"/project/source.rs").unwrap().modified, + source_time + ); + assert_eq!( + restored.metadata(b"/project/output").unwrap().modified, + output_time + ); + restored + .replace_project_file_contents(b"/project/source.rs", b"changed") + .unwrap(); + assert!( + restored + .metadata(b"/project/source.rs") + .unwrap() + .modified + .seconds + > output_time.seconds + ); + } + #[test] fn clears_one_project_tree_without_touching_source_files() { let mut filesystem = MemoryFileSystem::new(64);