Files
yvgude--lean-ctx/rust/tests/project_data_dir_scenarios.rs
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

454 lines
14 KiB
Rust

//! Scenario tests for project-scoped data directory isolation.
//!
//! Validates that project-scoped files (overlays.json, policies.json, proofs/)
//! are never written into the global data directory (~/.lean-ctx/).
//! Covers cowwoc's report: overlays.json appearing in ~/.lean-ctx/ when project
//! root resolves to the home directory.
use std::path::Path;
use lean_ctx::core::pathutil::{
is_broad_or_unsafe_root, is_data_dir_collision, safe_project_data_dir,
};
// ---------------------------------------------------------------------------
// is_broad_or_unsafe_root
// ---------------------------------------------------------------------------
#[test]
fn home_dir_is_broad() {
if let Some(home) = dirs::home_dir() {
assert!(
is_broad_or_unsafe_root(&home),
"home directory must be rejected as project root"
);
}
}
#[test]
fn filesystem_root_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new("/")));
}
#[test]
fn backslash_root_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new("\\")));
}
#[test]
fn bare_dot_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new(".")));
}
#[test]
fn claude_sandbox_dir_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
assert!(is_broad_or_unsafe_root(Path::new(
"/Users/dev/.claude/projects/abc"
)));
}
#[test]
fn codex_sandbox_dir_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
assert!(is_broad_or_unsafe_root(Path::new(
"/home/user/.codex/sandbox"
)));
}
#[test]
fn normal_project_is_not_broad() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("my-app");
std::fs::create_dir_all(&project).unwrap();
assert!(
!is_broad_or_unsafe_root(&project),
"normal project dir should be allowed"
);
}
#[test]
fn home_subdir_is_not_broad() {
if let Some(home) = dirs::home_dir() {
let project = home.join("projects").join("my-app");
assert!(
!is_broad_or_unsafe_root(&project),
"subdirectory of home should be allowed"
);
}
}
#[test]
fn tmp_subdir_is_not_broad() {
assert!(!is_broad_or_unsafe_root(Path::new("/tmp/my-project")));
}
// ---------------------------------------------------------------------------
// is_data_dir_collision
// ---------------------------------------------------------------------------
#[test]
fn collision_rejects_home_dir() {
if let Some(home) = dirs::home_dir() {
assert!(
is_data_dir_collision(&home),
"home dir as project root must be a data dir collision"
);
}
}
#[test]
fn collision_rejects_filesystem_root() {
assert!(
is_data_dir_collision(Path::new("/")),
"/ as project root must be a collision"
);
}
#[test]
fn collision_allows_normal_project() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("safe-project");
std::fs::create_dir_all(&project).unwrap();
assert!(
!is_data_dir_collision(&project),
"normal project should not collide with data dir"
);
}
// ---------------------------------------------------------------------------
// safe_project_data_dir
// ---------------------------------------------------------------------------
#[test]
fn safe_dir_returns_err_for_home() {
if let Some(home) = dirs::home_dir() {
let result = safe_project_data_dir(&home);
assert!(result.is_err(), "home dir should produce Err");
let msg = result.unwrap_err();
assert!(
msg.contains("collides"),
"error message should mention collision: {msg}"
);
}
}
#[test]
fn safe_dir_returns_err_for_root() {
let result = safe_project_data_dir(Path::new("/"));
assert!(result.is_err());
}
#[test]
fn safe_dir_returns_ok_for_normal_project() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("good-project");
std::fs::create_dir_all(&project).unwrap();
let result = safe_project_data_dir(&project);
assert!(result.is_ok());
let dir = result.unwrap();
assert_eq!(dir, project.join(".lean-ctx"));
}
// ---------------------------------------------------------------------------
// OverlayStore: save_project / load_project
// ---------------------------------------------------------------------------
#[test]
fn overlay_save_skips_home_dir() {
use lean_ctx::core::context_overlay::OverlayStore;
if let Some(home) = dirs::home_dir() {
let store = OverlayStore::new();
let result = store.save_project(&home);
assert!(
result.is_err(),
"save_project to home dir should fail: {result:?}"
);
}
}
#[test]
fn overlay_load_returns_empty_for_home_dir() {
use lean_ctx::core::context_overlay::OverlayStore;
if let Some(home) = dirs::home_dir() {
let store = OverlayStore::load_project(&home);
assert!(
store.all().is_empty(),
"load_project from home dir should return empty store"
);
}
}
#[test]
fn overlay_save_load_roundtrip_normal_project() {
use lean_ctx::core::context_field::ContextItemId;
use lean_ctx::core::context_overlay::{
ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
};
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("test-project");
std::fs::create_dir_all(&project).unwrap();
let mut store = OverlayStore::new();
store.add(ContextOverlay::new(
ContextItemId::from_file("src/main.rs"),
OverlayOp::Include,
OverlayScope::Project,
"hash123".into(),
OverlayAuthor::User,
));
store.save_project(&project).expect("save should succeed");
let loaded = OverlayStore::load_project(&project);
assert_eq!(
loaded.all().len(),
1,
"roundtrip should preserve overlay count"
);
}
#[test]
fn overlay_save_does_not_write_to_data_dir() {
use lean_ctx::core::context_overlay::OverlayStore;
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("clean-project");
std::fs::create_dir_all(&project).unwrap();
let store = OverlayStore::new();
store.save_project(&project).unwrap();
let overlay_path = project.join(".lean-ctx").join("overlays.json");
assert!(
overlay_path.exists(),
"overlay file should be in project dir"
);
if let Some(home) = dirs::home_dir() {
let data_dir_overlay = home.join(".lean-ctx").join("overlays.json");
if data_dir_overlay.exists() {
let content = std::fs::read_to_string(&data_dir_overlay).unwrap_or_default();
assert!(
!content.contains("clean-project"),
"global data dir should not contain project-specific overlay data"
);
}
}
}
// ---------------------------------------------------------------------------
// PolicySet: save_project / load_project
// ---------------------------------------------------------------------------
#[test]
fn policy_save_skips_home_dir() {
use lean_ctx::core::context_policies::PolicySet;
if let Some(home) = dirs::home_dir() {
let policies = PolicySet::defaults();
let result = policies.save_project(&home);
assert!(
result.is_err(),
"save_project to home dir should fail: {result:?}"
);
}
}
#[test]
fn policy_load_returns_defaults_for_home_dir() {
use lean_ctx::core::context_policies::PolicySet;
if let Some(home) = dirs::home_dir() {
let policies = PolicySet::load_project(&home);
let defaults = PolicySet::defaults();
assert_eq!(
policies.policies.len(),
defaults.policies.len(),
"load_project from home dir should return defaults"
);
}
}
#[test]
fn policy_save_load_roundtrip_normal_project() {
use lean_ctx::core::context_policies::PolicySet;
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("policy-project");
std::fs::create_dir_all(&project).unwrap();
let policies = PolicySet::defaults();
policies
.save_project(&project)
.expect("save should succeed");
let loaded = PolicySet::load_project(&project);
assert_eq!(
loaded.policies.len(),
policies.policies.len(),
"roundtrip should preserve policy count"
);
}
// ---------------------------------------------------------------------------
// Proof writers: safe_project_data_dir gate
// ---------------------------------------------------------------------------
#[test]
fn proof_writers_reject_home_dir() {
if let Some(home) = dirs::home_dir() {
let result = safe_project_data_dir(&home);
assert!(
result.is_err(),
"proof writers should be blocked by safe_project_data_dir for home dir"
);
}
}
#[test]
fn proof_writers_accept_normal_project() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proof-project");
std::fs::create_dir_all(&project).unwrap();
let result = safe_project_data_dir(&project);
assert!(result.is_ok());
let proofs_dir = result.unwrap().join("proofs");
std::fs::create_dir_all(&proofs_dir).unwrap();
assert!(proofs_dir.exists());
}
// ---------------------------------------------------------------------------
// Windows-specific edge cases (path format variants)
// ---------------------------------------------------------------------------
#[test]
fn backslash_windows_root_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new("\\")));
}
#[test]
fn agent_sandbox_with_subpath_is_broad() {
assert!(is_broad_or_unsafe_root(Path::new(
"/home/cowwoc/.claude/projects/myapp"
)));
assert!(is_broad_or_unsafe_root(Path::new(
"/home/cowwoc/.codex/workspace"
)));
}
// ---------------------------------------------------------------------------
// Data dir collision: direct collision check
// ---------------------------------------------------------------------------
#[test]
fn collision_detects_when_project_lean_ctx_equals_data_dir() {
// The parent-of-data-dir is only a collision by construction in the
// legacy layout (`<parent>/.lean-ctx` == data dir). In the XDG layout
// (`~/.config/lean-ctx`) the parent's project dir would be
// `~/.config/.lean-ctx` — a different path and correctly NOT a
// collision, so asserting there (as CI runners do) is wrong.
if let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir()
&& data_dir.file_name().is_some_and(|n| n == ".lean-ctx")
&& let Some(parent) = data_dir.parent()
{
assert!(
is_data_dir_collision(parent),
"parent of legacy data dir should be detected as collision"
);
}
}
// ---------------------------------------------------------------------------
// find_project_root: CWD fallback guard
// ---------------------------------------------------------------------------
#[test]
fn cwd_fallback_rejects_home_in_server_derive() {
use lean_ctx::core::pathutil::is_broad_or_unsafe_root;
if let Some(home) = dirs::home_dir() {
assert!(
is_broad_or_unsafe_root(&home),
"CWD fallback should reject home dir"
);
}
}
// ---------------------------------------------------------------------------
// Combined scenario: cowwoc's exact setup
// ---------------------------------------------------------------------------
#[test]
fn cowwoc_scenario_home_as_project_root() {
use lean_ctx::core::context_overlay::OverlayStore;
use lean_ctx::core::context_policies::PolicySet;
if let Some(home) = dirs::home_dir() {
let overlay_result = OverlayStore::new().save_project(&home);
assert!(
overlay_result.is_err(),
"cowwoc scenario: overlay save to ~ should be blocked"
);
let overlay_store = OverlayStore::load_project(&home);
assert!(
overlay_store.all().is_empty(),
"cowwoc scenario: overlay load from ~ should return empty"
);
let policy_result = PolicySet::defaults().save_project(&home);
assert!(
policy_result.is_err(),
"cowwoc scenario: policy save to ~ should be blocked"
);
let safe_dir_result = safe_project_data_dir(&home);
assert!(
safe_dir_result.is_err(),
"cowwoc scenario: proof write to ~ should be blocked"
);
}
}
#[test]
fn normal_project_all_writes_succeed() {
use lean_ctx::core::context_field::ContextItemId;
use lean_ctx::core::context_overlay::{
ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
};
use lean_ctx::core::context_policies::PolicySet;
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("healthy-project");
std::fs::create_dir_all(&project).unwrap();
let mut overlay_store = OverlayStore::new();
overlay_store.add(ContextOverlay::new(
ContextItemId::from_file("lib.rs"),
OverlayOp::Include,
OverlayScope::Project,
"hash".into(),
OverlayAuthor::User,
));
overlay_store.save_project(&project).expect("overlay save");
let policies = PolicySet::defaults();
policies.save_project(&project).expect("policy save");
let proof_dir = safe_project_data_dir(&project)
.expect("safe dir")
.join("proofs");
std::fs::create_dir_all(&proof_dir).expect("create proofs dir");
assert!(project.join(".lean-ctx").join("overlays.json").exists());
assert!(project.join(".lean-ctx").join("policies.json").exists());
assert!(project.join(".lean-ctx").join("proofs").is_dir());
}