use super::*;
use crate::persistence::{CrossProcessFileLock, atomic_write_with_permissions};
use sha2::{Digest, Sha256};
use std::{fs, io::Read, time::Duration};
const MAX_STORE: usize = 1024 * 1024;
const MAX_COMMENTS: usize = 256;
const MAX_COMMENT_TEXT: usize = 8192;
#[derive(Clone, Serialize, Deserialize)]
struct StoredComment {
comment: ReviewComment,
before: Option<String>,
after: Option<String>,
}
fn store_path(state: &Path, root: &Path) -> PathBuf {
let hash = Sha256::digest(root.as_os_str().as_encoded_bytes());
let key: String = hash.iter().map(|byte| format!("{byte:02x}")).collect();
state.join("diff-review").join(format!("{key}.json"))
}
fn check_regular(path: &Path) -> Result<bool, String> {
validate_parent(path)?;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true),
Ok(_) => Err("Review store must be a regular file, not a symlink".into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.to_string()),
}
}
fn validate_parent(path: &Path) -> Result<(), String> {
let parent = path.parent().ok_or("Review store has no parent")?;
for ancestor in parent.ancestors() {
match fs::symlink_metadata(ancestor) {
Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
return Err("Review storage directory must not be a symlink".into());
}
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.to_string()),
_ => {}
}
}
Ok(())
}
fn prepare_parent(path: &Path) -> Result<(), String> {
validate_parent(path)?;
let parent = path.parent().ok_or("Review store has no parent")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn read(path: &Path) -> Result<Vec<StoredComment>, String> {
if !check_regular(path)? {
return Ok(Vec::new());
}
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options.open(path).map_err(|e| e.to_string())?;
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("Review store is not a regular file".into());
}
let mut bytes = Vec::new();
file.take(MAX_STORE as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > MAX_STORE {
return Err("Review comment store exceeds size limit".into());
}
let comments: Vec<StoredComment> =
serde_json::from_slice(&bytes).map_err(|e| format!("Cannot read review comments: {e}"))?;
if comments.len() > MAX_COMMENTS {
return Err("Too many stored review comments".into());
}
for stored in &comments {
git::validate_path(&stored.comment.path)?;
}
Ok(comments)
}
fn write(path: &Path, comments: &[StoredComment]) -> Result<(), String> {
check_regular(path)?;
let bytes = serde_json::to_vec(comments).map_err(|e| e.to_string())?;
if bytes.len() > MAX_STORE {
return Err("Review comment store exceeds size limit".into());
}
atomic_write_with_permissions(path, &bytes, Some(0o600)).map_err(|e| e.to_string())
}
fn lock(path: &Path, options: &ReviewReadOptions<'_>) -> Result<CrossProcessFileLock, String> {
loop {
options.check()?;
if let Some(lock) = CrossProcessFileLock::try_acquire(path).map_err(|e| e.to_string())? {
return Ok(lock);
}
std::thread::sleep(options.remaining()?.min(Duration::from_millis(10)));
}
}
pub(super) fn load_reanchored(
state: &Path,
root: &Path,
files: &mut Vec<ReviewFile>,
options: &ReviewReadOptions<'_>,
) -> Result<Vec<ReviewComment>, String> {
options.cancellation.check().map_err(|e| e.to_string())?;
let path = store_path(state, root);
if !check_regular(&path)? {
return Ok(Vec::new());
}
let _lock = match prepare_parent(&path).and_then(|()| lock(&path, options)) {
Ok(lock) => lock,
Err(error) => {
options.cancellation.check().map_err(|e| e.to_string())?;
let mut comments = read(&path)?;
for stored in &mut comments {
stored.comment.stale = true;
}
files.push(git::notice_file(
"[saved comments]",
format!("Using saved anchors: {error}"),
));
return Ok(comments.into_iter().map(|s| s.comment).collect());
}
};
let mut comments = read(&path)?;
let mut dirty = false;
let mut budget = MAX_TOTAL_SOURCE.saturating_sub(
files
.iter()
.map(|f| f.original.len() + f.current.len())
.sum(),
);
for stored in &mut comments {
options.cancellation.check().map_err(|e| e.to_string())?;
if let Err(error) = options.check() {
stored.comment.stale = true;
if !files.iter().any(|f| f.path == stored.comment.path) {
files.push(git::notice_file(&stored.comment.path, error));
}
continue;
}
if !files.iter().any(|f| f.path == stored.comment.path) {
let mut file = git::read_file(
root,
&stored.comment.path,
true,
(budget / 2).min(MAX_SOURCE),
options,
);
budget = budget.saturating_sub(file.original.len() + file.current.len());
if file.original.is_empty() && file.current.is_empty() && file.notice.is_none() {
file.notice = Some("Commented file is no longer present".into());
}
files.push(file);
}
if let Some(file) = files.iter().find(|f| f.path == stored.comment.path) {
dirty |= reanchor(stored, file);
}
}
options.cancellation.check().map_err(|e| e.to_string())?;
if dirty
&& options.check().is_ok()
&& let Err(error) = write(&path, &comments)
{
files.push(git::notice_file(
"[saved comments]",
format!("Could not persist updated locations: {error}"),
));
}
Ok(comments.into_iter().map(|s| s.comment).collect())
}
fn reanchor(stored: &mut StoredComment, file: &ReviewFile) -> bool {
if file.notice.is_some() {
let changed = !stored.comment.stale;
stored.comment.stale = true;
return changed;
}
let lines: Vec<_> = source(file, stored.comment.side).lines().collect();
let previous = (stored.comment.line, stored.comment.stale);
let matches: Vec<_> = lines
.iter()
.enumerate()
.filter(|(_, line)| **line == stored.comment.anchor)
.map(|(index, _)| index)
.collect();
let contextual: Vec<_> = matches
.iter()
.copied()
.filter(|&i| {
stored.before.as_deref() == i.checked_sub(1).and_then(|n| lines.get(n).copied())
&& stored.after.as_deref() == lines.get(i + 1).copied()
})
.collect();
let candidates = if contextual.is_empty() {
&matches
} else {
&contextual
};
let nearest = candidates
.iter()
.min_by_key(|&&index| (index + 1).abs_diff(stored.comment.line))
.copied();
match nearest {
Some(index) => {
stored.comment.line = index + 1;
stored.comment.stale |= candidates.len() != 1 || contextual.is_empty();
}
None => {
stored.comment.line = stored.comment.line.max(1).min(lines.len().max(1));
stored.comment.stale = true;
}
}
previous != (stored.comment.line, stored.comment.stale)
}
pub(super) fn save(
state: &Path,
root: &Path,
change: CommentChange,
options: &ReviewReadOptions<'_>,
) -> Result<(), String> {
options.check()?;
let path = store_path(state, root);
prepare_parent(&path)?;
let _lock = lock(&path, options)?;
let mut comments = read(&path)?;
match change {
CommentChange::Delete(id) => {
comments.retain(|c| c.comment.id != id);
}
CommentChange::Resolve { id, resolved } => {
comments
.iter_mut()
.find(|c| c.comment.id == id)
.ok_or("Review comment no longer exists")?
.comment
.resolved = resolved;
}
CommentChange::Save {
id,
path,
side,
line,
text,
anchor,
} => {
if text.trim().is_empty() || text.len() > MAX_COMMENT_TEXT {
return Err("Comment must contain 1–8192 bytes of text".into());
}
git::validate_path(&path)?;
if let Some(id) = id {
comments
.iter_mut()
.find(|c| c.comment.id == id)
.ok_or("Review comment no longer exists")?
.comment
.text = text;
} else {
if comments.len() >= MAX_COMMENTS {
return Err("Review comment limit reached (256)".into());
}
let anchor = anchor.ok_or("Cannot comment without a snapshot anchor")?;
if line == 0 || anchor.text.len() > MAX_SOURCE {
return Err("Invalid review anchor".into());
}
let file = git::read_file(root, &path, true, MAX_SOURCE, options);
options.check()?;
let mut stored = StoredComment {
comment: ReviewComment {
id: uuid::Uuid::new_v4().to_string(),
path,
side,
line,
anchor: anchor.text,
text,
stale: false,
resolved: false,
},
before: anchor.before,
after: anchor.after,
};
reanchor(&mut stored, &file);
comments.push(stored);
}
}
}
options.check()?;
write(&path, &comments)
}