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_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 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()) } const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews ( id INTEGER PRIMARY KEY CHECK (id > 0), root TEXT NOT NULL, revision INTEGER NOT NULL CHECK (revision > 0), summary TEXT NOT NULL, verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested')) ) STRICT"; // SQLite integers are signed; decimal text preserves the model's full u64 comment IDs. const COMMENTS_SCHEMA: &str = "CREATE TABLE comments ( review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, id TEXT NOT NULL CHECK ( length(id) BETWEEN 1 AND 20 AND id NOT GLOB '*[^0-9]*' AND substr(id, 1, 1) BETWEEN '1' AND '9' AND (length(id) < 20 OR id <= '18446744073709551615') ), position INTEGER NOT NULL CHECK (position >= 0), body TEXT NOT NULL, resolved INTEGER NOT NULL CHECK (resolved IN (0, 1)), anchor_revision TEXT NOT NULL CHECK (length(anchor_revision) > 0), path TEXT NOT NULL, side TEXT NOT NULL CHECK (side IN ('before', 'after')), start_row INTEGER NOT NULL CHECK (start_row >= 0), start_column INTEGER NOT NULL CHECK (start_column >= 0), end_row INTEGER NOT NULL CHECK (end_row >= 0), end_column INTEGER NOT NULL CHECK (end_column >= 0), content TEXT NOT NULL, PRIMARY KEY (review_id, id), UNIQUE (review_id, position), CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column)) ) 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)?; connection .pragma_update(None, "foreign_keys", true) .map_err(database_error)?; 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 != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_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 .pragma_update(None, "foreign_keys", true) .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(REVIEWS_SCHEMA) .map_err(database_error)?; transaction .execute_batch(COMMENTS_SCHEMA) .map_err(database_error)?; transaction .pragma_update(None, "user_version", 2) .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 transaction = self .connection .unchecked_transaction() .map_err(database_error)?; let reviews = { let mut statement = transaction .prepare( "SELECT id, root, revision, summary, verdict 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 reviews = Vec::new(); while let Some(row) = rows.next().map_err(database_error)? { reviews.push(stored_review(&transaction, row, &root)?); } reviews }; transaction.commit().map_err(database_error)?; Ok(reviews) } 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 transaction = self .connection .unchecked_transaction() .map_err(database_error)?; let review = { let mut statement = transaction .prepare("SELECT id, root, revision, summary, verdict 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(&transaction, row, &root)? }; transaction.commit().map_err(database_error)?; Ok(review) } 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 mut size = review.root.as_os_str().len() + review.summary.len(); for comment in &review.comments { size = size .checked_add( comment.body.len() + comment.anchor.revision.len() + comment.anchor.path.as_os_str().len() + comment.anchor.content.len() + 64, ) .ok_or("Review exceeds the 64 MiB limit")?; } if size > LIMIT { return Err("Review exceeds the 64 MiB limit".into()); } let verdict = match review.verdict { Some(Verdict::LooksGood) => Some("looks_good"), Some(Verdict::ChangesRequested) => Some("changes_requested"), None => None, }; 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, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6", params![revision, review.summary, verdict, 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, summary, verdict) VALUES (?1, ?2, ?3, ?4)", params![root, revision, review.summary, verdict], ) .map_err(database_error)?; transaction.last_insert_rowid() }; // ponytail: replace comment rows per save; diff rows if snapshot writes become costly. transaction .execute("DELETE FROM comments WHERE review_id = ?1", [id]) .map_err(database_error)?; { let mut insert = transaction.prepare( "INSERT INTO comments (review_id, id, position, body, resolved, anchor_revision, path, side, start_row, start_column, end_row, end_column, content) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", ).map_err(database_error)?; for (position, comment) in review.comments.iter().enumerate() { let anchor = &comment.anchor; insert .execute(params![ id, comment.id.to_string(), sql_index(position)?, comment.body, comment.resolved, anchor.revision, anchor.path.to_str().unwrap(), match anchor.side { Side::Before => "before", Side::After => "after", }, sql_index(anchor.start.row)?, sql_index(anchor.start.column)?, sql_index(anchor.end.row)?, sql_index(anchor.end.column)?, anchor.content, ]) .map_err(database_error)?; } } 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 sql_index(value: usize) -> Result { i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into()) } fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result { usize::try_from(row.get::<_, i64>(index).map_err(database_error)?) .map_err(|_| "Stored comment coordinate exceeds this platform's range".into()) } fn stored_review( connection: &Connection, row: &rusqlite::Row<'_>, root: &Path, ) -> Result { let id: i64 = row.get(0).map_err(database_error)?; let stored_root = row .get_ref(1) .map_err(database_error)? .as_str() .map_err(|error| error.to_string())?; 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 summary = row .get_ref(3) .map_err(database_error)? .as_str() .map_err(|error| error.to_string())?; let comment_bytes: i64 = connection .query_row( "SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB)) + length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0) FROM comments WHERE review_id = ?1", [id], |row| row.get(0), ) .map_err(database_error)?; if comment_bytes < 0 || comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64 { return Err("Review exceeds the 64 MiB limit".into()); } let mut review = Review { root: root.to_owned(), ..Review::default() }; review.summary = summary.to_owned(); review.verdict = match row .get::<_, Option>(4) .map_err(database_error)? .as_deref() { Some("looks_good") => Some(Verdict::LooksGood), Some("changes_requested") => Some(Verdict::ChangesRequested), None => None, _ => return Err("Invalid stored review verdict".into()), }; let mut statement = connection .prepare( "SELECT id, position, body, resolved, anchor_revision, path, side, start_row, start_column, end_row, end_column, content FROM comments WHERE review_id = ?1 ORDER BY position", ) .map_err(database_error)?; let mut rows = statement.query([id]).map_err(database_error)?; while let Some(row) = rows.next().map_err(database_error)? { if stored_index(row, 1)? != review.comments.len() { return Err("Invalid stored comment order".into()); } let side = match row.get::<_, String>(6).map_err(database_error)?.as_str() { "before" => Side::Before, "after" => Side::After, _ => return Err("Invalid stored comment side".into()), }; let resolved = match row.get::<_, i64>(3).map_err(database_error)? { 0 => false, 1 => true, _ => return Err("Invalid stored comment resolution".into()), }; review.comments.push(Comment { id: row .get::<_, String>(0) .map_err(database_error)? .parse::() .map_err(|_| "Invalid stored comment ID")?, body: row.get(2).map_err(database_error)?, resolved, anchor: Anchor { revision: row.get(4).map_err(database_error)?, path: PathBuf::from(row.get::<_, String>(5).map_err(database_error)?), side, start: crate::plugin::TextPosition { row: stored_index(row, 7)?, column: stored_index(row, 8)?, }, end: crate::plugin::TextPosition { row: stored_index(row, 9)?, column: stored_index(row, 10)?, }, content: row.get(11).map_err(database_error)?, }, }); } review.validate()?; 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;