Files
vamp/src/review.rs
T

755 lines
28 KiB
Rust

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<usize> {
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<Verdict>,
pub comments: Vec<Comment>,
#[serde(skip)]
pub storage_id: Option<i64>,
#[serde(skip)]
pub storage_revision: i64,
#[serde(skip)]
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
}
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<PathBuf>,
pub selected: Option<u64>,
pub pending_navigation: Option<u64>,
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<Self, String> {
#[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<Self, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<Option<Self>, 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<String> = 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::<Result<_, _>>()
.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<Self, String> {
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<Vec<Review>, 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<Review, 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 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, String> {
i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into())
}
fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result<usize, String> {
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<Review, String> {
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<String>>(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::<u64>()
.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<PathBuf, String> {
// 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<Option<Vec<u8>>, 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;