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
454 lines
14 KiB
Rust
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());
|
|
}
|