#![allow(
clippy::too_many_lines,
clippy::needless_collect,
clippy::uninlined_format_args
)]
mod common;
use beads_rust::model::Issue;
use beads_rust::storage::SqliteStorage;
use beads_rust::sync::{ExportConfig, export_to_jsonl};
use sha2::{Digest, Sha256};
use std::fs;
#[cfg(unix)]
use std::fs::Permissions;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::TempDir;
fn create_test_issue(id: &str, title: &str) -> Issue {
let mut issue = common::fixtures::issue(title);
issue.id = id.to_string();
issue
}
fn export_temp_path_for_test(output_path: &Path) -> std::path::PathBuf {
export_temp_path_attempt_for_test(output_path, 0)
}
fn export_temp_path_attempt_for_test(output_path: &Path, attempt: u32) -> std::path::PathBuf {
let pid = std::process::id();
if attempt == 0 {
return output_path.with_extension(format!("jsonl.{pid}.tmp"));
}
let retry_suffix = u64::from(pid)
.saturating_mul(100)
.saturating_add(u64::from(attempt));
output_path.with_extension(format!("jsonl.{retry_suffix}.tmp"))
}
fn setup_storage_with_issues(count: usize) -> SqliteStorage {
let mut storage = SqliteStorage::open_memory().unwrap();
for i in 0..count {
let issue = create_test_issue(&format!("test-{i:04}"), &format!("Issue {i}"));
storage.create_issue(&issue, "tester").unwrap();
}
storage
}
fn setup_beads_dir(temp: &TempDir) -> std::path::PathBuf {
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
beads_dir
}
fn compute_file_hash(path: &Path) -> String {
let content = fs::read(path).unwrap();
beads_rust::util::hex_encode(&Sha256::digest(&content))
}
fn default_config(beads_dir: &Path) -> ExportConfig {
ExportConfig {
beads_dir: Some(beads_dir.to_path_buf()),
..Default::default()
}
}
#[test]
fn concurrent_exports_no_corruption() {
let _log = common::test_log("concurrent_exports_no_corruption");
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let handles: Vec<_> = (0..4)
.map(|i| {
let path = beads_dir.join(format!("issues_{i}.jsonl"));
let bd = beads_dir.clone();
std::thread::spawn(move || {
let mut storage = SqliteStorage::open_memory().unwrap();
for j in 0..5 {
let issue = create_test_issue(
&format!("t{i}-{j:03}"),
&format!("Thread {i} Issue {j}"),
);
storage.create_issue(&issue, "tester").unwrap();
}
let config = ExportConfig {
beads_dir: Some(bd),
..Default::default()
};
let result = export_to_jsonl(&storage, &path, &config);
(path, result)
})
})
.collect();
for handle in handles {
let (path, result) = handle.join().unwrap();
let export = result.expect("export should succeed");
assert_eq!(export.exported_count, 5, "Each export should have 5 issues");
let content = fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 5);
for line in &lines {
let _: serde_json::Value =
serde_json::from_str(line).expect("line should be valid JSON");
}
}
}
#[test]
fn export_rejects_path_traversal() {
let _log = common::test_log("export_rejects_path_traversal");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let evil_path = beads_dir.join("..").join("escaped.jsonl");
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &evil_path, &config);
assert!(
result.is_err(),
"Export to traversal path should fail at runtime"
);
let escaped = temp.path().join("escaped.jsonl");
assert!(
!escaped.exists(),
"Traversal path should not create escaped file"
);
}
#[test]
fn export_rejects_outside_beads_dir() {
let _log = common::test_log("export_rejects_outside_beads_dir");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let outside_path = temp.path().join("outside.jsonl");
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &outside_path, &config);
assert!(
result.is_err(),
"Export to path outside beads_dir should be rejected"
);
assert!(
!outside_path.exists(),
"File outside beads_dir should not be created"
);
}
#[test]
fn export_rejects_git_path() {
let _log = common::test_log("export_rejects_git_path");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let git_dir = beads_dir.join(".git");
fs::create_dir_all(&git_dir).unwrap();
let git_path = git_dir.join("issues.jsonl");
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &git_path, &config);
assert!(
result.is_err(),
"Export to .git path should be rejected at runtime"
);
assert!(
!git_path.exists(),
".git path should not be written to during export"
);
}
#[test]
#[cfg(unix)]
fn temp_file_cleaned_up_on_failure() {
let _log = common::test_log("temp_file_cleaned_up_on_failure");
let storage = setup_storage_with_issues(3);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let temp_path = export_temp_path_for_test(&jsonl_path);
fs::write(&jsonl_path, r#"{"id":"old","title":"Old"}"#).unwrap();
fs::set_permissions(&beads_dir, Permissions::from_mode(0o555)).unwrap();
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &jsonl_path, &config);
fs::set_permissions(&beads_dir, Permissions::from_mode(0o755)).unwrap();
assert!(result.is_err(), "Export should fail on read-only dir");
assert!(
!temp_path.exists(),
"Temp file should not remain after failed export"
);
}
#[test]
fn re_export_overwrites_cleanly() {
let _log = common::test_log("re_export_overwrites_cleanly");
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let mut storage = SqliteStorage::open_memory().unwrap();
for i in 0..3 {
let issue = create_test_issue(&format!("test-{i:03}"), &format!("Issue {i}"));
storage.create_issue(&issue, "tester").unwrap();
}
let config = default_config(&beads_dir);
let r1 = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
assert_eq!(r1.exported_count, 3);
let hash1 = compute_file_hash(&jsonl_path);
for i in 3..6 {
let issue = create_test_issue(&format!("test-{i:03}"), &format!("Issue {i}"));
storage.create_issue(&issue, "tester").unwrap();
}
let r2 = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
assert_eq!(r2.exported_count, 6);
let hash2 = compute_file_hash(&jsonl_path);
assert_ne!(hash1, hash2, "Re-export with new issues should change hash");
let content = fs::read_to_string(&jsonl_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 6, "Re-export should contain all 6 issues");
}
#[test]
fn successive_exports_idempotent() {
let _log = common::test_log("successive_exports_idempotent");
let storage = setup_storage_with_issues(5);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let config = default_config(&beads_dir);
let r1 = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
let hash1 = compute_file_hash(&jsonl_path);
let r2 = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
let hash2 = compute_file_hash(&jsonl_path);
let r3 = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
let hash3 = compute_file_hash(&jsonl_path);
assert_eq!(hash1, hash2, "Successive exports should produce same hash");
assert_eq!(hash2, hash3, "Third export should match second");
assert_eq!(r1.exported_count, r2.exported_count);
assert_eq!(r2.exported_count, r3.exported_count);
assert_eq!(
r1.content_hash, r2.content_hash,
"Export result content_hash should be deterministic"
);
}
#[test]
fn export_produces_valid_jsonl_per_line() {
let _log = common::test_log("export_produces_valid_jsonl_per_line");
let storage = setup_storage_with_issues(20);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let config = default_config(&beads_dir);
export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
let content = fs::read_to_string(&jsonl_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 20);
for (i, line) in lines.iter().enumerate() {
let parsed: serde_json::Value =
serde_json::from_str(line).expect("line should be valid JSON");
assert!(parsed.get("id").is_some(), "Line {i} missing 'id' field");
assert!(
parsed.get("title").is_some(),
"Line {i} missing 'title' field"
);
assert!(
parsed.get("status").is_some(),
"Line {i} missing 'status' field"
);
}
}
#[test]
#[cfg(unix)]
fn export_rejects_symlink_escape() {
let _log = common::test_log("export_rejects_symlink_escape");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let outside = TempDir::new().unwrap();
let symlink_path = beads_dir.join("escape_link");
std::os::unix::fs::symlink(outside.path(), &symlink_path).unwrap();
let target = symlink_path.join("issues.jsonl");
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &target, &config);
assert!(
result.is_err(),
"Export through symlink escape should be rejected"
);
let escaped_file = outside.path().join("issues.jsonl");
assert!(
!escaped_file.exists(),
"No file should be written outside beads_dir via symlink"
);
}
#[test]
#[cfg(unix)]
fn export_rejects_existing_temp_symlink_and_preserves_live_jsonl() {
let _log = common::test_log("export_rejects_existing_temp_symlink_and_preserves_live_jsonl");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let temp_path = export_temp_path_for_test(&jsonl_path);
fs::write(&jsonl_path, "{\"id\":\"old\",\"title\":\"Old\"}\n").unwrap();
let outside = TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path().join("captured.jsonl"), &temp_path).unwrap();
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &jsonl_path, &config);
assert!(
result.is_err(),
"Export should reject an existing temp symlink"
);
assert_eq!(
fs::read_to_string(&jsonl_path).unwrap(),
"{\"id\":\"old\",\"title\":\"Old\"}\n",
"Live JSONL should remain untouched when staged export is rejected"
);
assert!(
!outside.path().join("captured.jsonl").exists(),
"Temp symlink should not receive exported data"
);
}
#[test]
fn export_skips_stale_regular_temp_file_and_preserves_it() {
let _log = common::test_log("export_skips_stale_regular_temp_file_and_preserves_it");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let stale_temp_path = export_temp_path_for_test(&jsonl_path);
let retry_temp_path = export_temp_path_attempt_for_test(&jsonl_path, 1);
fs::write(&stale_temp_path, "stale temp").unwrap();
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
assert_eq!(result.exported_count, 1);
assert!(
fs::read_to_string(&jsonl_path)
.unwrap()
.contains("\"id\":\"test-0000\""),
"export should succeed through a retry temp path"
);
assert_eq!(
fs::read_to_string(&stale_temp_path).unwrap(),
"stale temp",
"regular stale temp collision should be left untouched"
);
assert!(
!retry_temp_path.exists(),
"successful rename should not leave the retry temp file behind"
);
}
#[test]
fn export_external_path_with_flag() {
let _log = common::test_log("export_external_path_with_flag");
let storage = setup_storage_with_issues(3);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let external_path = temp.path().join("external_export.jsonl");
let config = ExportConfig {
beads_dir: Some(beads_dir),
allow_external_jsonl: true,
..Default::default()
};
let result = export_to_jsonl(&storage, &external_path, &config);
assert!(
result.is_ok(),
"External export with flag should succeed: {:?}",
result.err()
);
assert!(external_path.exists(), "External file should be created");
let content = fs::read_to_string(&external_path).unwrap();
assert_eq!(
content.lines().count(),
3,
"External export should contain all issues"
);
}
#[test]
fn export_external_still_rejects_git() {
let _log = common::test_log("export_external_still_rejects_git");
let storage = setup_storage_with_issues(1);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let git_dir = temp.path().join(".git");
fs::create_dir_all(&git_dir).unwrap();
let git_path = git_dir.join("issues.jsonl");
let config = ExportConfig {
beads_dir: Some(beads_dir),
allow_external_jsonl: true,
..Default::default()
};
let result = export_to_jsonl(&storage, &git_path, &config);
assert!(
result.is_err(),
"External export to .git path should still be rejected"
);
assert!(!git_path.exists(), ".git file should not be created");
}
#[test]
fn export_count_matches_file_line_count() {
let _log = common::test_log("export_count_matches_file_line_count");
let storage = setup_storage_with_issues(50);
let temp = TempDir::new().unwrap();
let beads_dir = setup_beads_dir(&temp);
let jsonl_path = beads_dir.join("issues.jsonl");
let config = default_config(&beads_dir);
let result = export_to_jsonl(&storage, &jsonl_path, &config).unwrap();
let content = fs::read_to_string(&jsonl_path).unwrap();
let actual_lines = content.lines().count();
assert_eq!(
result.exported_count, actual_lines,
"Export count should match actual JSONL line count"
);
let ids: std::collections::HashSet<String> = content
.lines()
.map(|line| {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
v["id"].as_str().unwrap().to_string()
})
.collect();
assert_eq!(
ids.len(),
actual_lines,
"All exported issue IDs should be unique"
);
}