Files
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

157 lines
5.9 KiB
Rust

//! #475 end-to-end: a configured read-only root (`read_only_roots` /
//! `LEAN_CTX_READ_ONLY_ROOTS`) is fully **readable** through the real
//! `ContextEngine` tool dispatch — the same path the MCP server drives — yet
//! **no write tool can mutate it**, while ordinary writes inside the project
//! root keep working.
//!
//! This is the integration-level counterpart to the unit guards at every write
//! choke point: it exercises `resolve_path` (jail allow-list widened by the
//! read-only roots) → tool `handle` → `pathjail::enforce_writable`, proving the
//! tier holds across the *whole* request, not just the low-level primitive.
//!
//! A prior attempt (#464) was rejected because writes could escape the
//! read-only tier; this test is the regression gate against that recurring.
#![cfg(not(feature = "no-jail"))]
// The test_env_lock guard is intentionally held across the async tool calls to
// serialize the process-global env mutation (LEAN_CTX_READ_ONLY_ROOTS) against
// other tests — the same pattern as power_user_worksession.rs.
#![allow(clippy::await_holding_lock)]
use serde_json::json;
/// Mutating the process environment is unsafe in Rust 2024 because it is not
/// thread-safe; this test serializes every environment access through
/// `test_env_lock`, so the precondition holds.
fn set_env(key: &str, value: &std::path::Path) {
// SAFETY: the caller holds `test_env_lock` for the whole test, so no other
// thread reads or writes the environment concurrently.
unsafe { std::env::set_var(key, value) };
}
fn clear_env(key: &str) {
// SAFETY: see `set_env`.
unsafe { std::env::remove_var(key) };
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn read_only_root_is_readable_but_never_writable_via_engine() {
let _lock = lean_ctx::core::data_dir::test_env_lock();
let dir = tempfile::tempdir().expect("tempdir");
let project = dir.path().join("project");
let refrepo = dir.path().join("refrepo");
std::fs::create_dir_all(project.join("src")).expect("project/src");
std::fs::create_dir_all(&refrepo).expect("refrepo");
// A project marker keeps the jail root stable (no auto-reroot heuristics).
std::fs::write(
project.join("Cargo.toml"),
"[package]\nname = \"p\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.expect("Cargo.toml");
let proj_file = project.join("src/main.rs");
std::fs::write(&proj_file, "fn main() { let x = 1; }\n").expect("main.rs");
let ref_file = refrepo.join("lib.rs");
let ref_original = "pub fn shared_secret() -> i32 { 42 }\n";
std::fs::write(&ref_file, ref_original).expect("refrepo/lib.rs");
let data_dir = dir.path().join("data");
std::fs::create_dir_all(&data_dir).expect("data");
set_env("LEAN_CTX_DATA_DIR", &data_dir);
// The feature under test: the sibling repo is a read-only root.
set_env("LEAN_CTX_READ_ONLY_ROOTS", &refrepo);
let engine = lean_ctx::engine::ContextEngine::with_project_root(&project);
// 1) READ of a file in the read-only root must resolve and return content
// (the whole point of read-only roots: read sibling repos).
let read_out = engine
.call_tool_text(
"ctx_read",
Some(json!({ "path": ref_file.to_string_lossy() })),
)
.await
.expect("ctx_read call");
// 2) WRITE (edit) into the read-only root must be refused.
let edit_ro = engine
.call_tool_text(
"ctx_edit",
Some(json!({
"path": ref_file.to_string_lossy(),
"old_string": "42",
"new_string": "999",
})),
)
.await
.expect("ctx_edit (read-only) call");
// 3) CREATE of a new file inside the read-only root must be refused too.
let create_ro = engine
.call_tool_text(
"ctx_edit",
Some(json!({
"path": refrepo.join("injected.rs").to_string_lossy(),
"old_string": "",
"new_string": "pub fn injected() {}\n",
"create": true,
})),
)
.await
.expect("ctx_edit (create in read-only) call");
// 4) CONTROL: a normal edit inside the project root still works.
let edit_ok = engine
.call_tool_text(
"ctx_edit",
Some(json!({
"path": proj_file.to_string_lossy(),
"old_string": "let x = 1;",
"new_string": "let x = 2;",
})),
)
.await
.expect("ctx_edit (project) call");
// Snapshot disk state, then drop the env before asserting so a failure can
// never leak the read-only root into a parallel test sharing the lock.
let ref_after = std::fs::read_to_string(&ref_file).expect("read refrepo file");
let proj_after = std::fs::read_to_string(&proj_file).expect("read project file");
let injected_exists = refrepo.join("injected.rs").exists();
clear_env("LEAN_CTX_READ_ONLY_ROOTS");
clear_env("LEAN_CTX_DATA_DIR");
// Reads are allowed.
assert!(
read_out.contains("shared_secret"),
"a read of a read-only root must return its content: {read_out}"
);
// Every write into the read-only root is denied and names the tier.
assert!(
edit_ro.contains("read-only"),
"editing a file in a read-only root must be refused: {edit_ro}"
);
assert!(
create_ro.contains("read-only"),
"creating a file in a read-only root must be refused: {create_ro}"
);
// The read-only tree is byte-identical — nothing leaked through any tool.
assert_eq!(
ref_after, ref_original,
"the read-only file must be untouched"
);
assert!(
!injected_exists,
"no file may be created inside a read-only root"
);
// The project write applied normally — the feature does not break writes.
assert!(
proj_after.contains("let x = 2;"),
"a normal project edit must still apply: {edit_ok}"
);
}