use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use std::{ cell::RefCell, collections::BTreeSet, ffi::OsStr, fmt::Write as _, 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 repository context; source comments use the exact buffer's Git blob identity. 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 excerpt(&self) -> &str { let offset = |position: crate::plugin::TextPosition| { let line = self.content.split('\n').nth(position.row).unwrap_or(""); self.content .split('\n') .take(position.row) .map(|line| line.len() + 1) .sum::() + line .grapheme_indices(true) .nth(position.column) .map_or(line.len(), |(index, _)| index) }; &self.content[offset(self.start)..offset(self.end)] } /// Map an unchanged selection through edits without changing its saved snapshot. pub fn mapped_range( &self, revision: &str, path: &Path, side: Side, lines: &[String], eof: bool, ) -> Option> { use crate::plugin::TextPosition; if self.path != path || self.side != side || (self.revision != revision && !(self.revision.starts_with("working:") && revision.starts_with("working:"))) { return None; } if self.content.split('\n').eq(lines .iter() .map(String::as_str) .chain((eof || lines.is_empty()).then_some(""))) { return Some(self.start..self.end); } let mut content = lines.join("\n"); if eof && !lines.is_empty() { content.push('\n'); } // Split LF explicitly: editor columns count a retained CR separately. fn tokens(text: &str) -> Vec<&str> { text.split_inclusive('\n') .flat_map(|line| { line.strip_suffix('\n') .unwrap_or(line) .graphemes(true) .chain(line.ends_with('\n').then_some("\n")) }) .collect() } let before = tokens(&self.content); let after = tokens(&content); let index = |position: TextPosition| { self.content .split('\n') .take(position.row) .map(|line| line.graphemes(true).count() + 1) .sum::() + position.column }; let start = index(self.start); let end = index(self.end); if start == end { return None; } let deadline = std::time::Instant::now() + std::time::Duration::from_millis(10); let (mut old, mut new) = (0, 0); for op in similar::capture_diff_slices_deadline( similar::Algorithm::Myers, &before, &after, Some(deadline), ) { let (tag, old_range, new_range) = op.as_tag_tuple(); let old_end = old + old_range.len(); if tag == similar::DiffTag::Equal && start >= old && end <= old_end { // Use the unchanged context, not just potentially repeated selected text. let byte_start: usize = before[..old].iter().map(|text| text.len()).sum(); let byte_len: usize = before[old..old_end].iter().map(|text| text.len()).sum(); let context = &self.content[byte_start..byte_start + byte_len]; if self.content.match_indices(context).take(2).count() != 1 || content.match_indices(context).take(2).count() != 1 { return None; } let mapped = new + start - old; let position = |offset: usize| { let mut position = TextPosition { row: 0, column: 0 }; for grapheme in &after[..offset] { if *grapheme == "\n" { position.row += 1; position.column = 0; } else { position.column += 1; } } position }; return Some(position(mapped)..position(mapped + end - start)); } old = old_end; new += new_range.len(); } None } } #[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, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReviewedFile { pub revisions: [String; 2], pub path: PathBuf, pub old_path: Option, pub after: Option, } #[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, pub reviewed_files: 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: 4, root, summary: String::new(), verdict: None, comments: Vec::new(), reviewed_files: Vec::new(), storage_id: None, storage_revision: 0, saved: RefCell::new(None), } } pub fn summary_text(&self) -> String { let resolved = self .comments .iter() .filter(|comment| comment.resolved) .count(); format!( "Review ID: {}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n", self.storage_id .map_or_else(|| "null".into(), |id| id.to_string()), if self.summary.is_empty() { "" } else { ": " }, self.summary, match self.verdict { Some(Verdict::LooksGood) => "looks_good", Some(Verdict::ChangesRequested) => "changes_requested", None => "none", }, self.comments.len() - resolved, resolved, self.comments.len(), ) } /// CLI show format, including the repository, summary, and selected comments. pub fn format_text(&self, all: bool) -> String { let mut text = format!( "Repository: {}\n{}", self.root.display(), self.summary_text() ); for comment in self .comments .iter() .filter(|comment| all || !comment.resolved) { let anchor = &comment.anchor; writeln!( text, "\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}\n{}\n{}", comment.id, if comment.resolved { "resolved" } else { "unresolved" }, anchor.path.display(), match anchor.side { Side::Before => "before", Side::After => "after", }, anchor.start.row, anchor.start.column, anchor.end.row, anchor.end.column, anchor.revision, anchor.excerpt(), comment.body, ) .unwrap(); } text } fn validate(&self) -> Result<(), String> { if self.version != 4 { 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; validate_path(&anchor.path)?; 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()); } } let mut files = BTreeSet::new(); for file in &self.reviewed_files { validate_path(&file.path)?; if let Some(path) = &file.old_path { validate_path(path)?; } if file .revisions .iter() .any(|revision| revision.is_empty() || revision.chars().any(char::is_control)) { return Err("Reviewed files require immutable revision identities".into()); } if !files.insert((&file.revisions, &file.path)) { return Err("Reviewed files must be unique by revision pair and path".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 != 4 { 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(()) } } fn validate_path(path: &Path) -> Result<(), String> { crate::repository::safe_path(path)?; if path.to_str().is_none_or(|path| path.contains('\0')) { return Err("Review paths must be UTF-8 without NUL bytes".into()); } 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"; const REVIEWED_FILES_SCHEMA: &str = "CREATE TABLE reviewed_files ( review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, position INTEGER NOT NULL CHECK (position >= 0), before_revision TEXT NOT NULL CHECK (length(before_revision) > 0), after_revision TEXT NOT NULL CHECK (length(after_revision) > 0), path TEXT NOT NULL, old_path TEXT, after_content TEXT, PRIMARY KEY (review_id, before_revision, after_revision, path), UNIQUE (review_id, position) ) 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 != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_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 .execute_batch(REVIEWED_FILES_SCHEMA) .map_err(database_error)?; transaction .pragma_update(None, "user_version", 3) .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_else(|| format!( "Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.", root.display() ))?; 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")?; } for file in &review.reviewed_files { size = size .checked_add( file.revisions[0].len() + file.revisions[1].len() + file.path.as_os_str().len() + file .old_path .as_ref() .map_or(0, |path| path.as_os_str().len()) + file.after.as_ref().map_or(0, String::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 child 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 .execute("DELETE FROM reviewed_files WHERE review_id = ?1", [id]) .map_err(database_error)?; { let mut insert = transaction .prepare( "INSERT INTO reviewed_files (review_id, position, before_revision, after_revision, path, old_path, after_content) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .map_err(database_error)?; for (position, file) in review.reviewed_files.iter().enumerate() { insert .execute(params![ id, sql_index(position)?, file.revisions[0], file.revisions[1], file.path.to_str().unwrap(), file.old_path.as_ref().map(|path| path.to_str().unwrap()), file.after, ]) .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 stored_bytes: i64 = connection .query_row( "SELECT COALESCE(SUM(size), 0) FROM ( SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB)) + length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64 AS size FROM comments WHERE review_id = ?1 UNION ALL SELECT length(CAST(before_revision AS BLOB)) + length(CAST(after_revision AS BLOB)) + length(CAST(path AS BLOB)) + COALESCE(length(CAST(old_path AS BLOB)), 0) + COALESCE(length(CAST(after_content AS BLOB)), 0) + 64 FROM reviewed_files WHERE review_id = ?1)", [id], |row| row.get(0), ) .map_err(database_error)?; if stored_bytes < 0 || stored_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)?, }, }); } let mut statement = connection .prepare( "SELECT position, before_revision, after_revision, path, old_path, after_content FROM reviewed_files 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, 0)? != review.reviewed_files.len() { return Err("Invalid stored reviewed-file order".into()); } review.reviewed_files.push(ReviewedFile { revisions: [ row.get(1).map_err(database_error)?, row.get(2).map_err(database_error)?, ], path: PathBuf::from(row.get::<_, String>(3).map_err(database_error)?), old_path: row .get::<_, Option>(4) .map_err(database_error)? .map(PathBuf::from), after: row.get(5).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;