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}, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, }; use unicode_segmentation::UnicodeSegmentation; const LIMIT: usize = 64 * 1024 * 1024; static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Side { Before, After, } impl Side { pub fn index(self) -> usize { match self { Self::Before => 0, Self::After => 1, } } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Verdict { LooksGood, ChangesRequested, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Anchor { /// Frozen comparison context; `content` additionally captures any live buffer edits. pub revision: String, pub path: PathBuf, pub side: Side, /// Zero-based extended-grapheme bounds; `end` is exclusive. pub start: crate::plugin::TextPosition, pub end: crate::plugin::TextPosition, /// Exact whole-side text, including its final newline when present. // ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters. pub content: String, } impl Anchor { pub fn rows(&self) -> std::ops::RangeInclusive { let end = self.end.row - usize::from(self.end.column == 0 && self.end.row > self.start.row); self.start.row..=end } pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool { self.revision == revision && self.path == path && self.side == side && self.content == text } pub fn matches_lines( &self, revision: &str, path: &Path, side: Side, lines: &[String], eof: bool, ) -> bool { self.revision == revision && self.path == path && self.side == side && self.content.split('\n').eq(lines .iter() .map(String::as_str) .chain((eof || lines.is_empty()).then_some(""))) } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Comment { pub id: u64, pub anchor: Anchor, pub body: String, pub resolved: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Review { pub version: u32, pub root: PathBuf, pub summary: String, pub verdict: Option, pub comments: Vec, #[serde(skip)] pub storage_id: Option, #[serde(skip)] pub storage_revision: i64, #[serde(skip)] saved: RefCell>)>>, } impl Default for Review { fn default() -> Self { Self::new(PathBuf::new()) } } #[derive(Clone, Debug, Default)] pub struct ReviewState { pub document: Review, pub initialized: bool, pub choices: Vec<(i64, String)>, pub path: Option, pub selected: Option, pub pending_navigation: Option, pub dirty: bool, } impl Review { pub fn new(root: PathBuf) -> Self { let root = if root.as_os_str().is_empty() { root } else { fs::canonicalize(&root).unwrap_or(root) }; Self { version: 3, root, summary: String::new(), verdict: None, comments: Vec::new(), storage_id: None, storage_revision: 0, saved: RefCell::new(None), } } fn validate(&self) -> Result<(), String> { if self.version != 3 { return Err(format!("Unsupported review version {}", self.version)); } if !self.root.is_absolute() || !self.root.is_dir() { return Err("Review repository root must be an existing absolute directory".into()); } let mut ids = BTreeSet::new(); for comment in &self.comments { if comment.id == 0 || !ids.insert(comment.id) { return Err("Review comment IDs must be unique and nonzero".into()); } let anchor = &comment.anchor; crate::repository::safe_path(&anchor.path)?; if anchor.path.to_str().is_none_or(|path| path.contains('\0')) { return Err("Review paths must be UTF-8 without NUL bytes".into()); } if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) { return Err("Review anchors require an immutable revision identity".into()); } if anchor.start > anchor.end { return Err("Review ranges must be ordered and end-exclusive".into()); } let mut end_exists = false; for (row, line) in anchor.content.split('\n').enumerate() { if row == anchor.start.row || row == anchor.end.row { let columns = line.graphemes(true).count(); if (row == anchor.start.row && anchor.start.column > columns) || (row == anchor.end.row && anchor.end.column > columns) { return Err( "Review columns must be within the anchor's grapheme text".into() ); } } if row == anchor.end.row { end_exists = true; break; } } if !end_exists { return Err("Review rows must be within the anchor text".into()); } } Ok(()) } fn from_json(bytes: &[u8]) -> Result { #[derive(Deserialize)] struct Header { version: u32, } let header: Header = serde_json::from_slice(bytes) .map_err(|error| format!("Invalid review JSON: {error}"))?; if header.version != 3 { return Err(format!("Unsupported review version {}", header.version)); } serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}")) } pub fn load(path: &Path, root: &Path) -> Result { let bytes = read_file(path)?.ok_or("Review file does not exist")?; let mut review = Self::from_json(&bytes)?; review.validate()?; let expected = fs::canonicalize(root).map_err(|error| error.to_string())?; let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?; if actual != expected { return Err("Review belongs to a different repository".into()); } review.root = actual; *review.saved.borrow_mut() = Some((identity(path)?, Arc::new(bytes))); Ok(review) } pub fn save(&self, path: &Path) -> Result<(), String> { self.validate()?; let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?; if bytes.len() > LIMIT { return Err("Review exceeds the 64 MiB limit".into()); } let path = identity(path)?; let saved = self.saved.borrow(); let expected = saved .as_ref() .filter(|(previous, _)| previous == &path) .map(|(_, bytes)| bytes.as_slice()); check_file(&path, expected)?; let parent = path.parent().ok_or("Review path has no parent")?; let (temporary, mut file) = loop { let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); let temporary = parent.join(format!(".vamp-review-{}-{id}.tmp", std::process::id())); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } match options.open(&temporary) { Ok(file) => break (temporary, file), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error.to_string()), } }; let result: Result<(), String> = (|| { file.write_all(&bytes) .and_then(|_| file.sync_all()) .map_err(|error| error.to_string())?; drop(file); check_file(&path, expected)?; if expected.is_some() { // ponytail: optimistic check like editor saves; OS compare-and-swap for hostile writers. fs::rename(&temporary, &path).map_err(|error| error.to_string())?; } else { // Publishing a new file must not replace a concurrently created target. fs::hard_link(&temporary, &path).map_err(|error| error.to_string())?; } Ok(()) })(); let _ = fs::remove_file(&temporary); result?; drop(saved); *self.saved.borrow_mut() = Some((path, Arc::new(bytes))); Ok(()) } } 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 { /// Open existing storage without creating files, changing permissions, or migrating schemas. pub fn open_read_only(directory: &Path) -> Result, String> { let path = directory.join("reviews.sqlite3"); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(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 path = fs::canonicalize(directory) .map_err(|error| format!("Cannot resolve review directory: {error}"))? .join("reviews.sqlite3"); let connection = Connection::open_with_flags( &path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_NOFOLLOW, ) .map_err(database_error)?; Self::check_schema(&connection)?; Ok(Some(Self { connection })) } fn check_schema(connection: &Connection) -> Result<(), String> { 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" )); } Ok(()) } 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 { Self::check_schema(&connection)?; } #[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::from_json(bytes)?; 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 .parent() .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let name = path.file_name().ok_or("Review path must name a file")?; Ok(fs::canonicalize(parent) .map_err(|error| error.to_string())? .join(name)) } fn read_file(path: &Path) -> Result>, String> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.to_string()), }; if !metadata.is_file() || metadata.file_type().is_symlink() { return Err("Review file must be a regular file, not a symbolic link".into()); } if metadata.len() > LIMIT as u64 { return Err("Review exceeds the 64 MiB limit".into()); } let mut bytes = Vec::new(); fs::File::open(path) .and_then(|file| file.take(LIMIT as u64 + 1).read_to_end(&mut bytes)) .map_err(|error| error.to_string())?; if bytes.len() > LIMIT { return Err("Review exceeds the 64 MiB limit".into()); } Ok(Some(bytes)) } fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> { let actual = read_file(path)?; if actual.as_deref() != expected { return Err( "Review file changed outside vamp or already exists; open it before saving".into(), ); } Ok(()) } #[cfg(test)] #[path = "../tests/unit/review.rs"] mod tests;