diff --git a/Cargo.lock b/Cargo.lock index d04ff4b..beb8dfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "anyhow" version = "1.0.104" @@ -46,6 +52,36 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cc" version = "1.8.0" @@ -105,6 +141,16 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -129,6 +175,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -224,6 +281,34 @@ dependencies = [ "typenum", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itoa" version = "1.0.18" @@ -268,6 +353,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.3" @@ -385,6 +482,29 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -523,6 +643,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "cap-std", "serde", "serde_json", "thiserror", @@ -680,6 +801,7 @@ version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ + "bytes", "pin-project-lite", "tokio-macros", ] @@ -829,6 +951,178 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index bb652c1..2e8d7cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ async-trait = "0.1" thiserror = "2" anyhow = "1" uuid = { version = "1", features = ["v4"] } -tokio = { version = "1", features = ["fs"] } +tokio = { version = "1", features = ["fs", "io-util", "rt"] } +cap-std = "4" tracing = "0.1" tinytools = { git = "https://github.com/tinyhumansai/tinytools.git", rev = "3a01641", version = "0.6" } # Artifact records and file bytes use the host-bound stable storage ports. diff --git a/crates/tiny-artifacts/Cargo.toml b/crates/tiny-artifacts/Cargo.toml index 7dcc1e3..c493784 100644 --- a/crates/tiny-artifacts/Cargo.toml +++ b/crates/tiny-artifacts/Cargo.toml @@ -19,6 +19,7 @@ thiserror.workspace = true anyhow.workspace = true uuid.workspace = true tokio.workspace = true +cap-std.workspace = true tracing.workspace = true tinytools.workspace = true tinystoragedrivers-types.workspace = true diff --git a/crates/tiny-artifacts/src/file_store.rs b/crates/tiny-artifacts/src/file_store.rs index 21ccd7f..4eae63b 100644 --- a/crates/tiny-artifacts/src/file_store.rs +++ b/crates/tiny-artifacts/src/file_store.rs @@ -1,6 +1,6 @@ //! Durable artifact bytes over a host-bound blob handle. -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use anyhow::{bail, Context, Result}; use async_trait::async_trait; @@ -103,6 +103,337 @@ impl FileStore for BlobFileStore { } } +/// Files kept below `root` using path-safe storage keys. +#[derive(Clone, Debug)] +pub struct FsFileStore { + root: PathBuf, +} + +impl FsFileStore { + /// Store files under `root`. + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// The directory containing stored file objects. + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// Replace the bytes of an existing regular file below the store root. + /// + /// The file is opened and truncated in place, preserving its inode and + /// filesystem metadata such as permissions. The write is not atomic: an + /// I/O error can leave the target partially written. The root, parent + /// directories, and target must already exist. A final symlink is allowed + /// only when it resolves to a regular file inside the root. + /// + /// Unlike [`FileStore::put`], this operation does not create or atomically + /// replace a target. It is intended for callers that must respect the + /// operating system's write permission for an already existing file. + /// + /// # Errors + /// + /// Returns an error when the key is invalid, the root or target is + /// missing, the target is not a regular file, it escapes the root, or the + /// target cannot be opened or written. + pub async fn overwrite_existing( + &self, + key: &str, + bytes: &[u8], + media_type: Option<&str>, + ) -> Result { + self.overwrite_existing_with_hook(key, bytes, media_type, || {}) + .await + } + + async fn overwrite_existing_with_hook( + &self, + key: &str, + bytes: &[u8], + media_type: Option<&str>, + before_open: F, + ) -> Result + where + F: FnOnce() + Send + 'static, + { + validate_key(key)?; + let root_path = self.root.clone(); + let key = key.to_owned(); + let bytes = bytes.to_vec(); + let media_type = media_type.map(str::to_owned); + let file = tokio::task::spawn_blocking(move || -> Result { + use cap_std::ambient_authority; + use cap_std::fs::{Dir, OpenOptions}; + use std::io::Write; + + let root_path = std::fs::canonicalize(&root_path).with_context(|| { + format!("cannot resolve file store root {}", root_path.display()) + })?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .with_context(|| format!("cannot open file store root {}", root_path.display()))?; + let relative = canonical_relative_target(&root_path, &key) + .with_context(|| format!("cannot resolve existing file {key}"))?; + let metadata = root + .metadata(&relative) + .with_context(|| format!("cannot resolve existing file {key}"))?; + if !metadata.is_file() { + bail!("file key does not name a regular file: {key:?}"); + } + before_open(); + let mut options = OpenOptions::new(); + options.write(true).truncate(true); + let mut file = root + .open_with(&relative, &options) + .with_context(|| format!("cannot open {key} for writing"))?; + file.write_all(&bytes) + .with_context(|| format!("cannot write {key}"))?; + file.flush() + .with_context(|| format!("cannot flush {key}"))?; + + Ok(FileMetadata { + size_bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), + media_type: media_type + .or_else(|| crate::media_type_for(Path::new(&key)).map(str::to_owned)), + }) + }) + .await + .context("file-store overwrite task failed")??; + Ok(file) + } + + async fn read_with_hook(&self, key: &str, before_open: F) -> Result> + where + F: FnOnce() + Send + 'static, + { + validate_key(key)?; + let root_path = self.root.clone(); + let key = key.to_owned(); + tokio::task::spawn_blocking(move || -> Result> { + use cap_std::ambient_authority; + use cap_std::fs::Dir; + use std::io::Read; + + let root_path = std::fs::canonicalize(&root_path).with_context(|| { + format!("cannot resolve file store root {}", root_path.display()) + })?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .with_context(|| format!("cannot open file store root {}", root_path.display()))?; + let relative = canonical_relative_target(&root_path, &key) + .with_context(|| format!("cannot resolve file {key}"))?; + before_open(); + let mut file = root + .open(&relative) + .with_context(|| format!("cannot read {key}"))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("cannot read {key}"))?; + Ok(bytes) + }) + .await + .context("file-store read task failed")? + } +} + +#[async_trait] +impl FileStore for FsFileStore { + async fn put(&self, key: &str, bytes: &[u8], media_type: Option<&str>) -> Result { + validate_key(key)?; + tokio::fs::create_dir_all(&self.root) + .await + .with_context(|| format!("cannot create file store root {}", self.root.display()))?; + let root_path = self.root.clone(); + let key = key.to_owned(); + let bytes = bytes.to_vec(); + let media_type = media_type.map(str::to_owned); + tokio::task::spawn_blocking(move || -> Result { + use cap_std::ambient_authority; + use cap_std::fs::Dir; + use std::io::Write; + + let root_path = std::fs::canonicalize(&root_path).with_context(|| { + format!("cannot resolve file store root {}", root_path.display()) + })?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .with_context(|| format!("cannot open file store root {}", root_path.display()))?; + let relative = Path::new(&key); + let parent = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let directory = create_parent_directory(&root, &root_path, parent) + .with_context(|| format!("cannot open parent directory for {key}"))?; + let file_name = relative.file_name().context("file key has no name")?; + let staging = format!(".{}.tmp", uuid::Uuid::new_v4()); + write_staging_file(&directory, &staging, |file| file.write_all(&bytes)) + .with_context(|| format!("cannot write staging file for {key}"))?; + if let Err(error) = directory.rename(&staging, &directory, file_name) { + let _ = directory.remove_file(&staging); + return Err(error).with_context(|| format!("cannot save {key}")); + } + Ok(FileMetadata { + size_bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), + media_type: media_type + .or_else(|| crate::media_type_for(Path::new(&key)).map(str::to_owned)), + }) + }) + .await + .context("file-store write task failed")? + } + + async fn read(&self, key: &str) -> Result> { + self.read_with_hook(key, || {}).await + } + + async fn metadata(&self, key: &str) -> Result> { + validate_key(key)?; + let root_path = self.root.clone(); + let key = key.to_owned(); + tokio::task::spawn_blocking(move || -> Result> { + use cap_std::ambient_authority; + use cap_std::fs::Dir; + + let root_path = std::fs::canonicalize(&root_path).with_context(|| { + format!("cannot resolve file store root {}", root_path.display()) + })?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .with_context(|| format!("cannot open file store root {}", root_path.display()))?; + let relative = match canonical_relative_target(&root_path, &key) { + Ok(relative) => relative, + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; + match root.metadata(&relative) { + Ok(metadata) if metadata.is_file() => Ok(Some(FileMetadata { + size_bytes: metadata.len(), + media_type: crate::media_type_for(Path::new(&key)).map(str::to_owned), + })), + Ok(_) => Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("cannot inspect {key}")), + } + }) + .await + .context("file-store metadata task failed")? + } + + async fn delete_prefix(&self, prefix: &str) -> Result<()> { + validate_prefix(prefix)?; + let root_path = self.root.clone(); + let relative = prefix.trim_end_matches('/').to_owned(); + tokio::task::spawn_blocking(move || -> Result<()> { + use cap_std::ambient_authority; + use cap_std::fs::Dir; + + let root_path = match std::fs::canonicalize(&root_path) { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .with_context(|| format!("cannot open file store root {}", root_path.display()))?; + if let Err(error) = canonical_relative_target(&root_path, &relative) { + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) + { + return Ok(()); + } + return Err(error); + } + match root.remove_dir_all(&relative) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("cannot remove {relative}")), + } + }) + .await + .context("file-store deletion task failed")? + } +} + +fn canonical_relative_target(root_path: &Path, key: &str) -> Result { + let target = std::fs::canonicalize(root_path.join(key)) + .with_context(|| format!("cannot resolve file {key}"))?; + if !target.starts_with(root_path) { + bail!("file key escapes the configured store root: {key:?}"); + } + target + .strip_prefix(root_path) + .map(Path::to_path_buf) + .context("resolved file is outside the configured store root") +} + +fn create_parent_directory( + root: &cap_std::fs::Dir, + root_path: &Path, + parent: &Path, +) -> Result { + let mut directory = root.open_dir(".")?; + let mut resolved = PathBuf::new(); + for component in parent.components() { + let Component::Normal(segment) = component else { + continue; + }; + match std::fs::canonicalize(root_path.join(&resolved).join(segment)) { + Ok(canonical) => { + if !canonical.starts_with(root_path) { + bail!( + "file key escapes the configured store root: {}", + parent.display() + ); + } + resolved = canonical + .strip_prefix(root_path) + .context("resolved parent is outside the configured store root")? + .to_path_buf(); + directory = root.open_dir(&resolved)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match directory.create_dir(segment) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + directory = directory.open_dir(segment)?; + resolved.push(segment); + } + Err(error) => return Err(error.into()), + } + } + Ok(directory) +} + +fn write_staging_file(directory: &cap_std::fs::Dir, staging: &str, write_body: F) -> Result<()> +where + F: FnOnce(&mut cap_std::fs::File) -> std::io::Result<()>, +{ + use cap_std::fs::OpenOptions; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let mut file = directory.open_with(staging, &options)?; + if let Err(error) = write_body(&mut file) { + drop(file); + let _ = directory.remove_file(staging); + return Err(error).with_context(|| format!("cannot write staging file {staging}")); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = directory.remove_file(staging); + return Err(error).with_context(|| format!("cannot sync staging file {staging}")); + } + Ok(()) +} + fn validate_key(key: &str) -> Result<()> { if key.is_empty() || key.starts_with('/') || key.contains('\\') || key.contains('\0') { bail!("invalid file key {key:?}"); diff --git a/crates/tiny-artifacts/src/file_store_tests.rs b/crates/tiny-artifacts/src/file_store_tests.rs index 2d5b0b9..785321f 100644 --- a/crates/tiny-artifacts/src/file_store_tests.rs +++ b/crates/tiny-artifacts/src/file_store_tests.rs @@ -1,5 +1,28 @@ use super::*; +fn temp_dir() -> PathBuf { + let path = std::env::temp_dir().join(format!("tiny-artifacts-files-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + path +} + +#[test] +fn a_failed_staging_write_removes_its_partial_file() { + use cap_std::ambient_authority; + use cap_std::fs::Dir; + use std::io::Write; + + let root = temp_dir(); + let directory = Dir::open_ambient_dir(&root, ambient_authority()).unwrap(); + let result = write_staging_file(&directory, ".partial.tmp", |file| { + file.write_all(b"partial")?; + Err(std::io::Error::other("injected write failure")) + }); + + assert!(result.is_err()); + assert!(!root.join(".partial.tmp").exists()); +} + use tinystoragedrivers_api::Storage; use tinystoragedrivers_testkit::memory_storage; use tinystoragedrivers_types::Scope; @@ -13,6 +36,7 @@ async fn blob_adapter_reads_metadata_and_deletes_only_the_requested_prefix() { let storage = storage(); let first = BlobFileStore::new(&storage); let second = BlobFileStore::new(&storage); + assert!(format!("{first:?}").contains("BlobFileStore")); let metadata = first .put("artifacts/files/a/v1.png", b"png bytes", None) .await @@ -43,6 +67,278 @@ async fn blob_adapter_reads_metadata_and_deletes_only_the_requested_prefix() { ); } +#[tokio::test] +async fn overwrite_existing_preserves_the_file_and_replaces_its_bytes() { + let root = temp_dir(); + let path = root.join("existing.txt"); + std::fs::write(&path, b"before").unwrap(); + let store = FsFileStore::new(&root); + + #[cfg(unix)] + let before = { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let metadata = std::fs::metadata(&path).unwrap(); + (metadata.dev(), metadata.ino(), metadata.mode()) + }; + + let metadata = store + .overwrite_existing("existing.txt", b"after", None) + .await + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"after"); + assert_eq!(metadata.size_bytes, 5); + assert_eq!(metadata.media_type.as_deref(), Some("text/plain")); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let after = std::fs::metadata(&path).unwrap(); + assert_eq!((after.dev(), after.ino(), after.mode()), before); + } +} + +#[tokio::test] +async fn overwrite_existing_refuses_missing_targets_without_creating_paths() { + let root = temp_dir(); + let store = FsFileStore::new(&root); + + assert!(store + .overwrite_existing("missing.txt", b"bytes", None) + .await + .is_err()); + assert!(store + .overwrite_existing("missing/child.txt", b"bytes", None) + .await + .is_err()); + assert!(!root.join("missing").exists()); + assert!(!root.join("missing.txt").exists()); + + let missing_root = root.join("missing-root"); + assert!(FsFileStore::new(&missing_root) + .overwrite_existing("file.txt", b"bytes", None) + .await + .is_err()); + assert!(!missing_root.exists()); +} + +#[tokio::test] +async fn overwrite_existing_rejects_non_files_and_symlinks_outside_the_root() { + let root = temp_dir(); + let store = FsFileStore::new(&root); + std::fs::create_dir(root.join("directory")).unwrap(); + assert!(store + .overwrite_existing("directory", b"bytes", None) + .await + .is_err()); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let outside = temp_dir().join("outside.txt"); + std::fs::write(&outside, b"keep").unwrap(); + symlink(&outside, root.join("outside-link.txt")).unwrap(); + + assert!(store + .overwrite_existing("outside-link.txt", b"replace", None) + .await + .is_err()); + assert_eq!(std::fs::read(outside).unwrap(), b"keep"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn overwrite_existing_follows_symlinks_that_resolve_inside_the_root() { + use std::os::unix::fs::symlink; + use std::os::unix::fs::MetadataExt; + let root = temp_dir(); + let path = root.join("target.txt"); + std::fs::write(&path, b"before").unwrap(); + symlink(&path, root.join("link.txt")).unwrap(); + let inode = std::fs::metadata(&path).unwrap().ino(); + + FsFileStore::new(&root) + .overwrite_existing("link.txt", b"after", None) + .await + .unwrap(); + + let metadata = std::fs::metadata(&path).unwrap(); + assert_eq!(metadata.ino(), inode); + assert_eq!(std::fs::read(path).unwrap(), b"after"); +} + +#[cfg(unix)] +#[tokio::test] +async fn overwrite_existing_does_not_follow_a_parent_replaced_with_an_external_symlink() { + use std::os::unix::fs::symlink; + + let root = temp_dir(); + let parent = root.join("parent"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(parent.join("target.txt"), b"inside").unwrap(); + let outside = temp_dir(); + std::fs::write(outside.join("target.txt"), b"outside").unwrap(); + let store = FsFileStore::new(&root); + let moved_parent = root.join("parent-moved"); + let hook_parent = parent.clone(); + let hook_outside = outside.clone(); + + let result = store + .overwrite_existing_with_hook("parent/target.txt", b"changed", None, move || { + std::fs::rename(&hook_parent, moved_parent).unwrap(); + symlink(hook_outside, hook_parent).unwrap(); + }) + .await; + + assert!(result.is_err(), "the parent swap must invalidate the open"); + assert_eq!( + std::fs::read(outside.join("target.txt")).unwrap(), + b"outside" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn read_does_not_follow_a_parent_replaced_with_an_external_symlink() { + use std::os::unix::fs::symlink; + + let root = temp_dir(); + let parent = root.join("parent"); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(parent.join("target.txt"), b"inside").unwrap(); + let outside = temp_dir(); + std::fs::write(outside.join("target.txt"), b"outside").unwrap(); + let store = FsFileStore::new(&root); + let moved_parent = root.join("parent-moved"); + let hook_parent = parent.clone(); + let hook_outside = outside.clone(); + + let result = store + .read_with_hook("parent/target.txt", move || { + std::fs::rename(&hook_parent, moved_parent).unwrap(); + symlink(hook_outside, hook_parent).unwrap(); + }) + .await; + + assert!(result.is_err(), "the parent swap must invalidate the read"); +} + +#[cfg(unix)] +#[tokio::test] +async fn overwrite_existing_obeys_filesystem_write_permissions() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_dir(); + let path = root.join("read-only.txt"); + std::fs::write(&path, b"preserve").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap(); + + let write_is_allowed = std::fs::OpenOptions::new().write(true).open(&path).is_ok(); + let result = FsFileStore::new(&root) + .overwrite_existing("read-only.txt", b"replace", None) + .await; + + if write_is_allowed { + result.unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"replace"); + } else { + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"preserve"); + } +} + +#[tokio::test] +async fn filesystem_store_round_trips_and_deletes_only_the_requested_prefix() { + let root = temp_dir().join("not-created-yet"); + let store = FsFileStore::new(&root); + assert_eq!(store.root(), root); + + let metadata = store + .put( + "artifacts/files/a/v1.txt", + b"first version", + Some("text/custom"), + ) + .await + .unwrap(); + assert_eq!(metadata.size_bytes, 13); + assert_eq!(metadata.media_type.as_deref(), Some("text/custom")); + assert_eq!( + store.read("artifacts/files/a/v1.txt").await.unwrap(), + b"first version" + ); + assert_eq!( + store.metadata("artifacts/files/a/v1.txt").await.unwrap(), + Some(FileMetadata { + size_bytes: 13, + media_type: Some("text/plain".to_owned()), + }) + ); + + store + .put("artifacts/files/ab/v1.txt", b"sibling", None) + .await + .unwrap(); + store.delete_prefix("artifacts/files/a/").await.unwrap(); + + assert!(store.read("artifacts/files/a/v1.txt").await.is_err()); + assert_eq!( + store.metadata("artifacts/files/a/v1.txt").await.unwrap(), + None + ); + assert_eq!( + store.read("artifacts/files/ab/v1.txt").await.unwrap(), + b"sibling" + ); + store + .delete_prefix("artifacts/files/missing/") + .await + .unwrap(); +} + +#[tokio::test] +async fn filesystem_store_rejects_invalid_paths_and_escaping_symlinks() { + let root = temp_dir(); + let store = FsFileStore::new(&root); + for key in [ + "", + "../outside", + "nested//file", + "nested/./file", + "/absolute", + "bad\\key", + ] { + assert!(store.put(key, b"bad", None).await.is_err(), "{key}"); + assert!(store.read(key).await.is_err(), "{key}"); + assert!(store.metadata(key).await.is_err(), "{key}"); + } + for prefix in ["", "nested", "nested//", "../"] { + assert!(store.delete_prefix(prefix).await.is_err(), "{prefix}"); + } + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let outside = temp_dir(); + std::fs::write(outside.join("kept.txt"), b"outside").unwrap(); + symlink(&outside, root.join("linked-dir")).unwrap(); + symlink(outside.join("kept.txt"), root.join("linked-file.txt")).unwrap(); + assert!(store + .put("linked-dir/kept.txt", b"replace", None) + .await + .is_err()); + assert!(store.read("linked-dir/kept.txt").await.is_err()); + assert!(store.metadata("linked-dir/kept.txt").await.is_err()); + assert!(store.delete_prefix("linked-dir/").await.is_err()); + assert!(store.read("linked-file.txt").await.is_err()); + assert!(store.metadata("linked-file.txt").await.is_err()); + assert_eq!(std::fs::read(outside.join("kept.txt")).unwrap(), b"outside"); + } +} + #[tokio::test] async fn blob_adapter_rejects_invalid_keys_and_prefixes_without_touching_siblings() { let storage = storage(); diff --git a/crates/tiny-artifacts/src/lib.rs b/crates/tiny-artifacts/src/lib.rs index 9f84dcf..b519ebb 100644 --- a/crates/tiny-artifacts/src/lib.rs +++ b/crates/tiny-artifacts/src/lib.rs @@ -57,7 +57,7 @@ pub use diff::{ArtifactDiff, DiffLine, DiffOp, MAX_DIFF_LINES}; pub use document_store::DocumentArtifactStore; pub use events::{ArtifactEvent, ArtifactEventKind, ArtifactObserver}; pub use file_ref::{media_type_for, FileRef, FileRefError, FileRoots, PathGuard, ResolvedFile}; -pub use file_store::{BlobFileStore, FileMetadata, FileStore}; +pub use file_store::{BlobFileStore, FileMetadata, FileStore, FsFileStore}; pub use publish::{ArtifactPublisher, PublishOptions}; pub use service::{Artifacts, ArtifactsError, Author, Content, Published, Saved}; pub use storage::collections;