use crate::error::{BeadsError, Result};
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
pub const ALLOWED_EXTENSIONS: &[&str] = &[
"db", "db-wal", "db-shm", "db-journal", "jsonl", "jsonl.tmp", ];
pub const ALLOWED_EXACT_NAMES: &[&str] = &[".manifest.json", "metadata.json"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathValidation {
Allowed,
OutsideBeadsDir { path: PathBuf, beads_dir: PathBuf },
DisallowedExtension { path: PathBuf, extension: String },
TraversalAttempt { path: PathBuf },
SymlinkEscape { path: PathBuf, target: PathBuf },
CanonicalizationFailed { path: PathBuf, error: String },
NonRegularFile { path: PathBuf },
GitPathAttempt { path: PathBuf },
}
impl PathValidation {
#[must_use]
pub const fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed)
}
#[must_use]
pub fn rejection_reason(&self) -> Option<String> {
match self {
Self::Allowed => None,
Self::OutsideBeadsDir { path, beads_dir } => Some(format!(
"Path '{}' is outside the beads directory '{}'",
path.display(),
beads_dir.display()
)),
Self::DisallowedExtension { path, extension } => Some(format!(
"Path '{}' has disallowed extension '{}' (allowed: {:?}, plus pid-scoped '*.jsonl.<pid>.tmp')",
path.display(),
extension,
ALLOWED_EXTENSIONS
)),
Self::TraversalAttempt { path } => Some(format!(
"Path '{}' contains traversal sequences",
path.display()
)),
Self::SymlinkEscape { path, target } => Some(format!(
"Symlink '{}' points outside beads directory to '{}'",
path.display(),
target.display()
)),
Self::CanonicalizationFailed { path, error } => Some(format!(
"Failed to canonicalize path '{}': {}",
path.display(),
error
)),
Self::NonRegularFile { path } => {
Some(format!("Path '{}' must be a regular file", path.display()))
}
Self::GitPathAttempt { path } => Some(format!(
"Path '{}' targets git internals - sync never accesses .git/ (safety invariant NGI-3)",
path.display()
)),
}
}
}
fn normalize_path_lexically(path: &Path) -> Option<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(component.as_os_str()),
std::path::Component::CurDir => {}
std::path::Component::Normal(part) => normalized.push(part),
std::path::Component::ParentDir => {
if !normalized.pop() {
return None;
}
}
}
}
Some(normalized)
}
#[must_use]
pub fn validate_no_git_path(path: &Path) -> PathValidation {
fn has_git_component(candidate: &Path) -> bool {
for component in candidate.components() {
if let std::path::Component::Normal(name) = component
&& name == ".git"
{
return true;
}
}
let path_str = candidate.to_string_lossy();
path_str.contains("/.git/")
|| path_str.contains("\\.git\\")
|| path_str.ends_with("/.git")
|| path_str.ends_with("\\.git")
}
if has_git_component(path) {
return PathValidation::GitPathAttempt {
path: path.to_path_buf(),
};
}
if let Ok(canonical) = dunce::canonicalize(path) {
if has_git_component(&canonical) {
return PathValidation::GitPathAttempt { path: canonical };
}
} else if let Some(parent) = path.parent()
&& let Ok(canonical_parent) = dunce::canonicalize(parent)
&& has_git_component(&canonical_parent)
{
return PathValidation::GitPathAttempt {
path: canonical_parent,
};
}
PathValidation::Allowed
}
#[allow(clippy::too_many_lines)]
pub fn validate_sync_path(path: &Path, beads_dir: &Path) -> PathValidation {
debug!(path = %path.display(), beads_dir = %beads_dir.display(), "Validating sync path");
let git_check = validate_no_git_path(path);
if !git_check.is_allowed() {
warn!(
path = %path.display(),
reason = %git_check.rejection_reason().unwrap_or_default(),
"Git path access blocked"
);
return git_check;
}
let had_parent_dir = path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir));
let Some(normalized_path) = normalize_path_lexically(path) else {
let result = PathValidation::TraversalAttempt {
path: path.to_path_buf(),
};
warn!(
path = %path.display(),
reason = %result.rejection_reason().unwrap_or_default(),
"Path validation rejected"
);
return result;
};
let canonical_beads = match dunce::canonicalize(beads_dir) {
Ok(p) => p,
Err(e) => {
let result = PathValidation::CanonicalizationFailed {
path: beads_dir.to_path_buf(),
error: e.to_string(),
};
warn!(
path = %beads_dir.display(),
error = %e,
"Beads directory canonicalization failed"
);
return result;
}
};
if had_parent_dir
&& !normalized_path.starts_with(beads_dir)
&& !normalized_path.starts_with(&canonical_beads)
{
let result = PathValidation::TraversalAttempt {
path: path.to_path_buf(),
};
warn!(
path = %path.display(),
reason = %result.rejection_reason().unwrap_or_default(),
"Path validation rejected"
);
return result;
}
let path_to_check = if normalized_path.exists() {
normalized_path.clone()
} else {
match normalized_path.parent() {
Some(parent) if parent.exists() => parent.to_path_buf(),
_ => {
if let Ok(relative) = normalized_path.strip_prefix(&canonical_beads) {
if !relative.to_string_lossy().contains("..") {
return validate_extension_and_name(&normalized_path);
}
}
normalized_path.clone()
}
}
};
let canonical_path = match dunce::canonicalize(&path_to_check) {
Ok(p) => p,
Err(e) => {
if !normalized_path.exists() {
if normalized_path.starts_with(beads_dir)
|| normalized_path.starts_with(&canonical_beads)
{
return validate_extension_and_name(&normalized_path);
}
}
let result = PathValidation::CanonicalizationFailed {
path: path.to_path_buf(),
error: e.to_string(),
};
warn!(
path = %path.display(),
error = %e,
"Path canonicalization failed"
);
return result;
}
};
if normalized_path.is_symlink()
&& let Ok(target) = std::fs::read_link(&normalized_path)
{
let canonical_target = dunce::canonicalize(&target).unwrap_or_else(|_| target.clone());
if !canonical_target.starts_with(&canonical_beads) {
let result = PathValidation::SymlinkEscape {
path: path.to_path_buf(),
target: canonical_target,
};
warn!(
path = %path.display(),
target = %target.display(),
"Symlink escape detected"
);
return result;
}
}
if normalized_path.exists() {
match std::fs::symlink_metadata(&normalized_path) {
Ok(metadata) if !metadata.is_file() => {
let result = PathValidation::NonRegularFile {
path: path.to_path_buf(),
};
warn!(
path = %path.display(),
reason = %result.rejection_reason().unwrap_or_default(),
"Path validation rejected"
);
return result;
}
Ok(_) => {}
Err(e) => {
let result = PathValidation::CanonicalizationFailed {
path: path.to_path_buf(),
error: e.to_string(),
};
warn!(
path = %path.display(),
error = %e,
"Path metadata lookup failed"
);
return result;
}
}
}
let effective_canonical = if normalized_path.exists() {
canonical_path
} else {
canonical_path.join(normalized_path.file_name().unwrap_or_default())
};
if !effective_canonical.starts_with(&canonical_beads) {
let result = PathValidation::OutsideBeadsDir {
path: path.to_path_buf(),
beads_dir: canonical_beads,
};
warn!(
path = %path.display(),
beads_dir = %beads_dir.display(),
reason = %result.rejection_reason().unwrap_or_default(),
"Path validation rejected"
);
return result;
}
let extension_result = validate_extension_and_name(&normalized_path);
if !extension_result.is_allowed() {
warn!(
path = %path.display(),
reason = %extension_result.rejection_reason().unwrap_or_default(),
"Path validation rejected"
);
return extension_result;
}
debug!(path = %path.display(), "Path validated for sync I/O");
PathValidation::Allowed
}
fn validate_extension_and_name(path: &Path) -> PathValidation {
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if ALLOWED_EXACT_NAMES.iter().any(|&name| file_name == name) {
return PathValidation::Allowed;
}
if is_allowed_jsonl_temp_name(&file_name) {
return PathValidation::Allowed;
}
for allowed_ext in ALLOWED_EXTENSIONS {
if file_name.ends_with(&format!(".{allowed_ext}")) {
return PathValidation::Allowed;
}
}
let extension = path
.extension()
.map_or_else(|| "none".to_string(), |e| e.to_string_lossy().to_string());
PathValidation::DisallowedExtension {
path: path.to_path_buf(),
extension,
}
}
fn is_allowed_jsonl_temp_name(file_name: &str) -> bool {
if file_name.ends_with(".jsonl.tmp") {
return true;
}
let Some(prefix) = file_name.strip_suffix(".tmp") else {
return false;
};
let Some((base, pid)) = prefix.rsplit_once(".jsonl.") else {
return false;
};
!base.is_empty() && !pid.is_empty() && pid.chars().all(|c| c.is_ascii_digit())
}
pub fn require_valid_sync_path(path: &Path, beads_dir: &Path) -> Result<()> {
let validation = validate_sync_path(path, beads_dir);
match validation {
PathValidation::Allowed => Ok(()),
_ => Err(BeadsError::Config(
validation
.rejection_reason()
.unwrap_or_else(|| "Path validation failed".to_string()),
)),
}
}
#[must_use]
pub fn is_sync_path_allowed(path: &Path, beads_dir: &Path) -> bool {
let Some(normalized_path) = normalize_path_lexically(path) else {
return false;
};
if normalized_path.starts_with(beads_dir) {
return validate_extension_and_name(&normalized_path).is_allowed();
}
validate_sync_path(&normalized_path, beads_dir).is_allowed()
}
pub fn validate_sync_path_with_external(
path: &Path,
beads_dir: &Path,
allow_external: bool,
) -> Result<()> {
let git_check = validate_no_git_path(path);
if !git_check.is_allowed() {
return Err(BeadsError::Config(
git_check
.rejection_reason()
.unwrap_or_else(|| "Git path access denied".to_string()),
));
}
if allow_external {
tracing::info!(path = %path.display(), "Using external JSONL path (--allow-external-jsonl)");
return validate_external_jsonl_path(path);
}
require_valid_sync_path(path, beads_dir)
}
fn validate_external_jsonl_path(path: &Path) -> Result<()> {
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if !file_name.ends_with(".jsonl") && !is_allowed_jsonl_temp_name(&file_name) {
return Err(BeadsError::Config(format!(
"External path '{}' must be a .jsonl file",
path.display()
)));
}
for component in path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err(BeadsError::Config(format!(
"Path '{}' contains traversal sequences",
path.display()
)));
}
}
if let Ok(metadata) = std::fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() {
return Err(BeadsError::Config(format!(
"External path '{}' must not be a symlink",
path.display()
)));
}
if !metadata.is_file() {
return Err(BeadsError::Config(format!(
"External path '{}' must be a regular file",
path.display()
)));
}
}
Ok(())
}
pub fn require_safe_sync_overwrite_path(
path: &Path,
beads_dir: &Path,
allow_external: bool,
operation: &str,
) -> Result<()> {
let canonical_beads =
dunce::canonicalize(beads_dir).unwrap_or_else(|_| beads_dir.to_path_buf());
let resolved_path = if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let is_internal = resolved_path.starts_with(beads_dir)
|| resolved_path.starts_with(&canonical_beads)
|| path.starts_with(beads_dir)
|| path.starts_with(&canonical_beads);
if is_internal {
let validation = validate_sync_path(path, beads_dir);
if validation.is_allowed() {
debug!(
path = %path.display(),
operation,
"Sync path approved for destructive operation"
);
return Ok(());
}
let reason = validation
.rejection_reason()
.unwrap_or_else(|| "Path validation failed".to_string());
warn!(
path = %path.display(),
operation,
reason = %reason,
"Sync destructive path rejected"
);
return Err(BeadsError::Config(reason));
}
if !allow_external {
let reason = format!("Refusing to {operation} outside .beads: {}", path.display());
warn!(
path = %path.display(),
operation,
reason = %reason,
"Sync destructive path rejected"
);
return Err(BeadsError::Config(reason));
}
match validate_sync_path_with_external(path, beads_dir, true) {
Ok(()) => {
debug!(
path = %path.display(),
operation,
"External sync path approved for destructive operation"
);
Ok(())
}
Err(err) => {
warn!(
path = %path.display(),
operation,
error = %err,
"Sync destructive path rejected"
);
Err(err)
}
}
}
pub fn validate_temp_file_path(
temp_path: &Path,
target_path: &Path,
beads_dir: &Path,
allow_external: bool,
) -> Result<()> {
let git_check = validate_no_git_path(temp_path);
if !git_check.is_allowed() {
return Err(BeadsError::Config(
git_check
.rejection_reason()
.unwrap_or_else(|| "Git path access denied".to_string()),
));
}
let temp_parent = temp_path.parent();
let target_parent = target_path.parent();
if temp_parent != target_parent {
return Err(BeadsError::Config(format!(
"Temp file '{}' must be in the same directory as target '{}' (safety invariant PC-4)",
temp_path.display(),
target_path.display()
)));
}
let has_tmp_extension = temp_path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("tmp"));
if !has_tmp_extension {
return Err(BeadsError::Config(format!(
"Temp file '{}' must use a .tmp extension",
temp_path.display()
)));
}
validate_sync_path_with_external(temp_path, beads_dir, allow_external)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_test_beads_dir() -> (TempDir, PathBuf) {
let temp = TempDir::new().expect("create temp dir");
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).expect("create beads dir");
(temp, beads_dir)
}
#[test]
fn test_allowed_jsonl_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("issues.jsonl");
std::fs::write(&path, "{}").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "JSONL files should be allowed");
}
#[test]
fn test_allowed_db_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("beads.db");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "DB files should be allowed");
}
#[test]
fn test_allowed_db_wal_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("beads.db-wal");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "DB-WAL files should be allowed");
}
#[test]
fn test_allowed_db_journal_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("beads.db-journal");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "DB journal files should be allowed");
}
#[test]
fn test_allowed_manifest_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join(".manifest.json");
std::fs::write(&path, "{}").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "Manifest files should be allowed");
}
#[test]
fn test_allowed_normalized_internal_path_with_parent_component() {
let (temp, beads_dir) = setup_test_beads_dir();
let subdir = temp.path().join("subdir");
std::fs::create_dir_all(&subdir).expect("create subdir");
std::fs::write(beads_dir.join("issues.jsonl"), "{}").expect("write issues.jsonl");
let path = subdir.join("..").join(".beads").join("issues.jsonl");
let result = validate_sync_path(&path, &beads_dir);
assert!(
result.is_allowed(),
"Normalized in-tree paths should be allowed"
);
}
#[test]
fn test_allowed_metadata_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("metadata.json");
std::fs::write(&path, "{}").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "Metadata files should be allowed");
}
#[test]
fn test_allowed_temp_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("issues.jsonl.tmp");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(result.is_allowed(), "Temp JSONL files should be allowed");
}
#[test]
fn test_allowed_pid_scoped_temp_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("issues.jsonl.12345.tmp");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(
result.is_allowed(),
"PID-scoped temp JSONL files should be allowed"
);
}
#[test]
fn test_rejected_outside_beads_dir() {
let (_temp, beads_dir) = setup_test_beads_dir();
let outside_path = beads_dir.parent().unwrap().join("outside.jsonl");
std::fs::write(&outside_path, "").expect("write");
let result = validate_sync_path(&outside_path, &beads_dir);
assert!(
matches!(result, PathValidation::OutsideBeadsDir { .. }),
"Files outside beads dir should be rejected"
);
}
#[test]
fn test_rejected_traversal() {
let (_temp, beads_dir) = setup_test_beads_dir();
let traversal_path = beads_dir.join("../../../etc/passwd");
let result = validate_sync_path(&traversal_path, &beads_dir);
assert!(
matches!(result, PathValidation::TraversalAttempt { .. }),
"Traversal attempts should be rejected"
);
}
#[test]
fn test_rejected_disallowed_extension() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("config.yaml");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(
matches!(result, PathValidation::DisallowedExtension { .. }),
"Disallowed extensions should be rejected"
);
}
#[test]
fn test_rejected_source_file() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("main.rs");
std::fs::write(&path, "").expect("write");
let result = validate_sync_path(&path, &beads_dir);
assert!(
matches!(result, PathValidation::DisallowedExtension { .. }),
"Source files should be rejected"
);
}
#[test]
fn test_rejected_directory_named_like_jsonl() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("issues.jsonl");
std::fs::create_dir_all(&path).expect("create directory");
let result = validate_sync_path(&path, &beads_dir);
assert!(
matches!(result, PathValidation::NonRegularFile { .. }),
"Directories named like JSONL files should be rejected"
);
}
#[test]
fn test_rejected_absolute_path_outside() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = PathBuf::from("/etc/passwd");
let result = validate_sync_path(&path, &beads_dir);
assert!(
!result.is_allowed(),
"Absolute paths outside beads dir should be rejected"
);
}
#[test]
fn test_rejected_git_path_component() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join(".git").join("config");
let result = validate_sync_path(&path, &beads_dir);
assert!(
matches!(result, PathValidation::GitPathAttempt { .. }),
".git paths should be rejected"
);
}
#[test]
fn test_new_file_in_beads_dir() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("new.jsonl");
let result = validate_sync_path(&path, &beads_dir);
assert!(
result.is_allowed(),
"New JSONL files in beads dir should be allowed"
);
}
#[test]
fn test_require_valid_sync_path_ok() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("issues.jsonl");
std::fs::write(&path, "").expect("write");
let result = require_valid_sync_path(&path, &beads_dir);
assert!(result.is_ok(), "Valid paths should return Ok");
}
#[test]
fn test_require_valid_sync_path_error() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("../../../etc/passwd");
let result = require_valid_sync_path(&path, &beads_dir);
assert!(result.is_err(), "Invalid paths should return Err");
assert!(result.unwrap_err().to_string().contains("traversal"));
}
#[test]
fn test_is_sync_path_allowed_quick_check() {
let (_temp, beads_dir) = setup_test_beads_dir();
assert!(is_sync_path_allowed(
&beads_dir.join("issues.jsonl"),
&beads_dir
));
assert!(!is_sync_path_allowed(
&beads_dir.join("../evil.jsonl"),
&beads_dir
));
}
#[test]
fn test_is_sync_path_allowed_accepts_normalized_internal_path() {
let (temp, beads_dir) = setup_test_beads_dir();
let subdir = temp.path().join("subdir");
std::fs::create_dir_all(&subdir).expect("create subdir");
assert!(is_sync_path_allowed(
&subdir.join("..").join(".beads").join("issues.jsonl"),
&beads_dir
));
}
#[cfg(unix)]
#[test]
fn test_symlink_escape_rejected() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().expect("create temp dir");
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).expect("create beads dir");
let outside_target = temp.path().join("secret.txt");
std::fs::write(&outside_target, "secret data").expect("write");
let symlink_path = beads_dir.join("evil.jsonl");
symlink(&outside_target, &symlink_path).expect("create symlink");
let result = validate_sync_path(&symlink_path, &beads_dir);
assert!(
matches!(result, PathValidation::SymlinkEscape { .. }),
"Symlinks escaping beads dir should be rejected"
);
}
#[cfg(unix)]
#[test]
fn test_validate_no_git_path_rejects_symlinked_git_parent() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().expect("create temp dir");
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).expect("create .git dir");
let symlink_parent = temp.path().join("gitlink");
symlink(&git_dir, &symlink_parent).expect("create git symlink");
let candidate = symlink_parent.join("issues.jsonl");
let result = validate_no_git_path(&candidate);
assert!(
matches!(result, PathValidation::GitPathAttempt { .. }),
"Symlinked parents targeting .git should be rejected"
);
}
#[cfg(unix)]
#[test]
fn test_validate_sync_path_with_external_rejects_symlinked_jsonl() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().expect("create temp dir");
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).expect("create beads dir");
let outside_target = temp.path().join("secret.txt");
std::fs::write(&outside_target, "secret data").expect("write");
let symlink_path = temp.path().join("outside.jsonl");
symlink(&outside_target, &symlink_path).expect("create symlink");
let result = validate_sync_path_with_external(&symlink_path, &beads_dir, true);
assert!(
result.is_err(),
"External symlinked JSONL paths should be rejected"
);
assert!(
result
.unwrap_err()
.to_string()
.contains("must not be a symlink"),
"Error should explain why the external path was rejected"
);
}
#[test]
fn test_validation_logs_rejection() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join("../../../etc/passwd");
let result = validate_sync_path(&path, &beads_dir);
let reason = result.rejection_reason();
assert!(reason.is_some(), "Rejected paths should have a reason");
assert!(
reason.unwrap().contains("traversal"),
"Reason should mention traversal"
);
}
#[test]
fn test_safe_overwrite_blocks_external_without_flag() {
let (temp, beads_dir) = setup_test_beads_dir();
let path = temp.path().join("outside.jsonl");
let result = require_safe_sync_overwrite_path(&path, &beads_dir, false, "overwrite");
assert!(
result.is_err(),
"External overwrite should be rejected without flag"
);
}
#[test]
fn test_safe_overwrite_allows_external_jsonl_with_flag() {
let (temp, beads_dir) = setup_test_beads_dir();
let path = temp.path().join("outside.jsonl");
let result = require_safe_sync_overwrite_path(&path, &beads_dir, true, "overwrite");
assert!(
result.is_ok(),
"External JSONL overwrite should be allowed with flag"
);
}
#[test]
fn test_safe_overwrite_rejects_external_non_jsonl() {
let (temp, beads_dir) = setup_test_beads_dir();
let path = temp.path().join("outside.txt");
let result = require_safe_sync_overwrite_path(&path, &beads_dir, true, "overwrite");
assert!(
result.is_err(),
"External non-JSONL overwrite should be rejected"
);
}
#[test]
fn test_safe_overwrite_rejects_external_directory_named_jsonl() {
let (temp, beads_dir) = setup_test_beads_dir();
let path = temp.path().join("outside.jsonl");
std::fs::create_dir_all(&path).expect("create directory");
let result = require_safe_sync_overwrite_path(&path, &beads_dir, true, "overwrite");
assert!(
result.is_err(),
"External directories should be rejected even if they look like JSONL files"
);
}
#[test]
fn test_safe_overwrite_allows_manifest_inside_beads() {
let (_temp, beads_dir) = setup_test_beads_dir();
let path = beads_dir.join(".manifest.json");
let result = require_safe_sync_overwrite_path(&path, &beads_dir, true, "overwrite");
assert!(
result.is_ok(),
"Manifest overwrite should be allowed inside .beads"
);
}
#[test]
fn test_temp_file_valid_same_directory() {
let (_temp, beads_dir) = setup_test_beads_dir();
let target = beads_dir.join("issues.jsonl");
let temp = beads_dir.join("issues.jsonl.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_ok(),
"Temp file in same directory with .tmp extension should be valid"
);
}
#[test]
fn test_temp_file_valid_same_directory_with_pid_scoped_name() {
let (_temp, beads_dir) = setup_test_beads_dir();
let target = beads_dir.join("issues.jsonl");
let temp = beads_dir.join("issues.jsonl.12345.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_ok(),
"PID-scoped temp file in same directory should be valid"
);
}
#[test]
fn test_temp_file_rejects_different_directory() {
let (temp_dir, beads_dir) = setup_test_beads_dir();
let target = beads_dir.join("issues.jsonl");
let temp = temp_dir.path().join("issues.jsonl.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_err(),
"Temp file in different directory should be rejected (PC-4)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("same directory") || err.contains("PC-4"),
"Error should mention same directory requirement: {err}"
);
}
#[test]
fn test_temp_file_rejects_missing_tmp_extension() {
let (_temp, beads_dir) = setup_test_beads_dir();
let target = beads_dir.join("issues.jsonl");
let temp = beads_dir.join("issues.jsonl.bak");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_err(),
"Temp file without .tmp extension should be rejected"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains(".tmp"),
"Error should mention .tmp extension requirement: {err}"
);
}
#[test]
fn test_temp_file_rejects_git_path() {
let (temp_dir, beads_dir) = setup_test_beads_dir();
let git_dir = temp_dir.path().join(".git");
std::fs::create_dir_all(&git_dir).expect("create .git dir");
let target = git_dir.join("config");
let temp = git_dir.join("config.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, true);
assert!(
result.is_err(),
"Temp file in .git directory should always be rejected"
);
}
#[test]
fn test_temp_file_allows_external_with_flag() {
let (temp_dir, beads_dir) = setup_test_beads_dir();
let external_dir = temp_dir.path().join("external");
std::fs::create_dir_all(&external_dir).expect("create external dir");
let target = external_dir.join("issues.jsonl");
let temp = external_dir.join("issues.jsonl.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, true);
assert!(
result.is_ok(),
"External temp file should be allowed when allow_external is true"
);
}
#[test]
fn test_temp_file_rejects_external_without_flag() {
let (temp_dir, beads_dir) = setup_test_beads_dir();
let external_dir = temp_dir.path().join("external");
std::fs::create_dir_all(&external_dir).expect("create external dir");
let target = external_dir.join("issues.jsonl");
let temp = external_dir.join("issues.jsonl.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_err(),
"External temp file should be rejected when allow_external is false"
);
}
#[test]
fn test_temp_file_nested_beads_subdir() {
let (_temp, beads_dir) = setup_test_beads_dir();
let subdir = beads_dir.join("history");
std::fs::create_dir_all(&subdir).expect("create history subdir");
let target = subdir.join("backup.jsonl");
let temp = subdir.join("backup.jsonl.tmp");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_ok(),
"Temp file in nested .beads subdir should be valid"
);
}
#[cfg(unix)]
#[test]
fn test_temp_file_rejects_existing_symlink() {
use std::os::unix::fs::symlink;
let (temp_dir, beads_dir) = setup_test_beads_dir();
let external_dir = temp_dir.path().join("external");
std::fs::create_dir_all(&external_dir).expect("create external dir");
let target = beads_dir.join("issues.jsonl");
let temp = beads_dir.join("issues.jsonl.tmp");
symlink(external_dir.join("capture.jsonl"), &temp).expect("create symlink");
let result = validate_temp_file_path(&temp, &target, &beads_dir, false);
assert!(
result.is_err(),
"Existing symlink temp paths should be rejected"
);
}
}