KeiSeiKit-1.0/_primitives/_rust/kei-atom-discovery/src/path_safety.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
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.
2026-05-01 12:09:03 +08:00

81 lines
2.7 KiB
Rust

//! Path-traversal-safe base+rel join.
//!
//! `safe_join` is the authoritative base+rel path-join: rejects absolute
//! components and `..`, canonicalises, asserts base containment (including
//! post-canonicalise symlink escapes).
use crate::error::Error;
use std::path::{Component, Path, PathBuf};
/// Safe base+rel path join.
///
/// Rejects absolute paths, parent (`..`) components, non-existent bases,
/// and post-canonicalise escapes from `base` (including symlink escapes).
///
/// Contract:
/// - `base` MUST canonicalize (i.e. must exist as a real directory). A
/// non-existent base means the caller is not in a well-defined sandbox
/// and we refuse to construct a join.
/// - If `joined` canonicalizes, its real path MUST start with `base_canon`.
/// - If `joined` does not exist, we canonicalize `joined.parent()` and
/// require that to start with `base_canon`. This catches symlinked
/// parent directories that redirect outside the sandbox.
/// - If neither `joined` nor `joined.parent()` exist, no symlink can
/// possibly live there — the lexical (absolute + parent-free) check
/// already completed is sufficient.
pub fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, Error> {
let rel_path = reject_bad_rel(rel)?;
let joined = base.join(rel_path);
let base_canon = canonicalize_base(base)?;
assert_joined_inside_base(&joined, &base_canon, rel)?;
Ok(joined)
}
fn reject_bad_rel(rel: &str) -> Result<&Path, Error> {
let rel_path = Path::new(rel);
if rel_path.is_absolute() {
return Err(Error::PathAbsolute(rel.to_string()));
}
for comp in rel_path.components() {
if matches!(comp, Component::ParentDir) {
return Err(Error::PathParent(rel.to_string()));
}
}
Ok(rel_path)
}
fn canonicalize_base(base: &Path) -> Result<PathBuf, Error> {
base.canonicalize().map_err(|source| Error::Canonicalize {
path: base.to_path_buf(),
source,
})
}
fn assert_joined_inside_base(
joined: &Path,
base_canon: &Path,
rel: &str,
) -> Result<(), Error> {
if let Ok(jc) = joined.canonicalize() {
return check_contained(&jc, base_canon, rel);
}
let Some(parent) = joined.parent() else {
return Ok(());
};
let Ok(pc) = parent.canonicalize() else {
// Grand-parent also doesn't exist — no symlink can live here.
return Ok(());
};
check_contained(&pc, base_canon, rel)
}
fn check_contained(candidate: &Path, base_canon: &Path, rel: &str) -> Result<(), Error> {
if candidate.starts_with(base_canon) {
Ok(())
} else {
Err(Error::PathEscape {
base: base_canon.to_path_buf(),
rel: rel.to_string(),
})
}
}