Single-commit clean baseline after security scrub of niche-tells, project codenames, internal jargon, and contributor-email leaks. Contents: - 100 Rust crates (_primitives/_rust/) - 37 agent manifests (_manifests/) + generated specs (_generated/) - 67 user-invocable skills (skills/) - 33 hooks (hooks/) - Composition blocks (_blocks/) - Documentation (docs/, README.md) - TS adapter packages (_ts_packages/) - Assembler (_assembler/) - Roles (_roles/) - Templates (_templates/) - Forgejo CI (.forgejo/) Author: Denis Parfionovich <info@greendragon.info> License: see LICENSE.
115 lines
4.2 KiB
Rust
115 lines
4.2 KiB
Rust
//! MEDIUM-severity hardening of `safe_join`.
|
|
//!
|
|
//! Covers two regressions that the original lexical-fallback implementation
|
|
//! missed:
|
|
//! 1. Accepting a non-existent `base` (no well-defined sandbox).
|
|
//! 2. Accepting a symlinked target that escapes `base`.
|
|
|
|
use kei_atom_discovery::{safe_join, Error};
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn safe_join_rejects_nonexistent_base() {
|
|
let tmp = tempdir().unwrap();
|
|
let ghost = tmp.path().join("does-not-exist");
|
|
// `ghost` was never created → canonicalize fails → safe_join rejects.
|
|
let err = safe_join(&ghost, "schemas/foo.json").expect_err("must reject ghost base");
|
|
assert!(
|
|
matches!(err, Error::Canonicalize { .. }),
|
|
"expected Canonicalize, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn safe_join_accepts_valid_existing_base_and_rel() {
|
|
let tmp = tempdir().unwrap();
|
|
let target = tmp.path().join("schemas");
|
|
fs::create_dir_all(&target).unwrap();
|
|
let joined = safe_join(tmp.path(), "schemas").expect("valid join");
|
|
assert!(joined.ends_with("schemas"));
|
|
}
|
|
|
|
#[test]
|
|
fn safe_join_accepts_nonexistent_rel_when_parent_exists() {
|
|
// Parent-dir canonicalize succeeds → no symlink can redirect → accept.
|
|
let tmp = tempdir().unwrap();
|
|
let joined =
|
|
safe_join(tmp.path(), "not-yet-created.json").expect("nonexistent rel should join");
|
|
assert!(joined.ends_with("not-yet-created.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn safe_join_accepts_deeply_nonexistent_rel() {
|
|
// Neither the file nor its parent dir exists → no symlink can live here.
|
|
let tmp = tempdir().unwrap();
|
|
let joined = safe_join(tmp.path(), "brand/new/tree/file.json")
|
|
.expect("deeply nonexistent rel should join");
|
|
assert!(joined.ends_with("brand/new/tree/file.json"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn safe_join_rejects_symlink_escape() {
|
|
use std::os::unix::fs::symlink as unix_symlink;
|
|
|
|
// Layout:
|
|
// outside_root/secret.json ← the attacker target
|
|
// sandbox/ ← our safe base
|
|
// sandbox/escape -> ../outside_root ← symlinked dir
|
|
//
|
|
// `safe_join(sandbox, "escape/secret.json")` must REJECT: after
|
|
// canonicalisation, the resolved path leaves `sandbox`.
|
|
let tmp = tempdir().unwrap();
|
|
let outside_root = tmp.path().join("outside_root");
|
|
let sandbox = tmp.path().join("sandbox");
|
|
fs::create_dir_all(&outside_root).unwrap();
|
|
fs::create_dir_all(&sandbox).unwrap();
|
|
fs::write(outside_root.join("secret.json"), "pwned").unwrap();
|
|
unix_symlink(&outside_root, sandbox.join("escape")).unwrap();
|
|
|
|
let err = safe_join(&sandbox, "escape/secret.json")
|
|
.expect_err("symlink-escape must be rejected");
|
|
assert!(
|
|
matches!(err, Error::PathEscape { .. }),
|
|
"expected PathEscape, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn safe_join_rejects_symlink_escape_to_nonexistent_target() {
|
|
// Same shape as above, but the dangling target inside outside_root doesn't
|
|
// exist. The parent (`escape`) still canonicalizes into `outside_root`, so
|
|
// the escape must still be detected.
|
|
use std::os::unix::fs::symlink as unix_symlink;
|
|
|
|
let tmp = tempdir().unwrap();
|
|
let outside_root = tmp.path().join("outside_root2");
|
|
let sandbox = tmp.path().join("sandbox2");
|
|
fs::create_dir_all(&outside_root).unwrap();
|
|
fs::create_dir_all(&sandbox).unwrap();
|
|
unix_symlink(&outside_root, sandbox.join("escape")).unwrap();
|
|
|
|
let err = safe_join(&sandbox, "escape/not-yet.json")
|
|
.expect_err("symlink-escape with nonexistent tail must be rejected");
|
|
assert!(
|
|
matches!(err, Error::PathEscape { .. }),
|
|
"expected PathEscape, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn safe_join_accepts_symlink_that_stays_inside_base() {
|
|
// A symlink that resolves BACK INTO the sandbox must still be accepted.
|
|
use std::os::unix::fs::symlink as unix_symlink;
|
|
|
|
let tmp = tempdir().unwrap();
|
|
let sandbox = tmp.path().join("sandbox3");
|
|
fs::create_dir_all(sandbox.join("schemas")).unwrap();
|
|
unix_symlink(sandbox.join("schemas"), sandbox.join("alias")).unwrap();
|
|
|
|
let ok = safe_join(&sandbox, "alias").expect("inside-base symlink is fine");
|
|
assert!(ok.ends_with("alias"));
|
|
}
|