diff options
Diffstat (limited to 'crates/ghostty-vt/src/pane_graphics_files.rs')
| -rw-r--r-- | crates/ghostty-vt/src/pane_graphics_files.rs | 1041 |
1 files changed, 1041 insertions, 0 deletions
diff --git a/crates/ghostty-vt/src/pane_graphics_files.rs b/crates/ghostty-vt/src/pane_graphics_files.rs new file mode 100644 index 00000000..c5f844d2 --- /dev/null +++ b/crates/ghostty-vt/src/pane_graphics_files.rs @@ -0,0 +1,1041 @@ +#[cfg(unix)] +use std::fs::OpenOptions; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + +#[cfg(target_os = "linux")] +use crate::native_image_sources::clone_native_image_source; + +/// Kernel CoW snapshots are deliberately unsupported outside Linux. +#[cfg(not(target_os = "linux"))] +fn clone_native_image_source( + _source_fd: i64, + _destination: &File, + _expected_len: usize, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "native source cloning requires Linux", + )) +} + +// Herdr's default log filter is `herdr=info`; keep these warnings under its target. +const LOG_TARGET: &str = "herdr::pane_graphics_files"; + +#[cfg(unix)] +const DIRECTORY_MODE: u32 = 0o700; +#[cfg(unix)] +const FILE_MODE: u32 = 0o600; + +#[derive(Debug)] +pub struct FileStore { + base: PathBuf, + generation: OnceLock<Arc<Generation>>, + next_fingerprint: AtomicU64, + native_budget: Option<Arc<Mutex<NativeBudget>>>, +} + +#[derive(Debug)] +struct Generation { + root: PathBuf, + source: PathBuf, +} + +/// Keep this owned snapshot alive until the terminal has consumed its path. +#[derive(Debug)] +pub struct OwnedExport { + lease: Lease, + _file: ExportFile, + _reservation: Option<Reservation>, +} + +// Also owns cleanup during writing/validation, before an export is published. +#[derive(Debug)] +struct ExportFile { + path: PathBuf, + _generation: Arc<Generation>, +} + +#[derive(Debug, Default)] +struct NativeBudget { + bytes: usize, + objects: usize, +} + +#[derive(Debug)] +struct Reservation { + budget: Arc<Mutex<NativeBudget>>, + len: usize, +} + +impl Reservation { + fn acquire(budget: &Arc<Mutex<NativeBudget>>, len: usize) -> io::Result<Self> { + let mut state = budget.lock().unwrap_or_else(|err| err.into_inner()); + if state.objects >= 64 || len > 64 * 1024 * 1024 - state.bytes { + return Err(io::Error::other("native snapshot budget exhausted")); + } + state.objects += 1; + state.bytes += len; + Ok(Self { + budget: Arc::clone(budget), + len, + }) + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + let mut state = self.budget.lock().unwrap_or_else(|err| err.into_inner()); + state.objects -= 1; + state.bytes -= self.len; + } +} + +const MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug)] +struct Lease { + path: PathBuf, + file: File, + generation: Arc<Generation>, + metadata: fs::Metadata, + len: usize, + fingerprint: u64, +} + +impl Default for FileStore { + fn default() -> Self { + Self::new(runtime_base()) + } +} + +impl FileStore { + fn new(base: PathBuf) -> Self { + Self { + base, + generation: OnceLock::new(), + next_fingerprint: AtomicU64::new(1), + native_budget: None, + } + } + + pub fn native_sources() -> Self { + let mut store = Self::new(native_base()); + store.native_budget = Some(Arc::new(Mutex::new(NativeBudget::default()))); + store + } + + /// Kernel-only bounded snapshot; any unsupported clone returns an error. + /// The caller keeps the borrowed descriptor alive throughout this call. + pub fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> { + if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid native RGBA length", + )); + } + let budget = self.native_budget.as_ref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "not a native source store") + })?; + let reservation = Reservation::acquire(budget, expected_len)?; + let generation = self.generation()?; + let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + let path = generation.source.join(format!("native-{id}.rgba")); + let destination = create_export(&path)?; + let file = ExportFile { + path, + _generation: generation, + }; + clone_native_image_source(source_fd, &destination, expected_len)?; + drop(destination); + let lease = self.lease(&file.path, expected_len)?; + Ok(OwnedExport { + lease, + _file: file, + _reservation: Some(reservation), + }) + } + + // Not test-gated: herdr's own tests use it, and a dependency's cfg(test) is off. + #[cfg(unix)] + pub fn source_directory(&self) -> io::Result<PathBuf> { + Ok(self.generation()?.source.clone()) + } + + fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> { + let generation = self.generation()?; + validate_child(path, &generation.source)?; + let file = open_no_follow(path)?; + let metadata = file.metadata()?; + validate_metadata(&metadata, expected_len)?; + validate_path_identity(path, &metadata)?; + let fingerprint = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + Ok(Lease { + path: path.to_owned(), + file, + generation, + metadata, + len: expected_len, + fingerprint, + }) + } + + /// Snapshot decoded bytes into a private, uniquely named file. Aggregate + /// outstanding-export limits are the caller's responsibility. + pub fn export(&self, data: &[u8]) -> io::Result<OwnedExport> { + if data.len() > MAX_EXPORT_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "decoded export exceeds 16 MiB", + )); + } + let reservation = self + .native_budget + .as_ref() + .map(|budget| Reservation::acquire(budget, data.len())) + .transpose()?; + let generation = self.generation()?; + // Share the lease identity allocator; create_new also protects against + // collisions with producer files. Never overwrite or remove a collision. + let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + let path = generation.source.join(format!("decoded-{id}.rgba")); + let mut writer = create_export(&path)?; + let file = ExportFile { + path, + _generation: generation, + }; + writer.write_all(data)?; + drop(writer); + let lease = self.lease(&file.path, data.len())?; + Ok(OwnedExport { + lease, + _file: file, + _reservation: reservation, + }) + } + + fn generation(&self) -> io::Result<Arc<Generation>> { + if let Some(generation) = self.generation.get() { + return Ok(Arc::clone(generation)); + } + let generation = Arc::new(create_generation(&self.base)?); + match self.generation.set(Arc::clone(&generation)) { + Ok(()) => Ok(generation), + Err(_) => self + .generation + .get() + .cloned() + .ok_or_else(|| io::Error::other("pane graphics generation was lost")), + } + } + + #[cfg(all(test, unix))] + pub(crate) fn is_initialized(&self) -> bool { + self.generation.get().is_some() + } +} + +impl PartialEq for OwnedExport { + fn eq(&self, other: &Self) -> bool { + self.path() == other.path() && self.fingerprint() == other.fingerprint() + } +} + +impl Eq for OwnedExport {} + +impl OwnedExport { + pub fn copy_rgba(&self) -> io::Result<Vec<u8>> { + self.lease.copy_rgba() + } + + pub(crate) fn read_into(&self, data: &mut [u8]) -> io::Result<()> { + self.lease.read_into(data) + } + + pub fn path(&self) -> &Path { + self.lease.path() + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.lease.len() + } + + pub fn fingerprint(&self) -> u64 { + self.lease.fingerprint() + } +} + +impl Drop for ExportFile { + fn drop(&mut self) { + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + tracing::warn!(target: LOG_TARGET, path = %self.path.display(), err = %err, "failed to remove decoded graphics export"); + } + } + } +} + +impl Lease { + fn path(&self) -> &Path { + &self.path + } + + fn len(&self) -> usize { + self.len + } + + fn fingerprint(&self) -> u64 { + self.fingerprint + } + + fn copy_rgba(&self) -> io::Result<Vec<u8>> { + let mut data = vec![0; self.len]; + self.read_into(&mut data)?; + Ok(data) + } + + fn read_into(&self, data: &mut [u8]) -> io::Result<()> { + if data.len() != self.len { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "incorrect output length", + )); + } + let _keep_generation_alive = &self.generation; + validate_path_identity(&self.path, &self.metadata)?; + read_exact_at(&self.file, data)?; + if has_byte_at(&self.file, self.len as u64)? { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "frame length changed while leased", + )); + } + validate_metadata(&self.file.metadata()?, self.len)?; + validate_path_identity(&self.path, &self.metadata)?; + Ok(()) + } +} + +impl Drop for Generation { + fn drop(&mut self) { + if let Err(err) = fs::remove_dir_all(&self.root) { + if err.kind() != io::ErrorKind::NotFound { + tracing::warn!(target: LOG_TARGET, path = %self.root.display(), err = %err, "failed to remove pane graphics directory"); + } + } + } +} + +#[cfg(unix)] +pub fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> { + validate_source_under(path, expected_len, runtime_base()) +} + +#[cfg(unix)] +fn validate_source_under(path: &Path, expected_len: usize, base: PathBuf) -> io::Result<()> { + let source = path.parent().ok_or_else(invalid_path)?; + let generation = source.parent().ok_or_else(invalid_path)?; + if !path.is_absolute() + || path.file_name().is_none() + || source.file_name().and_then(|name| name.to_str()) != Some("source") + || generation.parent() != Some(base.as_path()) + || !generation + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("server-")) + { + return Err(invalid_path()); + } + for directory in [base, generation.to_owned(), source.to_owned()] { + validate_directory(&directory)?; + } + let file = open_no_follow(path)?; + let metadata = file.metadata()?; + validate_metadata(&metadata, expected_len)?; + validate_path_identity(path, &metadata) +} + +/// Unlike the API validator, accepts only the dedicated native hierarchy. +#[cfg(unix)] +pub fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> { + if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) { + return Err(invalid_path()); + } + validate_source_under(path, expected_len, native_base()) +} + +fn native_base() -> PathBuf { + #[cfg(unix)] + { + PathBuf::from(format!("/var/tmp/herdr-native-sources-{}", effective_uid())) + } + #[cfg(not(unix))] + { + PathBuf::from("native-sources-unavailable") + } +} + +fn create_generation(base: &Path) -> io::Result<Generation> { + #[cfg(not(unix))] + { + let _ = base; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file-backed pane graphics require Unix", + )) + } + #[cfg(unix)] + { + if base == native_base() { + match fs::DirBuilder::new().mode(DIRECTORY_MODE).create(base) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err), + } + } else { + fs::create_dir_all(base)?; + fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?; + } + validate_directory(base)?; + remove_stale_generations(base); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let root = base.join(format!("server-{}-{nonce}", std::process::id())); + fs::create_dir(&root)?; + fs::set_permissions(&root, fs::Permissions::from_mode(DIRECTORY_MODE))?; + let source = root.join("source"); + fs::create_dir(&source)?; + fs::set_permissions(&source, fs::Permissions::from_mode(DIRECTORY_MODE))?; + validate_directory(&root)?; + validate_directory(&source)?; + Ok(Generation { root, source }) + } +} + +fn runtime_base() -> PathBuf { + #[cfg(unix)] + { + let root = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| PathBuf::from("/var/tmp")); + root.join(format!("herdr-pane-graphics-{}", effective_uid())) + } + #[cfg(not(unix))] + { + PathBuf::from("pane-graphics-unavailable") + } +} + +#[cfg(unix)] +fn read_exact_at(file: &File, data: &mut [u8]) -> io::Result<()> { + file.read_exact_at(data, 0) +} + +#[cfg(not(unix))] +fn read_exact_at(_file: &File, _data: &mut [u8]) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) +} + +#[cfg(unix)] +fn has_byte_at(file: &File, offset: u64) -> io::Result<bool> { + Ok(file.read_at(&mut [0], offset)? != 0) +} + +#[cfg(not(unix))] +fn has_byte_at(_file: &File, _offset: u64) -> io::Result<bool> { + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) +} + +#[cfg(unix)] +fn remove_stale_generations(base: &Path) { + let Ok(entries) = fs::read_dir(base) else { + return; + }; + for entry in entries.flatten() { + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_prefix("server-")) + .and_then(|name| name.split('-').next()) + .and_then(|pid| pid.parse::<i32>().ok()) + .filter(|pid| *pid > 0) + else { + continue; + }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + // SAFETY: kill with signal 0 probes process existence without sending a signal. + let alive = unsafe { libc::kill(pid, 0) } == 0; + if alive || io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { + continue; + } + if let Err(err) = fs::remove_dir_all(entry.path()) { + tracing::warn!(target: LOG_TARGET, path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory"); + } + } +} + +fn validate_child(path: &Path, directory: &Path) -> io::Result<()> { + if !path.is_absolute() || path.parent() != Some(directory) || path.file_name().is_none() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "frame path must be a direct child of the advertised source directory", + )); + } + Ok(()) +} + +fn create_export(path: &Path) -> io::Result<File> { + #[cfg(unix)] + { + OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + } + #[cfg(not(unix))] + { + let _ = path; + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +fn open_no_follow(path: &Path) -> io::Result<File> { + #[cfg(unix)] + { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + } + #[cfg(not(unix))] + { + let _ = path; + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +fn validate_metadata(metadata: &fs::Metadata, expected_len: usize) -> io::Result<()> { + #[cfg(unix)] + { + if !metadata.file_type().is_file() + || metadata.uid() != effective_uid() + || metadata.mode() & 0o777 != FILE_MODE + || metadata.nlink() != 1 + || metadata.len() != expected_len as u64 + { + return Err(invalid( + "frame must be same-uid regular 0600 single-link exact-length file", + )); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = (metadata, expected_len); + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +#[cfg(unix)] +fn validate_directory(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() + || metadata.uid() != effective_uid() + || metadata.mode() & 0o777 != DIRECTORY_MODE + { + return Err(invalid("pane graphics directory is not private")); + } + Ok(()) +} + +fn validate_path_identity(path: &Path, expected: &fs::Metadata) -> io::Result<()> { + #[cfg(unix)] + { + let actual = fs::symlink_metadata(path)?; + if actual.dev() != expected.dev() || actual.ino() != expected.ino() { + return Err(invalid("frame path changed while leased")); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = (path, expected); + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +#[cfg(unix)] +fn effective_uid() -> u32 { + // SAFETY: geteuid takes no arguments and has no preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(unix)] +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(unix)] +fn invalid_path() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "invalid pane graphics source path", + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::io::Write; + use std::os::unix::fs::{symlink, PermissionsExt}; + use std::sync::atomic::AtomicU64; + + static NEXT: AtomicU64 = AtomicU64::new(1); + + fn store() -> (FileStore, PathBuf) { + let base = PathBuf::from(format!( + "/var/tmp/herdr-graphics-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + (FileStore::new(base.clone()), base) + } + + fn frame(store: &FileStore, name: &str, data: &[u8]) -> PathBuf { + let path = store.source_directory().unwrap().join(name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&path) + .unwrap(); + file.write_all(data).unwrap(); + path + } + + #[test] + fn native_budget_is_per_store_and_released_only_at_final_owner_drop() { + let store = FileStore::native_sources(); + let other = FileStore::native_sources(); + let budget = store.native_budget.as_ref().unwrap(); + let mut holds = Vec::new(); + for _ in 0..64 { + holds.push(Reservation::acquire(budget, 4).unwrap()); + } + assert!(Reservation::acquire(budget, 4).is_err()); + assert!(Reservation::acquire(other.native_budget.as_ref().unwrap(), 4).is_ok()); + holds.clear(); + for _ in 0..4 { + holds.push(Reservation::acquire(budget, MAX_EXPORT_BYTES).unwrap()); + } + assert!(Reservation::acquire(budget, 4).is_err()); + holds.clear(); + // Exercise the exact OwnedExport lifetime independently of FS reflink support. + let export = store.export(&[1, 2, 3, 4]).unwrap(); + let first = Arc::new(export); + let last = Arc::clone(&first); + let path = first.path().to_owned(); + drop(first); + assert_eq!(budget.lock().unwrap().objects, 1); + assert!(path.exists()); + drop(last); + assert_eq!(budget.lock().unwrap().objects, 0); + assert!(!path.exists()); + } + + #[test] + fn native_validation_is_separate_and_private() { + let store = FileStore::native_sources(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + validate_native_source(export.path(), 4).unwrap(); + assert!(validate_direct_source(export.path(), 4).is_err()); + assert!(validate_native_source(export.path(), 8).is_err()); + let decoded = FileStore::default().export(&[1, 2, 3, 4]).unwrap(); + assert!(validate_native_source(decoded.path(), 4).is_err()); + let link = export.path().with_extension("link"); + symlink(export.path(), &link).unwrap(); + assert!(validate_native_source(&link, 4).is_err()); + fs::remove_file(&link).unwrap(); + fs::hard_link(export.path(), &link).unwrap(); + assert!(validate_native_source(export.path(), 4).is_err()); + fs::remove_file(link).unwrap(); + fs::set_permissions(export.path(), fs::Permissions::from_mode(0o644)).unwrap(); + assert!(validate_native_source(export.path(), 4).is_err()); + } + + #[test] + fn snapshot_validation_releases_budget_and_cleans_staging() { + let store = FileStore::native_sources(); + for len in [0, 3, MAX_EXPORT_BYTES + 4] { + assert!(store.snapshot(-1, len).is_err()); + } + assert!(!store.is_initialized()); + assert!(store.snapshot(-1, 4).is_err()); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 0 + ); + } + + #[test] + fn snapshot_is_independent_and_preserves_borrowed_fd_offset_when_supported() { + use std::io::{Seek, SeekFrom}; + use std::os::fd::AsRawFd; + let store = FileStore::native_sources(); + let path = frame(&store, "producer", &vec![7; 4096]); + let mut producer = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + producer.seek(SeekFrom::Start(13)).unwrap(); + assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err()); + let result = store.snapshot(producer.as_raw_fd() as i64, 4096); + assert_eq!(producer.stream_position().unwrap(), 13); + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(err) => { + // Unsupported FS/range/cross-device is a normal parent-reader fallback. + assert!( + err.kind() == io::ErrorKind::Unsupported + || matches!( + err.raw_os_error(), + Some( + libc::EOPNOTSUPP + | libc::ENOTTY + | libc::EXDEV + | libc::EINVAL + | libc::ENOSYS + ) + ), + "{err}" + ); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 1 + ); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + return; + } + }; + producer.write_at(&vec![9; 4096], 0).unwrap(); + assert_eq!(snapshot.copy_rgba().unwrap(), vec![7; 4096]); + producer.set_len(0).unwrap(); + fs::remove_file(path).unwrap(); + let mut bytes = vec![0; 4096]; + snapshot.read_into(&mut bytes).unwrap(); + assert_eq!(bytes, vec![7; 4096]); + assert!(snapshot.read_into(&mut [0; 4]).is_err()); + assert_eq!(producer.stream_position().unwrap(), 13); + } + + #[test] + fn tmpfs_source_clone_failure_leaves_no_artifact() { + use std::os::fd::AsRawFd; + let path = PathBuf::from(format!( + "/dev/shm/herdr-snapshot-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let Ok(mut producer) = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&path) + else { + return; + }; + producer.write_all(&[1, 2, 3, 4]).unwrap(); + fs::remove_file(path).unwrap(); + let store = FileStore::native_sources(); + assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err()); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 0 + ); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + } + + #[test] + fn decoded_exports_snapshot_bytes_and_have_distinct_identities() { + let (store, base) = store(); + let mut data = vec![1, 2, 3, 4]; + let first = store.export(&data).unwrap(); + data.fill(9); + let second = store.export(&data).unwrap(); + assert_eq!(first.len(), 4); + assert_eq!(fs::read(first.path()).unwrap(), [1, 2, 3, 4]); + assert_eq!(first.lease.copy_rgba().unwrap(), [1, 2, 3, 4]); + assert_eq!(second.lease.copy_rgba().unwrap(), data); + assert_ne!(first.path(), second.path()); + assert_ne!(first.fingerprint(), second.fingerprint()); + assert_eq!( + first.path().parent().unwrap(), + store.source_directory().unwrap() + ); + validate_metadata(&fs::metadata(first.path()).unwrap(), 4).unwrap(); + let path = first.path().to_owned(); + drop(first); + assert!(!path.exists()); + assert!(second.path().exists()); + drop(second); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_guard_retains_generation_until_consumed() { + let (store, base) = store(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + let path = export.path().to_owned(); + let source = store.source_directory().unwrap(); + drop(store); + assert_eq!(fs::read(&path).unwrap(), [1, 2, 3, 4]); + assert!(source.exists()); + drop(export); + assert!(!path.exists()); + assert!(!source.exists()); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_rejects_oversize_and_never_overwrites_collisions() { + let (store, base) = store(); + assert!(store.export(&vec![0; MAX_EXPORT_BYTES + 1]).is_err()); + assert!(!store.is_initialized()); + let collision = frame(&store, "decoded-1.rgba", &[7, 8]); + assert!(store.export(&[1, 2, 3, 4]).is_err()); + assert_eq!(fs::read(&collision).unwrap(), [7, 8]); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 1 + ); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + assert_ne!(export.path(), collision); + drop(export); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn unpublished_export_guard_removes_partial_file_on_error() { + let (store, base) = store(); + let generation = store.generation().unwrap(); + let path = generation.source.join("partial.rgba"); + let result = (|| -> io::Result<()> { + let mut writer = create_export(&path)?; + let _cleanup = ExportFile { + path: path.clone(), + _generation: generation, + }; + writer.write_all(&[1, 2])?; + drop(writer); + store.lease(&path, 4)?; + Ok(()) + })(); + assert!(result.is_err()); + assert!(!path.exists()); + assert!(store.source_directory().unwrap().exists()); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_passes_direct_source_validation() { + let store = FileStore::default(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + validate_direct_source(export.path(), export.len()).unwrap(); + } + + #[test] + fn source_directory_is_lazy_private_and_removed_with_store() { + let (store, base) = store(); + assert!(!store.is_initialized()); + let source = store.source_directory().unwrap(); + assert!(store.is_initialized()); + validate_directory(&source).unwrap(); + drop(store); + assert!(!source.exists()); + let _ = fs::remove_dir(base); + } + + #[test] + fn lease_holds_exact_file_and_copies_from_validated_fd() { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let lease = store.lease(&path, 4).unwrap(); + assert_eq!(lease.path(), path); + assert_eq!(lease.len(), 4); + assert!(lease.fingerprint() > 0); + assert_eq!(lease.copy_rgba().unwrap(), [1, 2, 3, 4]); + drop(lease); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn validation_rejects_links_modes_lengths_and_outside_paths() { + let cases = ["mode", "length", "hard-link", "symlink", "outside"]; + for case in cases { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let result = match case { + "mode" => { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + store.lease(&path, 4) + } + "length" => store.lease(&path, 8), + "hard-link" => { + fs::hard_link(&path, store.source_directory().unwrap().join("other")).unwrap(); + store.lease(&path, 4) + } + "symlink" => { + let link = store.source_directory().unwrap().join("link"); + symlink(&path, &link).unwrap(); + store.lease(&link, 4) + } + "outside" => store.lease(&base.join("outside"), 4), + _ => unreachable!(), + }; + assert!(result.is_err(), "{case}"); + drop(store); + let _ = fs::remove_dir_all(base); + } + } + + #[test] + fn direct_source_validation_accepts_only_the_runtime_generation() { + let runtime_store = FileStore::default(); + let runtime_path = frame(&runtime_store, "direct", &[1, 2, 3, 4]); + validate_direct_source(&runtime_path, 4).unwrap(); + assert!(validate_direct_source(&runtime_path, 8).is_err()); + assert!(validate_direct_source(Path::new("relative"), 4).is_err()); + + let generation = runtime_path.parent().unwrap().parent().unwrap(); + let wrong_source = generation.join("frames"); + fs::create_dir(&wrong_source).unwrap(); + fs::set_permissions(&wrong_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let wrong_source_path = wrong_source.join("frame"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&wrong_source_path) + .unwrap(); + file.write_all(&[1, 2, 3, 4]).unwrap(); + assert!(validate_direct_source(&wrong_source_path, 4).is_err()); + + let (other_store, other_base) = store(); + let other_path = frame(&other_store, "other", &[1, 2, 3, 4]); + assert!(validate_direct_source(&other_path, 4).is_err()); + drop(other_store); + let _ = fs::remove_dir_all(other_base); + + let invalid_generation = runtime_base().join(format!( + "invalid-generation-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let invalid_source = invalid_generation.join("source"); + fs::create_dir_all(&invalid_source).unwrap(); + fs::set_permissions( + &invalid_generation, + fs::Permissions::from_mode(DIRECTORY_MODE), + ) + .unwrap(); + fs::set_permissions(&invalid_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let invalid_path = invalid_source.join("frame"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&invalid_path) + .unwrap(); + file.write_all(&[1, 2, 3, 4]).unwrap(); + assert!(validate_direct_source(&invalid_path, 4).is_err()); + let _ = fs::remove_dir_all(invalid_generation); + } + + #[test] + fn generation_creation_removes_dead_server_directories() { + let (store, base) = store(); + fs::create_dir_all(&base).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let stale = base.join("server-2147483647-stale"); + fs::create_dir(&stale).unwrap(); + fs::set_permissions(&stale, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + + store.source_directory().unwrap(); + + assert!(!stale.exists()); + drop(store); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn replacement_after_lease_is_detected_before_fallback_copy() { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let lease = store.lease(&path, 4).unwrap(); + fs::remove_file(&path).unwrap(); + let _replacement = frame(&store, "frame", &[5, 6, 7, 8]); + assert!(lease.copy_rgba().is_err()); + drop(lease); + drop(store); + let _ = fs::remove_dir_all(base); + } +} |
