diff --git a/.github/scripts/check-storage-engines.sh b/.github/scripts/check-storage-engines.sh new file mode 100755 index 0000000..dedce0f --- /dev/null +++ b/.github/scripts/check-storage-engines.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly forbidden='^(rusqlite|libsqlite3-sys|mongodb|bson|aws-sdk-s3|lapin|amq-protocol|sqlx|redb|sled|rocksdb|tinystoragedrivers-(document|kv|blob|queue|secrets|connect)|tinystoragedrivers)$' + +tree_names() { + awk 'NF { print $1 }' +} + +has_forbidden() { + local names + names="$(tree_names <<< "$1")" + [[ -n "$names" ]] || return 2 + grep -Exq "$forbidden" <<< "$names" +} + +self_test() { + local allowed forbidden_engine forbidden_tsd + allowed=$'tinystoragedrivers-types v2.0.1 (git+https://example.invalid/repo)\ntinystoragedrivers-api v2.0.1' + forbidden_engine=$'tiny-artifacts v0.1.0 (/workspace/crates/tiny-artifacts)\nrusqlite v0.40.2' + forbidden_tsd=$'tiny-artifacts v0.1.0 (/workspace/crates/tiny-artifacts)\ntinystoragedrivers-document v2.0.1 (git+https://example.invalid/repo)' + + if has_forbidden "$allowed"; then + echo "engine guard rejected allowed API/types dependencies" >&2 + return 1 + fi + if ! has_forbidden "$forbidden_engine"; then + echo "engine guard missed a versioned engine tree row" >&2 + return 1 + fi + if ! has_forbidden "$forbidden_tsd"; then + echo "engine guard missed a path-qualified TSD engine tree row" >&2 + return 1 + fi + if has_forbidden ""; then + echo "engine guard accepted empty tree output" >&2 + return 1 + else + local status=$? + if [[ $status -ne 2 ]]; then + echo "engine guard failed to reject an empty tree predictably" >&2 + return 1 + fi + fi + echo "storage engine guard self-test passed" +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test + exit +fi + +self_test +packages="$(cargo metadata --locked --no-deps --format-version 1 \ + | jq -er '[.packages[] | select(any(.targets[]; any(.kind[]; . == "lib" or . == "cdylib" or . == "staticlib"))) | .name] | unique | .[]')" +[[ -n "$packages" ]] || { echo "workspace metadata contains no library targets" >&2; exit 1; } + +while IFS= read -r package; do + [[ -n "$package" ]] || continue + names="$(cargo tree --locked --package "$package" --all-features --edges normal,build --prefix none | tree_names)" + [[ -n "$names" ]] || { echo "cargo tree returned no packages for $package" >&2; exit 1; } + if grep -Exq "$forbidden" <<< "$names"; then + printf 'storage engine dependency found in %s:\n' "$package" >&2 + grep -Ex "$forbidden" <<< "$names" >&2 + exit 1 + fi + printf 'storage dependency boundary passed: %s\n' "$package" +done <<< "$packages" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3890415..820f8dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + - name: Keep storage engines out of library dependencies + run: .github/scripts/check-storage-engines.sh + - name: Clippy run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index e239880..2aff3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,19 @@ # Changelog +## Unreleased + +- Back artifact records with the host-bound document port and apply + artifact-owned migrations on open. +- Adapt file bytes through the host-bound blob port; remove filesystem stores + so the library does not own host paths or persistence engines. +- Accept serialized legacy record bytes for host-managed imports. + ## 0.2.0 - Add the `FileStore` port with filesystem and blob-store adapters so file versions can be shared across host processes. - Store `FileRef` values as opaque keys with size and media type, and preserve deserialization of legacy path-based references for import. -- Configure file storage with `Artifacts::with_file_store` or - `Artifacts::with_version_files` (filesystem compatibility helper). +- Configure file storage with `Artifacts::with_file_store`. - This changes the public `FileRef` fields and file-storage setup API. Hosts must provide a file store for file artifacts. diff --git a/Cargo.lock b/Cargo.lock index cc01288..d04ff4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,13 +19,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -58,23 +64,28 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] @@ -85,26 +96,56 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -118,21 +159,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -156,6 +188,42 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "itoa" version = "1.0.18" @@ -189,6 +257,17 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "memchr" version = "2.8.3" @@ -281,6 +360,31 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -332,9 +436,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -353,6 +457,24 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "syn" version = "2.0.119" @@ -404,7 +526,9 @@ dependencies = [ "serde", "serde_json", "thiserror", - "tinystoragedrivers-core", + "tinystoragedrivers-api", + "tinystoragedrivers-testkit", + "tinystoragedrivers-types", "tinytools", "tokio", "tracing", @@ -412,21 +536,133 @@ dependencies = [ ] [[package]] -name = "tinystoragedrivers-core" -version = "1.0.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=be94f7568fe7650b0729be55128f48270414b9a7#be94f7568fe7650b0729be55128f48270414b9a7" +name = "tinystoragedrivers-api" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "bytes", + "futures-core", + "serde", + "serde_json", + "tinystoragedrivers-types", + "zeroize", +] + +[[package]] +name = "tinystoragedrivers-blob" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "bytes", + "futures-core", + "serde", + "serde_json", + "tinystoragedrivers-api", + "tinystoragedrivers-types", +] + +[[package]] +name = "tinystoragedrivers-connect" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "rusqlite", + "serde_json", + "tinystoragedrivers-types", + "tokio", + "zstd", +] + +[[package]] +name = "tinystoragedrivers-document" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" dependencies = [ "async-trait", "data-encoding", + "rusqlite", "serde", "serde_json", "sha2", - "thiserror", + "tinystoragedrivers-api", + "tinystoragedrivers-connect", + "tinystoragedrivers-types", "tokio", - "ulid", "zstd", ] +[[package]] +name = "tinystoragedrivers-kv" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinystoragedrivers-api", + "tinystoragedrivers-types", +] + +[[package]] +name = "tinystoragedrivers-queue" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "data-encoding", + "serde", + "serde_json", + "sha2", + "tinystoragedrivers-api", + "tinystoragedrivers-types", + "tokio", + "ulid", +] + +[[package]] +name = "tinystoragedrivers-secrets" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "tinystoragedrivers-api", + "tinystoragedrivers-types", + "zeroize", +] + +[[package]] +name = "tinystoragedrivers-testkit" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "serde_json", + "tinystoragedrivers-api", + "tinystoragedrivers-blob", + "tinystoragedrivers-document", + "tinystoragedrivers-kv", + "tinystoragedrivers-queue", + "tinystoragedrivers-secrets", + "tinystoragedrivers-types", + "tokio", + "ulid", + "zeroize", +] + +[[package]] +name = "tinystoragedrivers-types" +version = "2.1.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers.git?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "tinytools" version = "0.6.0" @@ -444,7 +680,6 @@ version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ - "bytes", "pin-project-lite", "tokio-macros", ] @@ -525,10 +760,10 @@ dependencies = [ ] [[package]] -name = "version_check" -version = "0.9.5" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "wasip2" @@ -620,6 +855,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index d49803c..bb652c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,10 @@ uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["fs"] } tracing = "0.1" tinytools = { git = "https://github.com/tinyhumansai/tinytools.git", rev = "3a01641", version = "0.6" } -# Blob-backed file versions use the same scoped port every OpenHuman module uses. -tinystoragedrivers-core = { version = "1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers.git", rev = "be94f7568fe7650b0729be55128f48270414b9a7" } +# Artifact records and file bytes use the host-bound stable storage ports. +tinystoragedrivers-types = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers.git", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } +tinystoragedrivers-api = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers.git", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } +tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers.git", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index fb5847a..6341a4b 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,16 @@ All creation and change goes through one service, `Artifacts`: ```rust use std::sync::Arc; +use tinystoragedrivers_api::Storage; use tiny_artifacts::{ - Artifacts, ArtifactKind, Author, Content, FileRoots, FsArtifactStore, FsFileStore, + Artifacts, ArtifactKind, Author, BlobFileStore, Content, DocumentArtifactStore, FileRoots, }; -# async fn demo() -> Result<(), tiny_artifacts::ArtifactsError> { -let artifacts = Artifacts::new(Arc::new(FsArtifactStore::new("data/artifacts"))) +async fn demo(storage: &Storage) -> anyhow::Result<()> { +let store = DocumentArtifactStore::open(storage).await?; +let artifacts = Artifacts::new(Arc::new(store)) .with_file_roots(FileRoots::new(["/path/to/agent/workspace".into()])) - .with_file_store(Arc::new(FsFileStore::new("data/artifact-files"))); + .with_file_store(Arc::new(BlobFileStore::new(storage))); // A text artifact, then a human edit of it. let saved = artifacts @@ -53,7 +55,7 @@ artifacts artifacts .create(ArtifactKind::File, "Q3 deck", Content::File("decks/q3.pptx".into()), &Author::agent("writer"), None) .await?; -# Ok(()) } +Ok(()) } ``` The service enforces the rules, whoever is calling: @@ -78,17 +80,19 @@ The service enforces the rules, whoever is calling: | Port | Role | | --- | --- | -| `ArtifactStore` | Where records are kept. `FsArtifactStore` is a ready-made one: one JSON file per artifact in a directory, written atomically. | +| `ArtifactStore` | Where records are kept. `DocumentArtifactStore` adapts the host-bound document port and applies the artifact-owned schema migration on open. | | `ArtifactObserver` | Told after every write (`pending`, `ready`, `failed`, `deleted`) — show a card, copy the file where the user can open it. If it fails, the write stands and the caller gets a notice. | | `ArtifactPublisher` | How a version is shared (e.g. an upload and a link). Optional. | | `FileStore` | Durable file bytes behind `FileRef` keys. Implement it for a host's blob backend, or use `BlobFileStore` to adapt the `BlobStore` port. | -| `FsFileStore` | Filesystem implementation of `FileStore`, useful for a single-host install or local development. | | `FileRoots` | Allowed input folders. Paths are canonicalized and checked before a file store reads them; `with_guard` can refuse paths inside them (credential stores, its own data). | -Older records that serialized `FileRef.path` still deserialize. A host importing -those records into any file store can use `tiny_artifacts::import_legacy_file` -with the old allowed roots; the imported record should then be saved with the -new key returned by that function. +Hosts provide an already-bound `tinystoragedrivers_api::Storage` bundle. The +library exposes `collections()` for host setup, while `DocumentArtifactStore::open` +ensures its own collections and runs its migration before returning. A host +that imports old filesystem records can pass each record's serialized bytes to +`import_legacy_record_json`; locating source files and copying legacy file +bytes remain host responsibilities. Legacy `FileRef.path` values still +deserialize so host import code can resolve them with its existing policy. ## Agent tools @@ -120,9 +124,9 @@ cargo test --all-features RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features ``` -The integration tests in `crates/tiny-artifacts/tests/` cover the tools, the -service (file references, lifecycle, observer, publishing) and the file-system -store, including records written by earlier versions. +The integration tests in `crates/tiny-artifacts/tests/` cover the tools and +service (file references, lifecycle, observer, publishing); storage adapter +tests use the real in-memory storage ports from the development-only testkit. ## Releasing diff --git a/crates/tiny-artifacts/Cargo.toml b/crates/tiny-artifacts/Cargo.toml index e59dba9..7dcc1e3 100644 --- a/crates/tiny-artifacts/Cargo.toml +++ b/crates/tiny-artifacts/Cargo.toml @@ -21,7 +21,9 @@ uuid.workspace = true tokio.workspace = true tracing.workspace = true tinytools.workspace = true -tinystoragedrivers-core.workspace = true +tinystoragedrivers-types.workspace = true +tinystoragedrivers-api.workspace = true [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tinystoragedrivers-testkit.workspace = true diff --git a/crates/tiny-artifacts/src/document_store.rs b/crates/tiny-artifacts/src/document_store.rs new file mode 100644 index 0000000..0381956 --- /dev/null +++ b/crates/tiny-artifacts/src/document_store.rs @@ -0,0 +1,213 @@ +//! Artifact record persistence over a host-bound document handle. + +use std::cmp::Reverse; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use tinystoragedrivers_api::{DocumentStore, DocumentStoreExt, Storage}; +use tinystoragedrivers_types::{ErrorKind, Precondition, Query, StorageError, WriteOptions}; + +use crate::storage::{ + collections, ARTIFACTS_COLLECTION, MIGRATIONS_COLLECTION, MIGRATIONS_ID, SCHEMA_VERSION, +}; +use crate::store::validate_id; +use crate::{ArtifactRecord, ArtifactStore}; + +/// [`ArtifactStore`] backed by documents in a host-provided storage scope. +#[derive(Clone)] +pub struct DocumentArtifactStore { + documents: Arc, +} + +impl std::fmt::Debug for DocumentArtifactStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DocumentArtifactStore") + .finish_non_exhaustive() + } +} + +impl DocumentArtifactStore { + /// Open the artifact owner over an already-bound storage bundle, ensure + /// its collections, and persist its schema checkpoint before returning a + /// usable store. + /// + /// # Errors + /// Returns an error when collections cannot be ensured or the migration + /// checkpoint cannot be read or written. + pub async fn open(storage: &Storage) -> Result { + let documents = Arc::clone(storage.documents()); + for spec in collections() { + documents.ensure_collection(&spec).await?; + } + migrate(documents.as_ref()).await?; + Ok(Self { documents }) + } + + /// Import one record encoded by the previous filesystem store writer. + /// + /// The host owns locating and reading source files; this method accepts + /// their bytes, validates the old record shape, and inserts it only when + /// its id is not already present. An exact body/schema replay is safe; an + /// id already holding different content is a conflict. + /// + /// # Errors + /// Returns an error when the record is malformed, its id is unsafe, a + /// conflicting id already holds another record, or the document backend + /// refuses the write. + pub async fn import_legacy_record_json(&self, bytes: &[u8]) -> Result { + let record: ArtifactRecord = + serde_json::from_slice(bytes).context("legacy artifact record is not valid JSON")?; + validate_id(&record.id)?; + let body = serde_json::to_value(&record)?; + match self + .documents + .put_with( + ARTIFACTS_COLLECTION, + &record.id, + body.clone(), + Precondition::Absent, + WriteOptions::new().schema(SCHEMA_VERSION), + ) + .await + { + Ok(_) => Ok(true), + Err(error) if error.kind() == ErrorKind::Conflict => { + let Some(existing) = self.documents.get(ARTIFACTS_COLLECTION, &record.id).await? + else { + return Err(error.into()); + }; + if existing.meta.schema == SCHEMA_VERSION + && existing.meta.expires_ms.is_none() + && existing.doc == body + { + Ok(false) + } else { + Err(StorageError::conflict( + "legacy artifact id already contains a different record", + ) + .into()) + } + } + Err(error) => Err(error.into()), + } + } +} + +async fn migrate(documents: &dyn DocumentStore) -> Result<()> { + let current = documents.get(MIGRATIONS_COLLECTION, MIGRATIONS_ID).await?; + let current_version = current + .as_ref() + .and_then(|record| record.doc.get("version")) + .and_then(Value::as_u64) + .unwrap_or_default(); + if current_version >= u64::from(SCHEMA_VERSION) + && current + .as_ref() + .is_some_and(|record| record.meta.schema == SCHEMA_VERSION) + { + return Ok(()); + } + + let next = json!({ "version": current_version.max(u64::from(SCHEMA_VERSION)) }); + let precondition = current.map_or(Precondition::Absent, |record| { + Precondition::Version(record.version) + }); + match documents + .put_with( + MIGRATIONS_COLLECTION, + MIGRATIONS_ID, + next, + precondition, + WriteOptions::new().schema(SCHEMA_VERSION), + ) + .await + { + Ok(_) => Ok(()), + Err(error) if error.kind() == ErrorKind::Conflict => { + let latest = documents.get(MIGRATIONS_COLLECTION, MIGRATIONS_ID).await?; + let completed = latest + .as_ref() + .and_then(|record| record.doc.get("version")) + .and_then(Value::as_u64) + .is_some_and(|version| version >= u64::from(SCHEMA_VERSION)) + && latest + .as_ref() + .is_some_and(|record| record.meta.schema == SCHEMA_VERSION); + if completed { + Ok(()) + } else { + Err(error.into()) + } + } + Err(error) => Err(error.into()), + } +} + +#[async_trait] +impl ArtifactStore for DocumentArtifactStore { + async fn list(&self) -> Result> { + let query = Query::all(); + let records = self + .documents + .query_all(ARTIFACTS_COLLECTION, &query) + .await?; + let mut artifacts = Vec::with_capacity(records.len()); + for record in records { + match serde_json::from_value(record.doc) { + Ok(artifact) => artifacts.push(artifact), + Err(error) => tracing::warn!( + target: "tiny-artifacts", + artifact_id = %record.id, + err = %error, + "skipping an unreadable artifact record" + ), + } + } + artifacts.sort_by_key(|record: &ArtifactRecord| { + (Reverse(record.updated_at_millis), record.id.clone()) + }); + Ok(artifacts) + } + + async fn get(&self, id: &str) -> Result> { + validate_id(id)?; + self.documents + .get(ARTIFACTS_COLLECTION, id) + .await? + .map(|record| { + serde_json::from_value(record.doc) + .with_context(|| format!("artifact {id} is not a valid record")) + }) + .transpose() + } + + async fn upsert(&self, artifact: &ArtifactRecord) -> Result<()> { + validate_id(&artifact.id)?; + let body = serde_json::to_value(artifact)?; + self.documents + .put_with( + ARTIFACTS_COLLECTION, + &artifact.id, + body, + Precondition::None, + WriteOptions::new().schema(SCHEMA_VERSION), + ) + .await?; + Ok(()) + } + + async fn delete(&self, id: &str) -> Result { + validate_id(id)?; + self.documents + .delete(ARTIFACTS_COLLECTION, id, Precondition::None) + .await + .map_err(Into::into) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[path = "document_store_tests.rs"] +mod tests; diff --git a/crates/tiny-artifacts/src/document_store_tests.rs b/crates/tiny-artifacts/src/document_store_tests.rs new file mode 100644 index 0000000..78c0ad4 --- /dev/null +++ b/crates/tiny-artifacts/src/document_store_tests.rs @@ -0,0 +1,519 @@ +use super::*; + +use serde_json::Value; +use std::sync::Arc; +use tinystoragedrivers_api::{async_trait, DocumentStore, Storage, StorageBackend}; +use tinystoragedrivers_testkit::{memory_backend, Failing, FaultPlan}; +use tinystoragedrivers_types::{ + Capabilities, Capability, CollectionSpec, ErrorKind, Filter, Page, Precondition, Query, Scope, + SearchHit, Sort, StorageError, Version, Versioned, WriteOptions, +}; + +fn storage(scope: &str) -> Storage { + memory_backend() + .for_scope(&Scope::new(scope).unwrap()) + .unwrap() +} + +fn record(id: &str, updated: u64) -> ArtifactRecord { + let mut value = + ArtifactRecord::new(id, id, crate::ArtifactKind::Markdown, "# body", "writer", 1); + value.updated_at_millis = updated; + value +} + +#[tokio::test] +async fn opening_applies_owned_schema_and_document_store_round_trips_records() { + let storage = storage("document-artifact-store-test"); + let store = DocumentArtifactStore::open(&storage).await.unwrap(); + + let marker = storage + .documents() + .get(MIGRATIONS_COLLECTION, MIGRATIONS_ID) + .await + .unwrap() + .unwrap(); + assert_eq!(marker.doc["version"], SCHEMA_VERSION); + assert_eq!(marker.meta.schema, SCHEMA_VERSION); + store.upsert(&record("older", 1)).await.unwrap(); + store.upsert(&record("newer", 2)).await.unwrap(); + for id in ["older", "newer"] { + let stored = storage + .documents() + .get(ARTIFACTS_COLLECTION, id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.meta.schema, SCHEMA_VERSION); + } + + let listed = store.list().await.unwrap(); + assert_eq!(listed, [record("newer", 2), record("older", 1)]); + assert_eq!(store.get("older").await.unwrap(), Some(record("older", 1))); + assert!(store.delete("older").await.unwrap()); + assert!(!store.delete("older").await.unwrap()); + + let reopened = DocumentArtifactStore::open(&storage).await.unwrap(); + assert_eq!( + reopened.get("newer").await.unwrap(), + Some(record("newer", 2)) + ); +} + +#[tokio::test] +async fn legacy_record_import_is_idempotent_and_does_not_replace_existing_records() { + let store = DocumentArtifactStore::open(&storage("artifact-import-test")) + .await + .unwrap(); + let legacy = include_bytes!("../tests/fixtures/previous-release-1.json"); + + assert!(store.import_legacy_record_json(legacy).await.unwrap()); + assert!(!store.import_legacy_record_json(legacy).await.unwrap()); + let imported = store.get("previous-release-1").await.unwrap().unwrap(); + assert_eq!(imported.title, "Previous release record"); + assert_eq!( + imported.latest().unwrap().body, + "# Preserved artifact\n\nWritten by the 0.1.0 filesystem store." + ); + assert_eq!(imported.created_at_millis, 1_704_067_200_000); + let stored = store + .documents + .get(ARTIFACTS_COLLECTION, "previous-release-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.meta.schema, SCHEMA_VERSION); + + assert!(store + .import_legacy_record_json(br#"{"id":"../outside"}"#) + .await + .is_err()); + assert!(store.import_legacy_record_json(b"not json").await.is_err()); +} + +#[tokio::test] +async fn legacy_import_conflict_only_accepts_identical_schema_and_body() { + let store = DocumentArtifactStore::open(&storage("artifact-import-collision-test")) + .await + .unwrap(); + let legacy = include_bytes!("../tests/fixtures/previous-release-1.json"); + let mut conflicting = serde_json::from_slice::(legacy).unwrap(); + conflicting.title = "A different record with the same id".to_owned(); + store.upsert(&conflicting).await.unwrap(); + + let error = store.import_legacy_record_json(legacy).await.unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + ErrorKind::Conflict + ); + assert_eq!( + store.get("previous-release-1").await.unwrap(), + Some(conflicting) + ); +} + +#[tokio::test] +async fn legacy_import_rejects_matching_target_with_expiry() { + let storage = storage("artifact-import-expiry-test"); + let store = DocumentArtifactStore::open(&storage).await.unwrap(); + let legacy = include_bytes!("../tests/fixtures/previous-release-1.json"); + let source_dir = std::env::current_dir() + .unwrap() + .join("target/test-fixtures") + .join(format!("artifact-import-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&source_dir).unwrap(); + let source_path = source_dir.join("previous-release-1.json"); + std::fs::write(&source_path, legacy).unwrap(); + let source_bytes = std::fs::read(&source_path).unwrap(); + let record: ArtifactRecord = serde_json::from_slice(legacy).unwrap(); + let body = serde_json::to_value(&record).unwrap(); + storage + .documents() + .put_with( + ARTIFACTS_COLLECTION, + &record.id, + body.clone(), + Precondition::Absent, + WriteOptions::new() + .schema(SCHEMA_VERSION) + .expires_ms(i64::MAX), + ) + .await + .unwrap(); + + let error = store + .import_legacy_record_json(&source_bytes) + .await + .unwrap_err(); + + assert_eq!( + error.downcast_ref::().unwrap().kind(), + ErrorKind::Conflict + ); + let existing = storage + .documents() + .get(ARTIFACTS_COLLECTION, &record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(existing.doc, body); + assert_eq!(existing.meta.schema, SCHEMA_VERSION); + assert_eq!(existing.meta.expires_ms, Some(i64::MAX)); + assert_eq!(std::fs::read(source_path).unwrap(), source_bytes); + std::fs::remove_dir_all(source_dir).unwrap(); +} + +#[tokio::test] +async fn legacy_import_rejects_identical_body_without_declared_schema() { + let storage = storage("artifact-import-undeclared-schema-test"); + let store = DocumentArtifactStore::open(&storage).await.unwrap(); + let legacy = include_bytes!("../tests/fixtures/previous-release-1.json"); + let record: ArtifactRecord = serde_json::from_slice(legacy).unwrap(); + storage + .documents() + .put( + ARTIFACTS_COLLECTION, + &record.id, + serde_json::to_value(&record).unwrap(), + Precondition::Absent, + ) + .await + .unwrap(); + + let error = store.import_legacy_record_json(legacy).await.unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + ErrorKind::Conflict + ); + let existing = storage + .documents() + .get(ARTIFACTS_COLLECTION, &record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(existing.doc, serde_json::to_value(record).unwrap()); + assert_eq!(existing.meta.schema, 0); +} + +#[tokio::test] +async fn document_store_debug_output_omits_records_and_import_rejects_unsafe_ids() { + let store = DocumentArtifactStore::open(&storage("artifact-debug-import-test")) + .await + .unwrap(); + let output = format!("{store:?}"); + assert!(output.contains("DocumentArtifactStore")); + assert!(!output.contains("secret record content")); + + let unsafe_record = record("../outside", 1); + let bytes = serde_json::to_vec(&unsafe_record).unwrap(); + assert!(store.import_legacy_record_json(&bytes).await.is_err()); +} + +#[tokio::test] +async fn list_skips_corrupt_records_while_get_reports_them() { + let storage = storage("artifact-corrupt-record-test"); + let store = DocumentArtifactStore::open(&storage).await.unwrap(); + storage + .documents() + .put( + ARTIFACTS_COLLECTION, + "corrupt", + serde_json::json!({"title": "not an artifact record"}), + Precondition::Absent, + ) + .await + .unwrap(); + + assert_eq!(store.list().await.unwrap(), Vec::::new()); + assert!(store.get("corrupt").await.is_err()); +} + +#[tokio::test] +async fn import_reports_backend_write_failure_and_unexplained_absence_conflict() { + let backing = storage("artifact-import-failure-test"); + let faults = FaultPlan::new(); + let wrapped = with_fault_plan(&backing, faults.clone()); + let store = DocumentArtifactStore::open(&wrapped).await.unwrap(); + let legacy = include_bytes!("../tests/fixtures/previous-release-1.json"); + + faults.fail_operation_nth("put_with", 1, ErrorKind::Unavailable); + let backend_error = store.import_legacy_record_json(legacy).await.unwrap_err(); + assert_eq!(storage_error(&backend_error).kind(), ErrorKind::Unavailable); + + faults.fail_operation_nth("put_with", 1, ErrorKind::Conflict); + let conflict = store.import_legacy_record_json(legacy).await.unwrap_err(); + assert_eq!(storage_error(&conflict).kind(), ErrorKind::Conflict); +} + +#[tokio::test] +async fn migration_refuses_to_claim_completion_after_checkpoint_conflict() { + let backing = storage("artifact-migration-conflict-test"); + backing + .documents() + .ensure_collection(&CollectionSpec::new(MIGRATIONS_COLLECTION)) + .await + .unwrap(); + backing + .documents() + .put( + MIGRATIONS_COLLECTION, + MIGRATIONS_ID, + serde_json::json!({"version": SCHEMA_VERSION}), + Precondition::Absent, + ) + .await + .unwrap(); + + let faults = FaultPlan::new(); + faults.fail_operation_nth("put_with", 1, ErrorKind::Conflict); + let error = DocumentArtifactStore::open(&with_fault_plan(&backing, faults)) + .await + .unwrap_err(); + assert_eq!(storage_error(&error).kind(), ErrorKind::Conflict); +} + +#[tokio::test] +async fn records_remain_isolated_by_the_host_bound_scope() { + let first = DocumentArtifactStore::open(&storage("artifact-scope-a")) + .await + .unwrap(); + let second = DocumentArtifactStore::open(&storage("artifact-scope-b")) + .await + .unwrap(); + + first.upsert(&record("same-id", 1)).await.unwrap(); + assert!(second.get("same-id").await.unwrap().is_none()); +} + +#[tokio::test] +async fn document_store_rejects_invalid_ids() { + let store = DocumentArtifactStore::open(&storage("artifact-id-validation")) + .await + .unwrap(); + for id in ["", ".", "..", "../x", "a/b", "a\\b", ".hidden"] { + assert!(store.get(id).await.is_err(), "{id}"); + assert!(store.upsert(&record(id, 1)).await.is_err(), "{id}"); + assert!(store.delete(id).await.is_err(), "{id}"); + } +} + +#[tokio::test] +async fn open_propagates_collection_schema_and_checkpoint_failures() { + let backing = storage("artifact-open-failure-test"); + let ensure_error = + DocumentArtifactStore::open(&with_failing_documents(&backing, FailurePoint::Ensure)) + .await + .unwrap_err(); + assert_eq!(storage_error(&ensure_error).kind(), ErrorKind::Backend); + + let read_error = + DocumentArtifactStore::open(&with_failing_documents(&backing, FailurePoint::Get)) + .await + .unwrap_err(); + assert_eq!(storage_error(&read_error).kind(), ErrorKind::Backend); + + let metadata_error = DocumentArtifactStore::open(&with_failing_documents( + &backing, + FailurePoint::MetadataWrite, + )) + .await + .unwrap_err(); + assert_eq!( + storage_error(&metadata_error).kind(), + ErrorKind::Unsupported(Capability::RecordMetadata) + ); +} + +fn storage_error(error: &anyhow::Error) -> &StorageError { + error.downcast_ref::().unwrap() +} + +fn with_failing_documents(storage: &Storage, fail_at: FailurePoint) -> Storage { + let faults = FaultPlan::new(); + let (operation, kind) = match fail_at { + FailurePoint::Ensure => ("ensure_collection", ErrorKind::Backend), + FailurePoint::Get => ("get", ErrorKind::Backend), + FailurePoint::MetadataWrite => ( + "put_with", + ErrorKind::Unsupported(Capability::RecordMetadata), + ), + }; + faults.fail_operation_nth(operation, 1, kind); + with_fault_plan(storage, faults) +} + +fn with_fault_plan(storage: &Storage, faults: FaultPlan) -> Storage { + let documents = Arc::new(FailingDocuments { + failing: Failing::with_plan(Arc::clone(storage.documents()), faults), + }); + Storage::new( + documents, + Arc::clone(storage.streams()), + Arc::clone(storage.kv()), + Arc::clone(storage.blobs()), + Arc::clone(storage.queues()), + Arc::clone(storage.secrets()), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FailurePoint { + Ensure, + Get, + MetadataWrite, +} + +#[derive(Debug)] +struct FailingDocuments { + failing: Failing, +} + +#[async_trait] +impl DocumentStore for FailingDocuments { + fn capabilities(&self) -> Capabilities { + self.failing.inner.capabilities() + } + + async fn ensure_collection( + &self, + spec: &CollectionSpec, + ) -> tinystoragedrivers_types::Result<()> { + self.failing + .call( + "ensure_collection", + self.failing.inner.ensure_collection(spec), + ) + .await + } + + async fn get( + &self, + collection: &str, + id: &str, + ) -> tinystoragedrivers_types::Result>> { + self.failing + .call("get", self.failing.inner.get(collection, id)) + .await + } + + async fn put( + &self, + collection: &str, + id: &str, + doc: Value, + precondition: Precondition, + ) -> tinystoragedrivers_types::Result { + self.failing + .call( + "put", + self.failing.inner.put(collection, id, doc, precondition), + ) + .await + } + + async fn put_with( + &self, + collection: &str, + id: &str, + doc: Value, + precondition: Precondition, + options: WriteOptions, + ) -> tinystoragedrivers_types::Result { + self.failing + .call( + "put_with", + self.failing + .inner + .put_with(collection, id, doc, precondition, options), + ) + .await + } + + async fn delete( + &self, + collection: &str, + id: &str, + precondition: Precondition, + ) -> tinystoragedrivers_types::Result { + self.failing + .call( + "delete", + self.failing.inner.delete(collection, id, precondition), + ) + .await + } + + async fn query( + &self, + collection: &str, + query: &Query, + ) -> tinystoragedrivers_types::Result>> { + self.failing + .call("query", self.failing.inner.query(collection, query)) + .await + } + + async fn count( + &self, + collection: &str, + filter: &Filter, + ) -> tinystoragedrivers_types::Result { + self.failing + .call("count", self.failing.inner.count(collection, filter)) + .await + } + + async fn delete_where( + &self, + collection: &str, + filter: &Filter, + ) -> tinystoragedrivers_types::Result { + self.failing + .call( + "delete_where", + self.failing.inner.delete_where(collection, filter), + ) + .await + } + + async fn claim( + &self, + collection: &str, + filter: &Filter, + sort: &[Sort], + patch: &Value, + ) -> tinystoragedrivers_types::Result>> { + self.failing + .call( + "claim", + self.failing.inner.claim(collection, filter, sort, patch), + ) + .await + } + + async fn drop_collection(&self, collection: &str) -> tinystoragedrivers_types::Result<()> { + self.failing + .call( + "drop_collection", + self.failing.inner.drop_collection(collection), + ) + .await + } + + async fn collections(&self, prefix: &str) -> tinystoragedrivers_types::Result> { + self.failing + .call("collections", self.failing.inner.collections(prefix)) + .await + } + + async fn search( + &self, + collection: &str, + text: &str, + limit: usize, + ) -> tinystoragedrivers_types::Result> { + self.failing + .call("search", self.failing.inner.search(collection, text, limit)) + .await + } +} diff --git a/crates/tiny-artifacts/src/file_store.rs b/crates/tiny-artifacts/src/file_store.rs index c1b5868..21ccd7f 100644 --- a/crates/tiny-artifacts/src/file_store.rs +++ b/crates/tiny-artifacts/src/file_store.rs @@ -1,12 +1,11 @@ -//! Durable bytes behind [`FileRef`] keys. +//! Durable artifact bytes over a host-bound blob handle. -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; use anyhow::{bail, Context, Result}; use async_trait::async_trait; -use tinystoragedrivers_core::{BlobStore as TsdBlobStore, ScopedStorage}; +use tinystoragedrivers_api::{BlobStore as TsdBlobStore, Storage}; -use crate::file_ref::{FileRef, FileRoots}; /// Storage-independent metadata returned when bytes are written. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FileMetadata { @@ -22,39 +21,36 @@ pub trait FileStore: Send + Sync { /// Store bytes at `key`, replacing any existing object. /// /// # Errors - /// /// Returns an error when the key is invalid or storage fails. async fn put(&self, key: &str, bytes: &[u8], media_type: Option<&str>) -> Result; /// Read the complete object at `key`. /// /// # Errors - /// /// Returns an error when the key is invalid, the object is missing, or /// storage fails. async fn read(&self, key: &str) -> Result>; /// Return metadata for `key`, or `None` when it does not exist. /// /// # Errors - /// /// Returns an error when the key is invalid or storage fails. async fn metadata(&self, key: &str) -> Result>; /// Remove every object below the directory `prefix`, which must end in `/`. /// /// # Errors - /// /// Returns an error when the prefix is invalid or storage fails. async fn delete_prefix(&self, prefix: &str) -> Result<()>; } -/// [`FileStore`] adapter for a host-provided scoped storage handle. +/// [`FileStore`] adapter for a host-provided blob handle. pub struct BlobFileStore { blobs: std::sync::Arc, } impl BlobFileStore { - /// Adapt the blobs in `storage` to the artifact file-store port. + /// Adapt the blobs in the host-bound `storage` bundle to the artifact + /// file-store port. #[must_use] - pub fn new(storage: &ScopedStorage) -> Self { + pub fn new(storage: &Storage) -> Self { Self { blobs: storage.blobs().clone(), } @@ -107,167 +103,6 @@ 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 - } - - async fn path(&self, key: &str, create_parent: bool) -> Result { - validate_key(key)?; - tokio::fs::create_dir_all(&self.root).await?; - let root = tokio::fs::canonicalize(&self.root).await?; - let path = root.join(key); - let parent = path.parent().context("file key has no parent")?; - if create_parent { - create_parent_directories(&root, key).await?; - } - let canonical_parent = match tokio::fs::canonicalize(parent).await { - Ok(parent) => parent, - Err(error) if !create_parent && error.kind() == std::io::ErrorKind::NotFound => { - let mut ancestor = parent; - let canonical_ancestor = loop { - match tokio::fs::canonicalize(ancestor).await { - Ok(canonical) => break canonical, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - ancestor = ancestor - .parent() - .context("file key parent is outside the store")?; - } - Err(error) => return Err(error.into()), - } - }; - if !canonical_ancestor.starts_with(&root) { - bail!("file key escapes the configured store root: {key:?}"); - } - let missing = parent - .strip_prefix(ancestor) - .context("file key parent is outside the store")?; - return Ok(canonical_ancestor - .join(missing) - .join(path.file_name().context("file key has no name")?)); - } - Err(error) => { - return Err(error) - .with_context(|| format!("cannot resolve parent of {}", path.display())); - } - }; - if !canonical_parent.starts_with(&root) { - bail!("file key escapes the configured store root: {key:?}"); - } - Ok(canonical_parent.join(path.file_name().context("file key has no name")?)) - } -} - -async fn create_parent_directories(root: &Path, key: &str) -> Result<()> { - let mut directory = root.to_path_buf(); - let mut segments = key.split('/').peekable(); - while let Some(segment) = segments.next() { - if segments.peek().is_none() { - break; - } - let candidate = directory.join(segment); - match tokio::fs::create_dir(&candidate).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error) - .with_context(|| format!("cannot create {}", candidate.display())); - } - } - directory = tokio::fs::canonicalize(&candidate) - .await - .with_context(|| format!("cannot resolve {}", candidate.display()))?; - if !directory.starts_with(root) { - bail!("file key escapes the configured store root: {key:?}"); - } - } - Ok(()) -} - -#[async_trait] -impl FileStore for FsFileStore { - async fn put(&self, key: &str, bytes: &[u8], media_type: Option<&str>) -> Result { - let path = self.path(key, true).await?; - let parent = path.parent().context("file key has no parent")?; - let staging = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); - tokio::fs::write(&staging, bytes) - .await - .with_context(|| format!("cannot write {}", staging.display()))?; - if let Err(error) = tokio::fs::rename(&staging, &path).await { - let _ = tokio::fs::remove_file(&staging).await; - return Err(error).with_context(|| format!("cannot save {}", path.display())); - } - Ok(FileMetadata { - size_bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), - media_type: media_type - .map(str::to_owned) - .or_else(|| crate::media_type_for(Path::new(key)).map(str::to_owned)), - }) - } - - async fn read(&self, key: &str) -> Result> { - let path = self.path(key, false).await?; - if let Ok(canonical) = tokio::fs::canonicalize(&path).await { - let root = tokio::fs::canonicalize(&self.root).await?; - if !canonical.starts_with(root) { - bail!("file key escapes the configured store root: {key:?}"); - } - } - tokio::fs::read(&path) - .await - .with_context(|| format!("cannot read {}", path.display())) - } - - async fn metadata(&self, key: &str) -> Result> { - let path = self.path(key, false).await?; - if let Ok(canonical) = tokio::fs::canonicalize(&path).await { - let root = tokio::fs::canonicalize(&self.root).await?; - if !canonical.starts_with(root) { - bail!("file key escapes the configured store root: {key:?}"); - } - } - match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => Ok(Some(FileMetadata { - size_bytes: metadata.len(), - media_type: crate::media_type_for(&path).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 {}", path.display())), - } - } - - async fn delete_prefix(&self, prefix: &str) -> Result<()> { - validate_prefix(prefix)?; - let path = self.path(prefix.trim_end_matches('/'), false).await?; - if let Ok(canonical) = tokio::fs::canonicalize(&path).await { - let root = tokio::fs::canonicalize(&self.root).await?; - if !canonical.starts_with(root) { - bail!("file key prefix escapes the configured store root: {prefix:?}"); - } - } - match tokio::fs::remove_dir_all(&path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).with_context(|| format!("cannot remove {}", path.display())), - } - } -} - fn validate_key(key: &str) -> Result<()> { if key.is_empty() || key.starts_with('/') || key.contains('\\') || key.contains('\0') { bail!("invalid file key {key:?}"); @@ -299,35 +134,6 @@ fn validate_prefix(prefix: &str) -> Result<()> { validate_key(&prefix[..prefix.len() - 1]) } -/// Copy a legacy path-based reference into any configured file store. -/// -/// The source path is resolved against the supplied roots before it is read; -/// this also applies the host's guard to desktop data being imported. -/// -/// # Errors -/// -/// Returns an error if the old path is refused, unreadable, or the new object -/// cannot be written. -pub async fn import_legacy_file( - store: &dyn FileStore, - roots: &FileRoots, - legacy: &FileRef, - key: &str, -) -> Result { - let source = roots.resolve(&legacy.key).await?; - let bytes = tokio::fs::read(&source.path).await?; - let media_type = legacy - .media_type - .as_deref() - .or(source.media_type.as_deref()); - let metadata = store.put(key, &bytes, media_type).await?; - Ok(FileRef { - key: key.to_owned(), - size_bytes: metadata.size_bytes, - media_type: metadata.media_type, - }) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] #[path = "file_store_tests.rs"] diff --git a/crates/tiny-artifacts/src/file_store_tests.rs b/crates/tiny-artifacts/src/file_store_tests.rs index bfc2a49..2d5b0b9 100644 --- a/crates/tiny-artifacts/src/file_store_tests.rs +++ b/crates/tiny-artifacts/src/file_store_tests.rs @@ -1,18 +1,19 @@ use super::*; -use tinystoragedrivers_core::{MemoryStorage, Scope, StorageBackend}; +use tinystoragedrivers_api::Storage; +use tinystoragedrivers_testkit::memory_storage; +use tinystoragedrivers_types::Scope; -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 +fn storage() -> Storage { + memory_storage(&Scope::new("artifact-files-test").unwrap()) } #[tokio::test] -async fn filesystem_store_reads_metadata_and_deletes_by_artifact_prefix() { - let root = temp_dir(); - let store = FsFileStore::new(&root); - let metadata = store +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); + let metadata = first .put("artifacts/files/a/v1.png", b"png bytes", None) .await .unwrap(); @@ -20,337 +21,73 @@ async fn filesystem_store_reads_metadata_and_deletes_by_artifact_prefix() { assert_eq!(metadata.size_bytes, 9); assert_eq!(metadata.media_type.as_deref(), Some("image/png")); assert_eq!( - store.read("artifacts/files/a/v1.png").await.unwrap(), + second.read("artifacts/files/a/v1.png").await.unwrap(), b"png bytes" ); assert_eq!( - store.metadata("artifacts/files/a/v1.png").await.unwrap(), + second.metadata("artifacts/files/a/v1.png").await.unwrap(), Some(metadata) ); - store.delete_prefix("artifacts/files/a/").await.unwrap(); - assert_eq!( - store.metadata("artifacts/files/a/v1.png").await.unwrap(), - None - ); -} - -#[tokio::test] -async fn blob_adapters_share_objects_across_instances() { - let backend = MemoryStorage::new(); - let scope = Scope::local(); - let first_storage = backend.for_scope(&scope).unwrap(); - let second_storage = backend.for_scope(&scope).unwrap(); - let first = BlobFileStore::new(&first_storage); - let second = BlobFileStore::new(&second_storage); - first - .put("artifacts/files/a/v1.txt", b"notes", None) + second + .put("artifacts/files/ab/v1.png", b"sibling", None) .await .unwrap(); - first - .put("artifacts/files/private-key.txt", b"private payload", None) - .await - .unwrap(); - let debug = format!("{first:?}"); - assert!(debug.contains("BlobFileStore")); - assert!(!debug.contains("private-key.txt")); - assert!(!debug.contains("private payload")); - - assert_eq!( - second.read("artifacts/files/a/v1.txt").await.unwrap(), - b"notes" - ); second.delete_prefix("artifacts/files/a/").await.unwrap(); assert_eq!( - first.metadata("artifacts/files/a/v1.txt").await.unwrap(), + first.metadata("artifacts/files/a/v1.png").await.unwrap(), None ); + assert_eq!( + first.read("artifacts/files/ab/v1.png").await.unwrap(), + b"sibling" + ); } #[tokio::test] -async fn blob_store_reports_missing_objects_and_rejects_invalid_keys_and_prefixes() { - let backend = MemoryStorage::new(); - let storage = backend.for_scope(&Scope::local()).unwrap(); +async fn blob_adapter_rejects_invalid_keys_and_prefixes_without_touching_siblings() { + let storage = storage(); let store = BlobFileStore::new(&storage); - assert!(store.read("artifacts/files/missing.txt").await.is_err()); assert_eq!( store.metadata("artifacts/files/missing.txt").await.unwrap(), None ); - assert!(store.put("../outside", b"bad", None).await.is_err()); - assert!(store - .put("artifacts/files/a//v1.txt", b"bad", None) - .await - .is_err()); - assert!(store - .put("artifacts/files/a/./v1.txt", b"bad", None) - .await - .is_err()); - assert!(store.metadata("/absolute").await.is_err()); - assert!(store.read("bad\\key").await.is_err()); - assert!(store.delete_prefix("").await.is_err()); + for key in [ + "../outside", + "artifacts/files/a//v1", + "artifacts/files/a/./v1", + "/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}"); + } store - .put("artifacts/files/sibling/v1.txt", b"keep", None) + .put("artifacts/files/a/v1.txt", b"a", None) .await .unwrap(); - assert!(store.delete_prefix("../").await.is_err()); - assert_eq!( - store.read("artifacts/files/sibling/v1.txt").await.unwrap(), - b"keep" - ); store - .delete_prefix("artifacts/files/missing/") + .put("artifacts/files/ab/v1.txt", b"ab", None) .await .unwrap(); -} -#[tokio::test] -async fn prefix_deletion_requires_a_directory_boundary_in_both_stores() { - let backend = MemoryStorage::new(); - let storage = backend.for_scope(&Scope::local()).unwrap(); - let blobs = BlobFileStore::new(&storage); - let fs = FsFileStore::new(temp_dir()); - - for store in [&blobs as &dyn FileStore, &fs as &dyn FileStore] { - store - .put("artifacts/files/a/v1.txt", b"a", None) - .await - .unwrap(); - store - .put("artifacts/files/ab/v1.txt", b"ab", None) - .await - .unwrap(); - - assert!(store.delete_prefix("artifacts/files/a").await.is_err()); + for prefix in [ + "", + "artifacts/files/a", + "artifacts/files/a//", + "artifacts/files/a/./", + "../", + ] { + assert!(store.delete_prefix(prefix).await.is_err(), "{prefix}"); assert_eq!(store.read("artifacts/files/a/v1.txt").await.unwrap(), b"a"); assert_eq!( store.read("artifacts/files/ab/v1.txt").await.unwrap(), b"ab" ); - store.delete_prefix("artifacts/files/a/").await.unwrap(); - assert!(store - .metadata("artifacts/files/a/v1.txt") - .await - .unwrap() - .is_none()); - assert_eq!( - store.read("artifacts/files/ab/v1.txt").await.unwrap(), - b"ab" - ); } -} - -#[tokio::test] -async fn prefix_deletion_rejects_noncanonical_segments_without_touching_siblings() { - let backend = MemoryStorage::new(); - let storage = backend.for_scope(&Scope::local()).unwrap(); - let blobs = BlobFileStore::new(&storage); - let fs = FsFileStore::new(temp_dir()); - - for store in [&blobs as &dyn FileStore, &fs as &dyn FileStore] { - store - .put("artifacts/files/a/v1.txt", b"a", None) - .await - .unwrap(); - store - .put("artifacts/files/ab/v1.txt", b"ab", None) - .await - .unwrap(); - - for malformed in ["artifacts/files/a//", "artifacts/files/a/./"] { - assert!(store.delete_prefix(malformed).await.is_err()); - assert_eq!(store.read("artifacts/files/a/v1.txt").await.unwrap(), b"a"); - assert_eq!( - store.read("artifacts/files/ab/v1.txt").await.unwrap(), - b"ab" - ); - } - } -} - -#[tokio::test] -async fn filesystem_store_refuses_traversal_keys() { - let store = FsFileStore::new(temp_dir()); - assert!(store.put("../outside", b"bad", None).await.is_err()); - assert!(store.read("/absolute").await.is_err()); - store - .put("artifacts/files/sibling/v1.txt", b"keep", None) - .await - .unwrap(); - assert!(store.delete_prefix("../").await.is_err()); - assert_eq!( - store.read("artifacts/files/sibling/v1.txt").await.unwrap(), - b"keep" - ); -} - -#[tokio::test] -async fn filesystem_store_reports_missing_paths_and_storage_errors() { - let root = temp_dir(); - let store = FsFileStore::new(&root); - - assert!(store.read("artifacts/files/missing.txt").await.is_err()); - assert_eq!( - store.metadata("artifacts/files/missing.txt").await.unwrap(), - None - ); store .delete_prefix("artifacts/files/missing/") .await .unwrap(); - assert!(store.put("", b"bad", None).await.is_err()); - assert!(store - .put("artifacts/files/a//v1.txt", b"bad", None) - .await - .is_err()); - assert!(store - .put("artifacts/files/a/./v1.txt", b"bad", None) - .await - .is_err()); - assert!(store.put("bad\\key", b"bad", None).await.is_err()); - assert!(store.put("bad\0key", b"bad", None).await.is_err()); - - let file_root = root.join("not-a-directory"); - std::fs::write(&file_root, b"file").unwrap(); - let blocked = FsFileStore::new(file_root); - assert!(blocked - .put("artifacts/files/a/v1.txt", b"bad", None) - .await - .is_err()); - - let stored = "artifacts/files/a/v1.txt"; - store.put(stored, b"data", None).await.unwrap(); - assert!(store.delete_prefix(&format!("{stored}/")).await.is_err()); - - assert_eq!(store.metadata("artifacts/files/a").await.unwrap(), None); - assert!(store - .put("artifacts/files/a", b"replace directory", None) - .await - .is_err()); - assert_eq!(store.read(stored).await.unwrap(), b"data"); -} - -#[test] -fn file_key_validation_rejects_unsafe_keys_and_prefixes() { - assert!(validate_key("").is_err()); - assert!(validate_key("/absolute").is_err()); - assert!(validate_key("../outside").is_err()); - assert!(validate_key("a//b").is_err()); - assert!(validate_key("a/./b").is_err()); - assert!(validate_key("nested\\file").is_err()); - assert!(validate_key("nul\0key").is_err()); - assert!(validate_key("artifacts/files/a/v1.txt").is_ok()); - assert!(validate_prefix("").is_err()); - assert!(validate_prefix("../").is_err()); - assert!(validate_prefix("a//").is_err()); - assert!(validate_prefix("a/./").is_err()); - assert!(validate_prefix("artifacts/files/a/").is_ok()); -} - -#[cfg(unix)] -#[tokio::test] -async fn filesystem_store_refuses_symlinked_directories_outside_root() { - use std::os::unix::fs::symlink; - - let root = temp_dir(); - let outside = temp_dir(); - std::fs::write(outside.join("keep.txt"), b"safe").unwrap(); - symlink(&outside, root.join("escape")).unwrap(); - let store = FsFileStore::new(&root); - - assert!(store.put("escape/new.txt", b"bad", None).await.is_err()); - assert!(store - .put("escape/new-directory/file.txt", b"bad", None) - .await - .is_err()); - assert!(!outside.join("new-directory").exists()); - assert!(store.read("escape/keep.txt").await.is_err()); - assert!(store.delete_prefix("escape/").await.is_err()); - assert!(store.metadata("escape/not-created/file.txt").await.is_err()); - assert!(store.delete_prefix("escape/not-created/").await.is_err()); - assert_eq!(std::fs::read(outside.join("keep.txt")).unwrap(), b"safe"); - assert!(!outside.join("new.txt").exists()); -} - -#[tokio::test] -async fn legacy_path_references_import_only_from_allowed_roots() { - let old_root = temp_dir(); - let old_path = old_root.join("v1.pdf"); - std::fs::write(&old_path, b"legacy").unwrap(); - let legacy: FileRef = serde_json::from_value(serde_json::json!({ - "path": old_path, - "sizeBytes": 6, - "mediaType": "application/pdf" - })) - .unwrap(); - let store = FsFileStore::new(temp_dir()); - - let imported = import_legacy_file( - &store, - &FileRoots::new([old_root.clone()]), - &legacy, - "artifacts/files/a/v1.pdf", - ) - .await - .unwrap(); - assert_eq!(imported.key, "artifacts/files/a/v1.pdf"); - assert_eq!(store.read(&imported.key).await.unwrap(), b"legacy"); - assert!(import_legacy_file( - &store, - &FileRoots::new([temp_dir()]), - &legacy, - "artifacts/files/b/v1.pdf", - ) - .await - .is_err()); - - let backend = MemoryStorage::new(); - let storage = backend.for_scope(&Scope::local()).unwrap(); - let blob_store = BlobFileStore::new(&storage); - let imported_blob = import_legacy_file( - &blob_store, - &FileRoots::new([old_root]), - &legacy, - "artifacts/files/cloud/v1.pdf", - ) - .await - .unwrap(); - assert_eq!( - blob_store.read(&imported_blob.key).await.unwrap(), - b"legacy" - ); -} - -#[tokio::test] -async fn legacy_import_respects_host_guards_and_infers_missing_media_type() { - let root = temp_dir(); - let old_path = root.join("legacy.txt"); - std::fs::write(&old_path, b"legacy text").unwrap(); - let legacy: FileRef = serde_json::from_value(serde_json::json!({ - "path": old_path, - "sizeBytes": 11 - })) - .unwrap(); - let store = FsFileStore::new(temp_dir()); - let guarded_roots = FileRoots::new([root]).with_guard(|_| Some("blocked by test".into())); - store - .put("artifacts/files/a/v1.txt", b"preserve me", None) - .await - .unwrap(); - - assert!( - import_legacy_file(&store, &guarded_roots, &legacy, "artifacts/files/a/v1.txt") - .await - .is_err() - ); - assert_eq!( - store.read("artifacts/files/a/v1.txt").await.unwrap(), - b"preserve me" - ); - let allowed = FileRoots::new([Path::new(&legacy.key).parent().unwrap().to_path_buf()]); - let imported = import_legacy_file(&store, &allowed, &legacy, "artifacts/files/a/v1.txt") - .await - .unwrap(); - assert_eq!(imported.media_type.as_deref(), Some("text/plain")); - assert_eq!(imported.size_bytes, 11); } diff --git a/crates/tiny-artifacts/src/fs_store.rs b/crates/tiny-artifacts/src/fs_store.rs deleted file mode 100644 index 763e688..0000000 --- a/crates/tiny-artifacts/src/fs_store.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! [`FsArtifactStore`]: a ready-made [`ArtifactStore`] on the local file system. -//! -//! One JSON file per artifact, `/.json`, holding the whole record. -//! The host picks the directory; the store creates it on first write. -//! -//! * Writes are atomic: the record goes to a temporary file in the same -//! directory and is renamed over the old one, so a crash mid-write never -//! leaves a half-written record. -//! * Ids are checked before they become file names: no separators, no `.` or -//! `..`, nothing empty. -//! * A record that cannot be parsed is skipped by [`ArtifactStore::list`] (with -//! a warning) so one bad file does not hide every other artifact, but -//! reported as an error by [`ArtifactStore::get`]. - -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context, Result}; -use async_trait::async_trait; - -use crate::store::ArtifactStore; -use crate::types::ArtifactRecord; - -/// Artifacts stored as one JSON file each in a directory. -#[derive(Clone, Debug)] -pub struct FsArtifactStore { - dir: PathBuf, -} - -impl FsArtifactStore { - /// A store keeping its records in `dir`. - #[must_use] - pub fn new(dir: impl Into) -> Self { - Self { dir: dir.into() } - } - - /// The directory records are kept in. - #[must_use] - pub fn dir(&self) -> &Path { - &self.dir - } - - fn record_path(&self, id: &str) -> Result { - validate_id(id)?; - Ok(self.dir.join(format!("{id}.json"))) - } -} - -/// Reject ids that could escape the directory or collide with its own files. -pub(crate) fn validate_id(id: &str) -> Result<()> { - if id.is_empty() || id == "." || id == ".." { - bail!("invalid artifact id {id:?}"); - } - if id.contains(['/', '\\', '\0']) { - bail!("artifact id must not contain path separators: {id:?}"); - } - if id.starts_with('.') { - bail!("artifact id must not start with '.': {id:?}"); - } - Ok(()) -} - -#[async_trait] -impl ArtifactStore for FsArtifactStore { - async fn list(&self) -> Result> { - let mut entries = match tokio::fs::read_dir(&self.dir).await { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => { - return Err(err).with_context(|| format!("cannot list {}", self.dir.display())) - } - }; - let mut records = Vec::new(); - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let bytes = tokio::fs::read(&path).await?; - match serde_json::from_slice::(&bytes) { - Ok(record) => records.push(record), - Err(err) => tracing::warn!( - target: "tiny-artifacts", - file = ?path.file_name(), - err = %err, - "skipping an unreadable artifact record" - ), - } - } - records.sort_by_key(|record| std::cmp::Reverse(record.updated_at_millis)); - Ok(records) - } - - async fn get(&self, id: &str) -> Result> { - let path = self.record_path(id)?; - let bytes = match tokio::fs::read(&path).await { - Ok(bytes) => bytes, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err).with_context(|| format!("cannot read artifact {id}")), - }; - let record = serde_json::from_slice(&bytes) - .with_context(|| format!("artifact {id} is not a valid record"))?; - Ok(Some(record)) - } - - async fn upsert(&self, artifact: &ArtifactRecord) -> Result<()> { - let path = self.record_path(&artifact.id)?; - tokio::fs::create_dir_all(&self.dir) - .await - .with_context(|| format!("cannot create {}", self.dir.display()))?; - let bytes = serde_json::to_vec(artifact)?; - // Same directory, so the rename is atomic on one file system. - let staging = self - .dir - .join(format!(".{}.{}.tmp", artifact.id, uuid::Uuid::new_v4())); - tokio::fs::write(&staging, bytes) - .await - .with_context(|| format!("cannot write artifact {}", artifact.id))?; - if let Err(err) = tokio::fs::rename(&staging, &path).await { - let _ = tokio::fs::remove_file(&staging).await; - return Err(err).with_context(|| format!("cannot save artifact {}", artifact.id)); - } - Ok(()) - } - - async fn delete(&self, id: &str) -> Result { - let path = self.record_path(id)?; - match tokio::fs::remove_file(&path).await { - Ok(()) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err).with_context(|| format!("cannot delete artifact {id}")), - } - } -} diff --git a/crates/tiny-artifacts/src/lib.rs b/crates/tiny-artifacts/src/lib.rs index 7fbc2ee..9f84dcf 100644 --- a/crates/tiny-artifacts/src/lib.rs +++ b/crates/tiny-artifacts/src/lib.rs @@ -12,8 +12,8 @@ //! ([`registered_tools`]) and the host's own producers both go through it. The //! host supplies the ports: //! -//! * [`ArtifactStore`] — where records are kept ([`FsArtifactStore`] is a -//! ready-made one: a JSON file per artifact in a directory); +//! * [`ArtifactStore`] — where records are kept (`DocumentArtifactStore` adapts +//! the host-bound document port); //! * [`ArtifactObserver`] — told after every write, to show the artifact //! (a card, a board row) and place its file where the user can open it; //! * [`ArtifactPublisher`] — how a version is shared, when the host can; @@ -22,24 +22,30 @@ //! ```rust,no_run //! use std::sync::Arc; //! use tiny_artifacts::{ -//! registered_tools, ArtifactStore, Artifacts, Author, FileRoots, FsFileStore, +//! registered_tools, ArtifactStore, Artifacts, Author, BlobFileStore, DocumentArtifactStore, +//! FileRoots, //! }; //! -//! fn tools(store: Arc, workdir: std::path::PathBuf) { +//! async fn tools( +//! storage: &tinystoragedrivers_api::Storage, +//! workdir: std::path::PathBuf, +//! ) -> anyhow::Result<()> { +//! let store = DocumentArtifactStore::open(storage).await?; //! let artifacts = Arc::new( -//! Artifacts::new(store) +//! Artifacts::new(Arc::new(store)) //! .with_file_roots(FileRoots::new([workdir])) -//! .with_file_store(Arc::new(FsFileStore::new("data/artifact-files"))), +//! .with_file_store(Arc::new(BlobFileStore::new(&storage))), //! ); //! let _tools = registered_tools(artifacts, Author::agent("my-agent")); +//! Ok(()) //! } //! ``` mod diff; +pub mod document_store; pub mod events; pub mod file_ref; pub mod file_store; -pub mod fs_store; pub mod publish; pub mod service; pub mod storage; @@ -48,13 +54,13 @@ pub mod tools; pub mod types; 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::{import_legacy_file, BlobFileStore, FileMetadata, FileStore, FsFileStore}; -pub use fs_store::FsArtifactStore; +pub use file_store::{BlobFileStore, FileMetadata, FileStore}; pub use publish::{ArtifactPublisher, PublishOptions}; pub use service::{Artifacts, ArtifactsError, Author, Content, Published, Saved}; -pub use storage::storage_schema; +pub use storage::collections; pub use store::ArtifactStore; pub use tools::{ registered_tools, CreateArtifactTool, DeleteArtifactTool, ListArtifactsTool, ReadArtifactTool, diff --git a/crates/tiny-artifacts/src/service.rs b/crates/tiny-artifacts/src/service.rs index 4bd2855..a9232e5 100644 --- a/crates/tiny-artifacts/src/service.rs +++ b/crates/tiny-artifacts/src/service.rs @@ -12,7 +12,6 @@ //! record is always written first and anything the host shows is derived //! from it. -use std::path::PathBuf; use std::sync::Arc; use uuid::Uuid; @@ -201,16 +200,6 @@ impl Artifacts { self } - /// Configure the built-in filesystem file store rooted at `dir`. - /// - /// Prefer [`Self::with_file_store`] when the host already has a durable - /// shared blob backend. - #[must_use] - pub fn with_version_files(mut self, dir: impl Into) -> Self { - self.file_store = Some(Arc::new(crate::FsFileStore::new(dir))); - self - } - /// The underlying store, for reads. #[must_use] pub fn store(&self) -> &Arc { @@ -450,7 +439,7 @@ impl Artifacts { }; }; let id = record.id.clone(); - crate::fs_store::validate_id(&id)?; + crate::store::validate_id(&id)?; let holds_file = record.kind.holds_file(); let Some(version) = record.versions.last_mut() else { return Ok(()); @@ -492,7 +481,7 @@ impl Artifacts { let Some(file_store) = &self.file_store else { return; }; - if crate::fs_store::validate_id(id).is_err() { + if crate::store::validate_id(id).is_err() { return; } match file_store diff --git a/crates/tiny-artifacts/src/storage.rs b/crates/tiny-artifacts/src/storage.rs index 0335a4b..d37ad21 100644 --- a/crates/tiny-artifacts/src/storage.rs +++ b/crates/tiny-artifacts/src/storage.rs @@ -1,27 +1,26 @@ -//! Tiny Storage Drivers declarations owned by this crate. +//! Collections owned by the artifact library. -use tinystoragedrivers_core::schema::{Owner, Schema, ScopeClass}; +use tinystoragedrivers_types::CollectionSpec; -/// Declare the blob namespace used by [`crate::BlobFileStore`]. -/// -/// Hosts compose this with their other storage schemas so the driver can -/// reserve the `artifacts/files/` prefix in the same scope used for artifact -/// data. -/// -/// # Panics +/// Documents containing versioned artifact records. +pub const ARTIFACTS_COLLECTION: &str = "artifacts.records"; + +/// The per-owner migration checkpoint. +pub(crate) const MIGRATIONS_COLLECTION: &str = "artifacts.migrations"; +pub(crate) const MIGRATIONS_ID: &str = "schema"; +pub(crate) const SCHEMA_VERSION: u32 = 1; + +/// Return the document collections the host should ensure in its bound scope. /// -/// This panics only if the hard-coded owner name ceases to be valid. +/// Migration behavior remains owned by [`crate::DocumentArtifactStore::open`]. #[must_use] -#[expect( - clippy::expect_used, - reason = "the constant owner name is valid and this declaration is tested" -)] -pub fn storage_schema() -> Schema { - Schema::new(Owner::new("artifacts").expect("`artifacts` is a valid owner")) - .blobs("artifacts/files/", ScopeClass::Tenant) +pub fn collections() -> Vec { + vec![ + CollectionSpec::new(ARTIFACTS_COLLECTION), + CollectionSpec::new(MIGRATIONS_COLLECTION), + ] } #[cfg(test)] -#[allow(clippy::expect_used)] #[path = "storage_tests.rs"] mod tests; diff --git a/crates/tiny-artifacts/src/storage_tests.rs b/crates/tiny-artifacts/src/storage_tests.rs index 37c8979..a001a21 100644 --- a/crates/tiny-artifacts/src/storage_tests.rs +++ b/crates/tiny-artifacts/src/storage_tests.rs @@ -1,13 +1,11 @@ use super::*; #[test] -fn declares_artifact_blob_prefix_for_tenant_scope() { - let schema = storage_schema(); - let _registry = tinystoragedrivers_core::schema::Registry::compose([schema.clone()]) - .expect("artifact schema composes"); +fn exposes_only_artifact_owned_document_collections() { + let names: Vec<_> = collections() + .into_iter() + .map(|spec| spec.name.clone()) + .collect(); - assert_eq!(schema.owner.as_str(), "artifacts"); - assert_eq!(schema.items.len(), 1); - assert_eq!(schema.items[0].name(), "artifacts/files/"); - assert_eq!(schema.items[0].scope(), ScopeClass::Tenant); + assert_eq!(names, ["artifacts.records", "artifacts.migrations"]); } diff --git a/crates/tiny-artifacts/src/store.rs b/crates/tiny-artifacts/src/store.rs index f520f2e..82ab353 100644 --- a/crates/tiny-artifacts/src/store.rs +++ b/crates/tiny-artifacts/src/store.rs @@ -1,9 +1,4 @@ //! The [`ArtifactStore`] port: durable storage for versioned artifacts. -//! -//! The trait is the contract; [`crate::FsArtifactStore`] is a ready-made -//! backend on the local file system. A host (`OpenHuman`, `OpenCompany`) may -//! implement it against its own storage instead; the service and the tools -//! drive it and know nothing about where bytes land. use anyhow::Result; use async_trait::async_trait; @@ -23,3 +18,20 @@ pub trait ArtifactStore: Send + Sync { /// Deletes an artifact by id; returns whether one was removed. async fn delete(&self, id: &str) -> Result; } + +/// Reject ids that could escape the artifact namespace or collide with path +/// separators used by versioned file keys. +pub(crate) fn validate_id(id: &str) -> anyhow::Result<()> { + use anyhow::bail; + + if id.is_empty() || id == "." || id == ".." { + bail!("invalid artifact id {id:?}"); + } + if id.contains(['/', '\\', '\0']) { + bail!("artifact id must not contain path separators: {id:?}"); + } + if id.starts_with('.') { + bail!("artifact id must not start with '.': {id:?}"); + } + Ok(()) +} diff --git a/crates/tiny-artifacts/tests/diff.rs b/crates/tiny-artifacts/tests/diff.rs index 698163b..78b5a60 100644 --- a/crates/tiny-artifacts/tests/diff.rs +++ b/crates/tiny-artifacts/tests/diff.rs @@ -22,7 +22,7 @@ fn writing_into_an_empty_version_is_total_churn_and_no_change_is_none() { let same = ArtifactDiff::between(&version(1, ""), &version(2, "")); assert!(same.churn.abs() < f64::EPSILON); - assert!(same.lines.is_empty()); + assert_eq!(same.lines.len(), 0); } #[test] diff --git a/crates/tiny-artifacts/tests/fixtures/README.md b/crates/tiny-artifacts/tests/fixtures/README.md new file mode 100644 index 0000000..488fdf3 --- /dev/null +++ b/crates/tiny-artifacts/tests/fixtures/README.md @@ -0,0 +1,27 @@ +# Previous filesystem writer fixture + +`previous-release-1.json` was written by the public `FsArtifactStore::upsert` +implementation from tiny-artifacts commit +`704e9c3062512fad1d309280fb8782c8ce3c9034`. That source's lockfile resolves +`tinystoragedrivers-core` to `be94f7568fe7650b0729be55128f48270414b9a7`. +The fixture is a real serialized `ArtifactRecord`; the current document owner +imports it through `DocumentArtifactStore::import_legacy_record_json` after +opening and applying its own schema migration. + +Regenerate it from this repository with the checked-in public-API generator: + +```sh +mkdir -p target/legacy-artifact-writer +git archive 704e9c3062512fad1d309280fb8782c8ce3c9034 \ + | tar -x -C target/legacy-artifact-writer +mkdir -p target/legacy-artifact-writer/crates/tiny-artifacts/examples +cp crates/tiny-artifacts/tests/fixtures/generate_legacy_artifact.rs \ + target/legacy-artifact-writer/crates/tiny-artifacts/examples/ +cargo run --locked \ + --manifest-path target/legacy-artifact-writer/crates/tiny-artifacts/Cargo.toml \ + --example generate_legacy_artifact -- \ + crates/tiny-artifacts/tests/fixtures +``` + +The generated file's SHA-256 is +`b388ce1f98e1fad9582b3797b526fe97d7ef6bc824f7ba102cd367f4ae0c9cb8`. diff --git a/crates/tiny-artifacts/tests/fixtures/generate_legacy_artifact.rs b/crates/tiny-artifacts/tests/fixtures/generate_legacy_artifact.rs new file mode 100644 index 0000000..38281b9 --- /dev/null +++ b/crates/tiny-artifacts/tests/fixtures/generate_legacy_artifact.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use tiny_artifacts::{ArtifactKind, ArtifactRecord, ArtifactStore, FsArtifactStore}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let directory = PathBuf::from(std::env::args().nth(1).expect("output directory")); + let record = ArtifactRecord::new( + "previous-release-1", + "Previous release record", + ArtifactKind::Markdown, + "# Preserved artifact\n\nWritten by the 0.1.0 filesystem store.", + "legacy-agent", + 1_704_067_200_000, + ); + FsArtifactStore::new(directory).upsert(&record).await?; + Ok(()) +} diff --git a/crates/tiny-artifacts/tests/fixtures/previous-release-1.json b/crates/tiny-artifacts/tests/fixtures/previous-release-1.json new file mode 100644 index 0000000..99f7dcb --- /dev/null +++ b/crates/tiny-artifacts/tests/fixtures/previous-release-1.json @@ -0,0 +1 @@ +{"id":"previous-release-1","title":"Previous release record","kind":"markdown","versions":[{"version":1,"body":"# Preserved artifact\n\nWritten by the 0.1.0 filesystem store.","author":"agent","authorId":"legacy-agent","createdAtMillis":1704067200000}],"createdAtMillis":1704067200000,"updatedAtMillis":1704067200000,"status":"ready"} \ No newline at end of file diff --git a/crates/tiny-artifacts/tests/fs_store.rs b/crates/tiny-artifacts/tests/fs_store.rs deleted file mode 100644 index da144dd..0000000 --- a/crates/tiny-artifacts/tests/fs_store.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! The ready-made file-system store. - -// Unwrapping is fine in tests (AGENTS.md); the workspace lints keep it out of -// library code. -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::path::PathBuf; - -use tiny_artifacts::{ArtifactKind, ArtifactRecord, ArtifactStore, FsArtifactStore}; - -fn dir() -> PathBuf { - std::env::temp_dir().join(format!("tiny-artifacts-fs-{}", uuid::Uuid::new_v4())) -} - -fn record(id: &str, updated: u64) -> ArtifactRecord { - let mut r = ArtifactRecord::new(id, id, ArtifactKind::Markdown, "# hi", "agent", 1); - r.updated_at_millis = updated; - r -} - -#[tokio::test] -async fn records_round_trip_and_list_newest_first() { - let root = dir().join("versioned"); - let store = FsArtifactStore::new(&root); - // The directory does not exist yet: nothing stored, no error. - assert!(store.list().await.unwrap().is_empty()); - assert!(store.get("a").await.unwrap().is_none()); - - store.upsert(&record("old", 1)).await.unwrap(); - store.upsert(&record("new", 2)).await.unwrap(); - let ids: Vec = store - .list() - .await - .unwrap() - .into_iter() - .map(|r| r.id) - .collect(); - assert_eq!(ids, ["new", "old"]); - assert_eq!(store.get("old").await.unwrap(), Some(record("old", 1))); - - // Replacing keeps one file per artifact and leaves no staging files. - store.upsert(&record("old", 3)).await.unwrap(); - let names: Vec = std::fs::read_dir(&root) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .collect(); - let mut names = names; - names.sort(); - assert_eq!(names, ["new.json", "old.json"]); - - assert!(store.delete("old").await.unwrap()); - assert!(!store.delete("old").await.unwrap()); - assert_eq!(store.list().await.unwrap().len(), 1); -} - -#[tokio::test] -async fn unsafe_ids_are_refused() { - let store = FsArtifactStore::new(dir()); - for id in ["", ".", "..", "../x", "a/b", "a\\b", ".hidden"] { - assert!(store.get(id).await.is_err(), "`{id}` must be refused"); - assert!( - store.upsert(&record(id, 1)).await.is_err(), - "`{id}` must be refused" - ); - } -} - -#[tokio::test] -async fn an_unreadable_record_is_skipped_by_list_but_reported_by_get() { - let root = dir(); - let store = FsArtifactStore::new(&root); - store.upsert(&record("good", 1)).await.unwrap(); - std::fs::write(root.join("bad.json"), b"{ not json").unwrap(); - std::fs::write(root.join("notes.txt"), b"ignored").unwrap(); - - let listed = store.list().await.unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, "good"); - assert!(store.get("bad").await.is_err()); -} - -#[tokio::test] -async fn records_written_by_earlier_hosts_still_load() { - // The on-disk shape OpenHuman wrote before this store moved here. - let root = dir(); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join("a-1.json"), - br##"{"id":"a-1","title":"Old","kind":"markdown","versions":[{"version":1,"body":"# old","author":"agent","authorId":"agent","createdAtMillis":1}],"createdAtMillis":1,"updatedAtMillis":1}"##, - ) - .unwrap(); - let record = FsArtifactStore::new(&root) - .get("a-1") - .await - .unwrap() - .unwrap(); - assert_eq!(record.title, "Old"); - assert_eq!(record.latest().unwrap().body, "# old"); -} - -#[tokio::test] -async fn io_failures_are_errors_with_the_artifact_named() { - let root = dir(); - std::fs::create_dir_all(&root).unwrap(); - - // The store's directory is a file: nothing can be listed or written. - let blocked = root.join("not-a-dir"); - std::fs::write(&blocked, b"x").unwrap(); - let store = FsArtifactStore::new(&blocked); - assert_eq!(store.dir(), blocked.as_path()); - assert!(store.list().await.is_err()); - assert!(store.upsert(&record("a", 1)).await.is_err()); - - // A directory where a record should be: unreadable, undeletable, and an - // atomic replace onto it fails without leaving its staging file behind. - let records = root.join("records"); - std::fs::create_dir_all(records.join("taken.json")).unwrap(); - std::fs::write(records.join("taken.json").join("inner"), b"x").unwrap(); - let store = FsArtifactStore::new(&records); - let err = store.get("taken").await.unwrap_err(); - assert!(format!("{err:#}").contains("taken"), "{err:#}"); - assert!(store.delete("taken").await.is_err()); - assert!(store.upsert(&record("taken", 1)).await.is_err()); - let leftovers: Vec<_> = std::fs::read_dir(&records) - .unwrap() - .map(|e| e.unwrap().file_name()) - .filter(|n| n.to_string_lossy().ends_with(".tmp")) - .collect(); - assert!(leftovers.is_empty(), "{leftovers:?}"); -} diff --git a/crates/tiny-artifacts/tests/service.rs b/crates/tiny-artifacts/tests/service.rs index 9b15f87..772973b 100644 --- a/crates/tiny-artifacts/tests/service.rs +++ b/crates/tiny-artifacts/tests/service.rs @@ -13,10 +13,12 @@ use serde_json::json; use tiny_artifacts::{ ArtifactAuthor, ArtifactEvent, ArtifactEventKind, ArtifactKind, ArtifactObserver, ArtifactPublisher, ArtifactRecord, ArtifactStatus, ArtifactStore, ArtifactVersion, Artifacts, - ArtifactsError, Author, Content, CreateArtifactTool, DeleteArtifactTool, FileRefError, - FileRoots, FileStore, FsFileStore, PublishOptions, PublishedLink, ShareArtifactTool, + ArtifactsError, Author, BlobFileStore, Content, CreateArtifactTool, DeleteArtifactTool, + FileRefError, FileRoots, FileStore, PublishOptions, PublishedLink, ShareArtifactTool, UpdateArtifactTool, }; +use tinystoragedrivers_testkit::memory_storage; +use tinystoragedrivers_types::Scope; use tinytools::Tool; #[derive(Default)] @@ -100,12 +102,17 @@ fn workdir() -> PathBuf { dir } +fn blob_file_store() -> Arc { + let storage = memory_storage(&Scope::new("artifact-service-test").unwrap()); + Arc::new(BlobFileStore::new(&storage)) +} + fn service(root: &Path) -> (Arc, Arc) { let recorder = Arc::new(Recorder::default()); let artifacts = Artifacts::new(Arc::new(MemStore::default())) .with_observer(recorder.clone()) .with_file_roots(FileRoots::new([root.to_path_buf()])) - .with_file_store(Arc::new(FsFileStore::new(workdir()))); + .with_file_store(blob_file_store()); (Arc::new(artifacts), recorder) } @@ -293,7 +300,7 @@ async fn a_slow_producer_opens_pending_then_completes_or_fails() { .unwrap(); assert_eq!(opened.record.status, ArtifactStatus::Pending); assert_eq!(opened.version, 0); - assert!(opened.record.versions.is_empty()); + assert_eq!(opened.record.versions.len(), 0); let done = artifacts .revise( @@ -466,7 +473,7 @@ async fn an_older_file_version_remains_shareable_after_its_source_changes() { let artifacts = Artifacts::new(Arc::new(MemStore::default())) .with_publisher(Arc::new(FakePublisher::default())) .with_file_roots(FileRoots::new([root.clone()])) - .with_file_store(Arc::new(FsFileStore::new(workdir()))); + .with_file_store(blob_file_store()); let id = artifacts .create( ArtifactKind::File, @@ -672,12 +679,12 @@ fn the_service_debug_view_shows_its_wiring_not_its_contents() { #[tokio::test] async fn the_file_store_keeps_each_version_after_the_source_changes() { let root = workdir(); - let kept = workdir(); + let file_store = blob_file_store(); std::fs::write(root.join("deck.pptx"), b"v1").unwrap(); let artifacts = Artifacts::new(Arc::new(MemStore::default())) .with_publisher(Arc::new(FakePublisher::default())) .with_file_roots(FileRoots::new([root.clone()])) - .with_version_files(&kept); + .with_file_store(file_store.clone()); let id = artifacts .create( ArtifactKind::File, @@ -704,7 +711,6 @@ async fn the_file_store_keeps_each_version_after_the_source_changes() { .record; let file_of = |n: u32| record.version(n).unwrap().file.clone().unwrap(); - let file_store = FsFileStore::new(&kept); assert_eq!(file_store.read(&file_of(1).key).await.unwrap(), b"v1"); assert_eq!( file_store.read(&file_of(2).key).await.unwrap(), @@ -727,11 +733,11 @@ async fn the_file_store_keeps_each_version_after_the_source_changes() { #[tokio::test] async fn deleting_an_artifact_removes_its_version_files() { let root = workdir(); - let kept = workdir(); + let file_store = blob_file_store(); std::fs::write(root.join("notes"), b"plain").unwrap(); let artifacts = Artifacts::new(Arc::new(MemStore::default())) .with_file_roots(FileRoots::new([root.clone()])) - .with_version_files(&kept); + .with_file_store(file_store.clone()); let saved = artifacts .create( ArtifactKind::File, @@ -742,17 +748,20 @@ async fn deleting_an_artifact_removes_its_version_files() { ) .await .unwrap(); - let id = saved.record.id; - // No extension: the copy is just the version number. - assert!(kept - .join("artifacts") - .join("files") - .join(&id) - .join("v1") - .is_file()); + let key = saved + .record + .latest() + .unwrap() + .file + .as_ref() + .unwrap() + .key + .clone(); + let id = saved.record.id.clone(); + assert!(file_store.metadata(&key).await.unwrap().is_some()); assert!(artifacts.delete(&id).await.unwrap().0); - assert!(!kept.join("artifacts").join("files").join(&id).exists()); + assert!(file_store.metadata(&key).await.unwrap().is_none()); // Deleting again finds nothing and touches nothing. assert!(!artifacts.delete(&id).await.unwrap().0); } @@ -760,11 +769,10 @@ async fn deleting_an_artifact_removes_its_version_files() { #[tokio::test] async fn a_version_file_needs_an_id_that_is_a_single_file_name() { let root = workdir(); - let kept = workdir(); std::fs::write(root.join("a.txt"), b"x").unwrap(); let artifacts = Artifacts::new(Arc::new(MemStore::default())) .with_file_roots(FileRoots::new([root.clone()])) - .with_version_files(&kept); + .with_file_store(blob_file_store()); let err = artifacts .create( ArtifactKind::File, @@ -776,13 +784,13 @@ async fn a_version_file_needs_an_id_that_is_a_single_file_name() { .await .unwrap_err(); assert!(matches!(err, ArtifactsError::Backend(_)), "{err}"); - assert!(!kept.parent().unwrap().join("escape").exists()); } #[tokio::test] async fn text_versions_are_not_copied() { - let kept = workdir(); - let artifacts = Artifacts::new(Arc::new(MemStore::default())).with_version_files(&kept); + let file_store = blob_file_store(); + let artifacts = + Artifacts::new(Arc::new(MemStore::default())).with_file_store(file_store.clone()); let saved = artifacts .create( ArtifactKind::Markdown, @@ -794,7 +802,11 @@ async fn text_versions_are_not_copied() { .await .unwrap(); assert!(saved.record.latest().unwrap().file.is_none()); - assert!(!kept.join(&saved.record.id).exists()); + assert!(file_store + .metadata(&format!("artifacts/files/{}/v1", saved.record.id)) + .await + .unwrap() + .is_none()); let shown = format!("{artifacts:?}"); assert!(shown.contains("file_store: true"), "{shown}"); } diff --git a/crates/tiny-artifacts/tests/shared_files.rs b/crates/tiny-artifacts/tests/shared_files.rs index bc564cc..240f5af 100644 --- a/crates/tiny-artifacts/tests/shared_files.rs +++ b/crates/tiny-artifacts/tests/shared_files.rs @@ -9,7 +9,9 @@ use tiny_artifacts::{ ArtifactKind, ArtifactPublisher, ArtifactRecord, ArtifactStore, ArtifactVersion, Artifacts, Author, BlobFileStore, Content, FileRoots, FileStore, PublishOptions, PublishedLink, }; -use tinystoragedrivers_core::{BlobStore, MemoryStorage, Scope, StorageBackend}; +use tinystoragedrivers_api::{BlobStore, StorageBackend}; +use tinystoragedrivers_testkit::memory_backend; +use tinystoragedrivers_types::Scope; #[derive(Clone, Default)] struct Records(Arc>>); @@ -75,8 +77,9 @@ async fn separate_services_read_share_and_delete_shared_file_versions() { let input = temp_dir(); std::fs::write(input.join("report.pdf"), b"version one").unwrap(); let records = Records::default(); - let backend = MemoryStorage::new(); - let first_storage = backend.for_scope(&Scope::local()).unwrap(); + let backend = memory_backend(); + let scope = Scope::new("artifact-shared-files").unwrap(); + let first_storage = backend.for_scope(&scope).unwrap(); let first_blobs = first_storage.blobs().clone(); let first_files = Arc::new(BlobFileStore::new(&first_storage)); let first = Artifacts::new(Arc::new(records.clone())) @@ -120,7 +123,7 @@ async fn separate_services_read_share_and_delete_shared_file_versions() { .unwrap(); std::fs::remove_dir_all(&input).unwrap(); - let second_storage = backend.for_scope(&Scope::local()).unwrap(); + let second_storage = backend.for_scope(&scope).unwrap(); let second_blobs = second_storage.blobs().clone(); let second_files = Arc::new(BlobFileStore::new(&second_storage)); let second = Artifacts::new(Arc::new(records.clone())) @@ -139,9 +142,12 @@ async fn separate_services_read_share_and_delete_shared_file_versions() { ); assert!(second.delete(&id).await.unwrap().0); - assert!(first_blobs - .list("artifacts/files/shared-report/") - .await - .unwrap() - .is_empty()); + assert_eq!( + first_blobs + .list("artifacts/files/shared-report/") + .await + .unwrap() + .len(), + 0 + ); }