584 lines
21 KiB
Rust
584 lines
21 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(&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<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 choices: Vec<(i64, String)>,
|
|
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())
|
|
}
|
|
|
|
// 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<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)?;
|
|
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 != 1 || schema != [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
|
|
.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<Vec<(i64, String)>, 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<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 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<Review, String> {
|
|
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<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;
|