diff --git a/Cargo.lock b/Cargo.lock index fa22b28..92ae105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,18 @@ dependencies = [ "num-traits", ] +[[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 = "fancy-regex" version = "0.11.0" @@ -539,6 +551,15 @@ 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 = "heck" version = "0.5.0" @@ -666,6 +687,17 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[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 = "line-clipping" version = "0.3.8" @@ -1009,6 +1041,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -1215,6 +1253,31 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1427,6 +1490,18 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[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 = "stacker" version = "0.1.25" @@ -1923,6 +1998,7 @@ dependencies = [ "crossterm", "ignore", "ratatui", + "rusqlite", "serde", "serde_json", "similar", @@ -1948,6 +2024,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index c56fc1a..36af145 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" crossterm = "0.29" ignore = "0.4" ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] } +rusqlite = { version = "0.40.2", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" similar = { version = "2", default-features = false } diff --git a/src/review.rs b/src/review.rs index 199e3b4..92f5227 100644 --- a/src/review.rs +++ b/src/review.rs @@ -1,7 +1,9 @@ +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use std::{ cell::RefCell, collections::BTreeSet, + ffi::OsStr, fs::{self, OpenOptions}, io::{Read, Write}, path::{Path, PathBuf}, @@ -96,6 +98,10 @@ pub struct Review { pub verdict: Option, pub comments: Vec, #[serde(skip)] + pub storage_id: Option, + #[serde(skip)] + pub storage_revision: i64, + #[serde(skip)] saved: RefCell>)>>, } @@ -127,6 +133,8 @@ impl Review { summary: String::new(), verdict: None, comments: Vec::new(), + storage_id: None, + storage_revision: 0, saved: RefCell::new(None), } } @@ -246,6 +254,244 @@ impl Review { } } +pub fn default_directory() -> Result { + directory_from( + std::env::var_os("XDG_DATA_HOME").as_deref(), + std::env::var_os("HOME").as_deref(), + ) +} + +fn directory_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result { + if let Some(path) = xdg.map(Path::new).filter(|path| path.is_absolute()) { + return Ok(path.join("vamp/reviews")); + } + home.map(Path::new) + .filter(|path| path.is_absolute()) + .map(|path| path.join(".local/share/vamp/reviews")) + .ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into()) +} + +// ponytail: whole-review JSON commits; normalize comment rows if large reviews make writes costly. +const SCHEMA: &str = "CREATE TABLE reviews ( + id INTEGER PRIMARY KEY CHECK (id > 0), + root TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + document TEXT NOT NULL CHECK (length(CAST(document AS BLOB)) <= 67108864) +) STRICT"; + +pub struct Store { + connection: Connection, +} + +impl Store { + pub fn open(directory: &Path) -> Result { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(directory).map_err(|error| { + format!( + "Cannot create review directory {}: {error}", + directory.display() + ) + })?; + let directory = fs::canonicalize(directory) + .map_err(|error| format!("Cannot resolve review directory: {error}"))?; + let path = directory.join("reviews.sqlite3"); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let created = match options.open(&path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(error) => return Err(format!("Cannot create review database: {error}")), + }; + let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("Review database must be a regular file, not a symbolic link".into()); + } + let mut connection = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW, + ) + .map_err(database_error)?; + connection + .busy_timeout(std::time::Duration::ZERO) + .map_err(database_error)?; + if created { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_error)?; + transaction.execute_batch(SCHEMA).map_err(database_error)?; + transaction + .pragma_update(None, "user_version", 1) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + } else { + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(database_error)?; + let schema: Vec = connection + .prepare( + "SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY name", + ) + .map_err(database_error)? + .query_map([], |row| row.get(0)) + .map_err(database_error)? + .collect::>() + .map_err(database_error)?; + if version != 1 || schema != [SCHEMA] { + return Err(format!( + "Unsupported review database schema (version {version}); existing data was not changed" + )); + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("Cannot secure review database: {error}"))?; + } + Ok(Self { connection }) + } + + pub fn list(&self, root: &Path) -> Result, String> { + let root = fs::canonicalize(root).map_err(|error| error.to_string())?; + let root_text = root + .to_str() + .ok_or("Review repository root must be UTF-8")?; + let mut statement = self + .connection + .prepare( + "SELECT id, root, revision, document FROM reviews WHERE root = ?1 ORDER BY id DESC", + ) + .map_err(database_error)?; + let mut rows = statement.query([root_text]).map_err(database_error)?; + let mut choices = Vec::new(); + while let Some(row) = rows.next().map_err(database_error)? { + let review = stored_review(row, &root)?; + let id = review.storage_id.unwrap(); + let label = review + .summary + .lines() + .find(|line| !line.trim().is_empty()) + .map_or_else(|| format!("Review {id}"), |line| line.trim().to_owned()); + choices.push((id, label)); + } + Ok(choices) + } + + pub fn load(&self, root: &Path, id: i64) -> Result { + let root = fs::canonicalize(root).map_err(|error| error.to_string())?; + let root_text = root + .to_str() + .ok_or("Review repository root must be UTF-8")?; + let mut statement = self + .connection + .prepare("SELECT id, root, revision, document FROM reviews WHERE id = ?1 AND root = ?2") + .map_err(database_error)?; + let mut rows = statement + .query(params![id, root_text]) + .map_err(database_error)?; + let row = rows + .next() + .map_err(database_error)? + .ok_or("Review does not exist in this repository")?; + stored_review(row, &root) + } + + pub fn save(&mut self, review: &mut Review) -> Result<(), String> { + review.validate()?; + let root = fs::canonicalize(&review.root).map_err(|error| error.to_string())?; + if root != review.root { + return Err("Review repository root must be canonical".into()); + } + let root = root + .to_str() + .ok_or("Review repository root must be UTF-8")?; + let revision = match review.storage_id { + Some(id) if id > 0 && review.storage_revision > 0 => review + .storage_revision + .checked_add(1) + .ok_or("Review storage revision is exhausted")?, + None if review.storage_revision == 0 => 1, + _ => return Err("Invalid review storage identity".into()), + }; + let bytes = serde_json::to_vec(review).map_err(|error| error.to_string())?; + if bytes.len() > LIMIT { + return Err("Review exceeds the 64 MiB limit".into()); + } + let document = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_error)?; + let id = if let Some(id) = review.storage_id { + let changed = transaction.execute( + "UPDATE reviews SET revision = ?1, document = ?2 WHERE id = ?3 AND root = ?4 AND revision = ?5", + params![revision, document, id, root, review.storage_revision], + ).map_err(database_error)?; + if changed != 1 { + return Err( + "Review changed in another session; draft kept, reload before saving".into(), + ); + } + id + } else { + transaction + .execute( + "INSERT INTO reviews (root, revision, document) VALUES (?1, ?2, ?3)", + params![root, revision, document], + ) + .map_err(database_error)?; + transaction.last_insert_rowid() + }; + transaction.commit().map_err(database_error)?; + review.storage_id = Some(id); + review.storage_revision = revision; + Ok(()) + } +} + +fn database_error(error: rusqlite::Error) -> String { + format!("Review database: {error}") +} + +fn stored_review(row: &rusqlite::Row<'_>, root: &Path) -> Result { + let id: i64 = row.get(0).map_err(database_error)?; + let stored_root: String = row.get(1).map_err(database_error)?; + let revision: i64 = row.get(2).map_err(database_error)?; + if id <= 0 || revision <= 0 || Path::new(&stored_root) != root { + return Err("Invalid review database identity".into()); + } + let document = row.get_ref(3).map_err(database_error)?; + let bytes = document + .as_str() + .map_err(|error| format!("Invalid stored review: {error}"))? + .as_bytes(); + if bytes.len() > LIMIT { + return Err("Review exceeds the 64 MiB limit".into()); + } + let mut review: Review = serde_json::from_slice(bytes) + .map_err(|error| format!("Invalid stored review JSON: {error}"))?; + review.validate()?; + if review.root != root { + return Err("Stored review belongs to a different repository".into()); + } + review.storage_id = Some(id); + review.storage_revision = revision; + Ok(review) +} + fn identity(path: &Path) -> Result { // Resolve the parent only: never silently follow a target symlink. let parent = path @@ -311,6 +557,285 @@ mod tests { directory } + #[test] + fn database_restarts_preserve_identity_and_exact_stale_anchors() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("storage"); + let mut review = Review::new(root.clone()); + review.summary = "Unicode review\nMore context".into(); + review.verdict = Some(Verdict::ChangesRequested); + review.comments.push(Comment { + id: 7, + anchor: Anchor { + revision: "working:old-snapshot".into(), + path: "日本語.rs".into(), + side: Side::Before, + start: crate::plugin::TextPosition { row: 0, column: 1 }, + end: crate::plugin::TextPosition { row: 1, column: 1 }, + content: "a👩‍🔬e\u{301}\r\n界x\n".into(), + }, + body: "Please preserve 👩‍🔬".into(), + }); + { + let mut store = Store::open(&storage).unwrap(); + store.save(&mut review).unwrap(); + assert!(review.storage_id.is_some()); + assert_eq!(review.storage_revision, 1); + } + let mut store = Store::open(&storage).unwrap(); + let id = review.storage_id.unwrap(); + assert_eq!( + store.list(&root).unwrap(), + vec![(id, "Unicode review".into())] + ); + let mut loaded = store.load(&root, id).unwrap(); + assert_eq!(loaded.storage_id, Some(id)); + assert_eq!(loaded.storage_revision, 1); + assert_eq!(loaded.comments, review.comments); + assert_eq!(loaded.verdict, review.verdict); + assert!(!loaded.comments[0].anchor.matches( + "working:new-snapshot", + Path::new("日本語.rs"), + Side::Before, + "changed\n", + )); + let path = root.join("export.json"); + loaded.save(&path).unwrap(); + let exported = fs::read(&path).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); + assert_eq!(json["version"], 2); + assert!(json.get("storage_id").is_none()); + assert!(json.get("storage_revision").is_none()); + loaded.summary = "Database only".into(); + store.save(&mut loaded).unwrap(); + assert_eq!(loaded.storage_id, Some(id)); + assert_eq!(loaded.storage_revision, 2); + assert_eq!(fs::read(&path).unwrap(), exported); + let mut imported = Review::load(&path, &root).unwrap(); + assert_eq!(imported.storage_id, None); + assert_eq!(imported.storage_revision, 0); + store.save(&mut imported).unwrap(); + assert_ne!(imported.storage_id, Some(id)); + assert_eq!(store.list(&root).unwrap().len(), 2); + } + + #[test] + fn database_repository_identity_is_canonical_and_independent() { + let first = directory(); + let second = directory(); + let first_root = first.0.canonicalize().unwrap(); + let second_root = second.0.canonicalize().unwrap(); + let mut store = Store::open(&first_root.join("storage")).unwrap(); + let mut a = Review::new(first_root.clone()); + let mut b = Review::new(second_root.clone()); + store.save(&mut a).unwrap(); + store.save(&mut b).unwrap(); + assert_ne!(a.storage_id, b.storage_id); + assert_eq!(store.list(&first_root).unwrap().len(), 1); + assert_eq!(store.list(&second_root).unwrap().len(), 1); + assert!(store.load(&second_root, a.storage_id.unwrap()).is_err()); + let id = a.storage_id; + let revision = a.storage_revision; + a.root = second_root; + assert!(store.save(&mut a).is_err()); + assert_eq!((a.storage_id, a.storage_revision), (id, revision)); + #[cfg(unix)] + { + let alias = first_root.join("alias"); + std::os::unix::fs::symlink(&first_root, &alias).unwrap(); + assert_eq!( + store.list(&alias).unwrap(), + store.list(&first_root).unwrap() + ); + } + } + + #[test] + fn database_conflicts_and_locked_writes_keep_drafts_and_identity() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("storage"); + let mut first = Store::open(&storage).unwrap(); + let mut second = Store::open(&storage).unwrap(); + let mut review = Review::new(root.clone()); + first.save(&mut review).unwrap(); + let id = review.storage_id.unwrap(); + let mut draft = second.load(&root, id).unwrap(); + review.summary = "First writer".into(); + first.save(&mut review).unwrap(); + draft.summary = "Second writer draft".into(); + assert!( + second + .save(&mut draft) + .unwrap_err() + .contains("another session") + ); + assert_eq!(draft.summary, "Second writer draft"); + assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1)); + assert_eq!(second.load(&root, id).unwrap().summary, "First writer"); + first.connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + let mut fresh = Review::new(root.clone()); + assert!(second.save(&mut fresh).unwrap_err().contains("locked")); + assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); + first.connection.execute_batch("ROLLBACK").unwrap(); + second.save(&mut fresh).unwrap(); + assert!(fresh.storage_id.is_some()); + } + + #[test] + fn incompatible_database_schemas_are_preserved() { + for schema in [ + "CREATE TABLE other (value TEXT); PRAGMA user_version = 42", + "CREATE TABLE other (value TEXT); PRAGMA user_version = 1", + "", + ] { + let directory = directory(); + let path = directory.0.join("reviews.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection.execute_batch(schema).unwrap(); + drop(connection); + let original = fs::read(&path).unwrap(); + assert!(Store::open(&directory.0).err().unwrap().contains("schema")); + assert_eq!(fs::read(&path).unwrap(), original); + } + } + + #[test] + fn malformed_or_mismatched_database_documents_are_rejected() { + let other = directory(); + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let mut store = Store::open(&root.join("storage")).unwrap(); + let mut review = Review::new(root.clone()); + store.save(&mut review).unwrap(); + let id = review.storage_id.unwrap(); + let mut wrong_root = serde_json::to_value(&review).unwrap(); + wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap()); + let mut invalid_version = serde_json::to_value(&review).unwrap(); + invalid_version["version"] = 1.into(); + let mut inherited_identity = serde_json::to_value(&review).unwrap(); + inherited_identity["storage_id"] = id.into(); + for document in [ + "{".to_owned(), + serde_json::to_string(&wrong_root).unwrap(), + serde_json::to_string(&invalid_version).unwrap(), + serde_json::to_string(&inherited_identity).unwrap(), + ] { + store + .connection + .execute( + "UPDATE reviews SET document = ?1 WHERE id = ?2", + params![document, id], + ) + .unwrap(); + assert!(store.load(&root, id).is_err()); + assert!(store.list(&root).is_err()); + } + store + .connection + .execute_batch("PRAGMA ignore_check_constraints = ON") + .unwrap(); + store + .connection + .execute( + "UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2", + params![LIMIT as i64 + 1, id], + ) + .unwrap(); + assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); + assert!(store.list(&root).unwrap_err().contains("64 MiB")); + } + + #[test] + fn failed_database_and_export_writes_preserve_metadata_and_documents() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let mut store = Store::open(&root.join("storage")).unwrap(); + let mut review = Review::new(root.clone()); + store.save(&mut review).unwrap(); + let identity = (review.storage_id, review.storage_revision); + store + .connection + .execute_batch("PRAGMA query_only = ON") + .unwrap(); + review.summary = "Unsaved draft".into(); + assert!(store.save(&mut review).is_err()); + assert_eq!((review.storage_id, review.storage_revision), identity); + assert_eq!(review.summary, "Unsaved draft"); + assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); + let mut fresh = Review::new(root.clone()); + assert!(store.save(&mut fresh).is_err()); + assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); + store + .connection + .execute_batch("PRAGMA query_only = OFF") + .unwrap(); + review.summary = "x".repeat(LIMIT); + assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); + assert!( + review + .save(&root.join("oversized.json")) + .unwrap_err() + .contains("64 MiB") + ); + assert!(!root.join("oversized.json").exists()); + assert_eq!((review.storage_id, review.storage_revision), identity); + assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); + let blocked = root.join("not-a-directory"); + fs::write(&blocked, b"preserve").unwrap(); + assert!(Store::open(&blocked).is_err()); + assert_eq!(fs::read(&blocked).unwrap(), b"preserve"); + } + + #[cfg(unix)] + #[test] + fn database_files_are_private_and_target_symlinks_are_refused() { + use std::os::unix::fs::PermissionsExt; + let directory = directory(); + let storage = directory.0.join("storage"); + drop(Store::open(&storage).unwrap()); + let path = storage.join("reviews.sqlite3"); + assert_eq!( + fs::metadata(&storage).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let original = fs::read(&path).unwrap(); + let links = directory.0.join("links"); + fs::create_dir(&links).unwrap(); + std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); + assert!(Store::open(&links).err().unwrap().contains("symbolic link")); + assert_eq!(fs::read(&path).unwrap(), original); + } + + #[test] + fn default_directory_uses_absolute_xdg_then_absolute_home() { + let resolve = |xdg: Option<&str>, home: Option<&str>| { + directory_from(xdg.map(OsStr::new), home.map(OsStr::new)) + }; + assert_eq!( + resolve(Some("/data"), Some("/home/me")).unwrap(), + Path::new("/data/vamp/reviews") + ); + for xdg in [None, Some("relative"), Some("")] { + assert_eq!( + resolve(xdg, Some("/home/me")).unwrap(), + Path::new("/home/me/.local/share/vamp/reviews") + ); + } + assert_eq!( + resolve(Some("/data"), None).unwrap(), + Path::new("/data/vamp/reviews") + ); + assert!(resolve(None, None).is_err()); + assert!(resolve(None, Some("relative")).is_err()); + assert!(resolve(None, Some("")).is_err()); + } + #[test] fn persistence_validates_anchors_and_preserves_files_on_failure() { let directory = directory();