Skip to content

Commit 2dc1393

Browse files
feat(safe_path): port proven SafePath as pure-Rust + add traversal guard (#115) (#120)
## Summary PROOF-PROGRAMME row 3 candidate #1: port `hyperpolymath/proven`'s `SafePath::has_traversal` + `sanitize_filename` to pure Rust. **Not via libproven.so FFI** — dylib build dep too heavy for `cargo install panic-attack`. Closes the first part of issue #115. The companion `SafeUrl` port is the next slice. ## What this adds `src/safe_path.rs`: | Function | Behaviour | Reference | |---|---|---| | `has_traversal(path: &str) -> bool` | Splits on `/` or `\\`, returns true if any segment equals `".."`. Empty segments dropped. | proven `Operations.idr containsTraversal` | | `sanitize_filename(s: &str) -> String` | Keeps ASCII alphanumeric + `-` / `_` / `.` / `~`. Returns `"_"` if filtering would produce empty. | proven `sanitizeSegment` | ## Wire-in Two sites in `src/main.rs` (the BridgeAction Triage + Status arms at 2360 + 2423) previously used `fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone())` — the silent-fallback pattern that let `../` slip past on canonicalize failure. Each arm now calls `has_traversal` first and bails with an explanatory error if a `..` segment is detected, BEFORE any disk I/O. The two `src/abduct/mod.rs` canonicalize sites (lines 123 + 266) were already safe — they use `with_context` + `?` to propagate errors rather than `unwrap_or_else` silent-fallback. Left untouched. ## Why pure-Rust port, not FFI The 2026-06-02 proven cross-fit survey (PROOF-PROGRAMME.md §Proven cross-fit) explicitly recommended port-to-Rust for this candidate: > Both NOT via FFI — `libproven.so` dylib build dep too heavy for `cargo install panic-attack`'s distribution model. Pure-Rust keeps panic-attack a single-binary install. ## Verification — proptest as the contract 23 new tests: - **18 concrete cases**: dotdot-at-start / middle / end, backslash separator, mixed separators, empty input, single dot (NOT traversal), dotdot-inside-name (NOT traversal — `foo..bar` is one segment), normal paths, consecutive separators, etc. - **5 proptest invariants** matching the proven Idris2 reference lemmas: 1. `sanitize` output always non-empty 2. `sanitize` output contains only safe characters 3. `sanitize` is idempotent 4. `has_traversal` operational spec (segment-split oracle agreement) 5. Safe prefix preserves no-traversal Each proptest runs 256 random cases per invariant. The Idris2 reference is NOT a build dep; the proptest invariants are the contract. If proven's semantics ever change, the invariants need to be re-checked against the new Idris2 spec. ## Test count - `cargo build --release`: clean - `cargo test --release --lib`: **382 pass / 0 fail** (359 baseline + 23 new `safe_path` tests) ## What this PR does NOT do - Port `SafeUrl` — the second part of #115. Will land separately because `src/storage/mod.rs:1071` needs more careful audit (the VERISIMDB_URL currently flows through unvalidated, and the migration needs to preserve compatibility with localhost-default behaviour). - Touch `src/abduct/mod.rs` — already-safe call sites. ## Refs - PROOF-PROGRAMME.md row 3 - Issue #115 — proven cross-fit candidates - hyperpolymath/proven `src/Proven/SafePath/Operations.idr` — the reference
1 parent 833f394 commit 2dc1393

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub mod notify;
3737
pub mod panll;
3838
pub mod query;
3939
pub mod report;
40+
pub mod safe_path;
4041
pub mod signatures;
4142
pub mod storage;
4243
pub mod sweep_tracker;

src/main.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2357,6 +2357,20 @@ fn run_main() -> Result<()> {
23572357
offline,
23582358
register,
23592359
} => {
2360+
// Reject path traversal sequences BEFORE any disk I/O.
2361+
// The prior `canonicalize().unwrap_or_else(|_| dir.clone())`
2362+
// pattern silently fell back to the un-canonicalized path
2363+
// on failure, which let `../`-laden inputs slip past.
2364+
// safe_path::has_traversal is a pure-Rust port of
2365+
// proven::SafePath::containsTraversal (issue #115).
2366+
if let Some(dir_str) = dir.to_str() {
2367+
if panic_attack::safe_path::has_traversal(dir_str) {
2368+
anyhow::bail!(
2369+
"rejected: directory contains '..' traversal sequence: {}",
2370+
dir.display()
2371+
);
2372+
}
2373+
}
23602374
let project_dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
23612375

23622376
qprintln!(cli.quiet, "Patch Bridge triage: {}", project_dir.display());
@@ -2420,6 +2434,15 @@ fn run_main() -> Result<()> {
24202434
}
24212435

24222436
BridgeAction::Status { dir } => {
2437+
// Same safe_path::has_traversal guard as the triage arm.
2438+
if let Some(dir_str) = dir.to_str() {
2439+
if panic_attack::safe_path::has_traversal(dir_str) {
2440+
anyhow::bail!(
2441+
"rejected: directory contains '..' traversal sequence: {}",
2442+
dir.display()
2443+
);
2444+
}
2445+
}
24232446
let project_dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
24242447

24252448
let registry = bridge::registry::MitigationRegistry::load(&project_dir)?;

src/safe_path.rs

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
3+
//! Safe path operations — Rust port of `proven::SafePath` (PROOF-PROGRAMME row 3, issue #115).
4+
//!
5+
//! Provides two operations validated against `hyperpolymath/proven`'s Idris2
6+
//! reference at `src/Proven/SafePath/Operations.idr`:
7+
//!
8+
//! * [`has_traversal`] — true if any path component is `..` (directory-
9+
//! traversal sequence detected). Mirrors the reference's
10+
//! `containsTraversal` predicate using the same segment-splitting
11+
//! semantics: both `/` and `\` are treated as separators; empty
12+
//! segments are dropped; the segment `..` triggers detection.
13+
//!
14+
//! * [`sanitize_filename`] — returns a filename containing only
15+
//! alphanumeric characters plus `-`, `_`, `.`, `~`. If filtering
16+
//! would produce an empty string (e.g. all-emoji input), returns
17+
//! `"_"` so callers always get a non-empty result. Mirrors the
18+
//! reference's `sanitizeSegment` function.
19+
//!
20+
//! The proven reference is Idris2; this is a pure-Rust port with no
21+
//! libproven dylib dependency. The implementation is hand-written but
22+
//! the behaviour is locked in by proptest invariants in the tests
23+
//! module. The invariants are stated as Rust property tests so they
24+
//! re-check on every `cargo test` run, but they're semantically
25+
//! identical to the lemmas the Idris2 reference would prove:
26+
//!
27+
//! * `sanitize_filename` output is always non-empty.
28+
//! * `sanitize_filename` output contains only safe characters.
29+
//! * `sanitize_filename` is idempotent.
30+
//! * `has_traversal` returns true iff some component split by `/` or
31+
//! `\\` equals `".."` (the operational specification).
32+
//!
33+
//! Call sites replacing the prior `fs::canonicalize(..).unwrap_or_else(|_| dir)`
34+
//! pattern at `src/abduct/mod.rs:123,266` + `src/main.rs:2314,2377` will
35+
//! use [`has_traversal`] to short-circuit before any disk I/O.
36+
37+
/// Characters considered safe in a sanitized filename. Matches the
38+
/// `proven::SafePath::sanitizeSegment` reference: alphanumeric plus
39+
/// `-`, `_`, `.`, `~`.
40+
const SAFE_PUNCT: &[char] = &['-', '_', '.', '~'];
41+
42+
/// Returns true if `path` contains a directory-traversal sequence.
43+
///
44+
/// Splits the path on `/` and `\\` separators, drops empty segments
45+
/// (so leading / trailing / consecutive separators don't cause false
46+
/// negatives), and returns true if any remaining segment is exactly
47+
/// `".."`.
48+
///
49+
/// Examples:
50+
///
51+
/// ```
52+
/// use panic_attack::safe_path::has_traversal;
53+
///
54+
/// assert!(has_traversal("../etc/passwd"));
55+
/// assert!(has_traversal("foo/../bar"));
56+
/// assert!(has_traversal("foo/.."));
57+
/// assert!(has_traversal("..\\windows\\system32"));
58+
/// assert!(!has_traversal("foo/bar/baz"));
59+
/// assert!(!has_traversal(""));
60+
/// assert!(!has_traversal("./foo")); // single dot is not traversal
61+
/// assert!(!has_traversal("foo..bar")); // .. inside a name is not a segment
62+
/// ```
63+
pub fn has_traversal(path: &str) -> bool {
64+
for segment in path.split(|c| c == '/' || c == '\\') {
65+
if segment == ".." {
66+
return true;
67+
}
68+
}
69+
false
70+
}
71+
72+
/// Sanitize a filename by retaining only safe characters.
73+
///
74+
/// Keeps ASCII alphanumeric characters plus `-`, `_`, `.`, `~`. All
75+
/// other characters are dropped. If the result would be empty (e.g.
76+
/// an all-emoji or all-punctuation input), returns `"_"` so callers
77+
/// always receive a non-empty filename.
78+
///
79+
/// Examples:
80+
///
81+
/// ```
82+
/// use panic_attack::safe_path::sanitize_filename;
83+
///
84+
/// assert_eq!(sanitize_filename("hello world.txt"), "helloworld.txt");
85+
/// assert_eq!(sanitize_filename("../etc/passwd"), "..etcpasswd");
86+
/// assert_eq!(sanitize_filename("🚀🌟"), "_");
87+
/// assert_eq!(sanitize_filename(""), "_");
88+
/// assert_eq!(sanitize_filename("my-file_2026.06.02.tar.gz"), "my-file_2026.06.02.tar.gz");
89+
/// ```
90+
pub fn sanitize_filename(filename: &str) -> String {
91+
let cleaned: String = filename
92+
.chars()
93+
.filter(|c| c.is_ascii_alphanumeric() || SAFE_PUNCT.contains(c))
94+
.collect();
95+
if cleaned.is_empty() {
96+
"_".to_string()
97+
} else {
98+
cleaned
99+
}
100+
}
101+
102+
/// Returns true if `c` is a character `sanitize_filename` would keep.
103+
fn is_safe_char(c: char) -> bool {
104+
c.is_ascii_alphanumeric() || SAFE_PUNCT.contains(&c)
105+
}
106+
107+
/// Returns true if every character in `s` is safe. Used by the
108+
/// idempotence + non-empty proptest invariants.
109+
fn all_chars_safe(s: &str) -> bool {
110+
s.chars().all(is_safe_char)
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use proptest::prelude::*;
117+
118+
// ─────────────────────────────────────────────────────────────────
119+
// has_traversal — concrete cases
120+
// ─────────────────────────────────────────────────────────────────
121+
122+
#[test]
123+
fn dotdot_at_start() {
124+
assert!(has_traversal("../etc"));
125+
}
126+
127+
#[test]
128+
fn dotdot_in_middle() {
129+
assert!(has_traversal("foo/../bar"));
130+
}
131+
132+
#[test]
133+
fn dotdot_at_end() {
134+
assert!(has_traversal("foo/.."));
135+
}
136+
137+
#[test]
138+
fn backslash_separator() {
139+
assert!(has_traversal("..\\windows"));
140+
assert!(has_traversal("foo\\..\\bar"));
141+
}
142+
143+
#[test]
144+
fn mixed_separators() {
145+
assert!(has_traversal("foo/bar\\..\\baz"));
146+
}
147+
148+
#[test]
149+
fn empty_string_no_traversal() {
150+
assert!(!has_traversal(""));
151+
}
152+
153+
#[test]
154+
fn single_dot_no_traversal() {
155+
assert!(!has_traversal("./foo"));
156+
assert!(!has_traversal("foo/./bar"));
157+
}
158+
159+
#[test]
160+
fn dotdot_inside_name_not_traversal() {
161+
// Critical: `foo..bar` is a single segment named `foo..bar`,
162+
// not a traversal. The reference impl agrees.
163+
assert!(!has_traversal("foo..bar"));
164+
assert!(!has_traversal("..foo"));
165+
assert!(!has_traversal("foo.."));
166+
}
167+
168+
#[test]
169+
fn normal_path_no_traversal() {
170+
assert!(!has_traversal("src/main.rs"));
171+
assert!(!has_traversal("/usr/local/bin/panic-attack"));
172+
assert!(!has_traversal("C:\\Users\\Public\\Documents"));
173+
}
174+
175+
#[test]
176+
fn consecutive_separators_no_false_positive() {
177+
assert!(!has_traversal("foo//bar"));
178+
assert!(!has_traversal("foo///bar"));
179+
assert!(!has_traversal("//foo"));
180+
}
181+
182+
// ─────────────────────────────────────────────────────────────────
183+
// sanitize_filename — concrete cases
184+
// ─────────────────────────────────────────────────────────────────
185+
186+
#[test]
187+
fn sanitize_keeps_alphanumeric() {
188+
assert_eq!(sanitize_filename("hello123"), "hello123");
189+
}
190+
191+
#[test]
192+
fn sanitize_keeps_safe_punct() {
193+
assert_eq!(sanitize_filename("my-file_2026.06.02"), "my-file_2026.06.02");
194+
assert_eq!(sanitize_filename("config~"), "config~");
195+
}
196+
197+
#[test]
198+
fn sanitize_drops_separator() {
199+
assert_eq!(sanitize_filename("../etc"), "..etc");
200+
assert_eq!(sanitize_filename("foo\\bar"), "foobar");
201+
assert_eq!(sanitize_filename("foo/bar"), "foobar");
202+
}
203+
204+
#[test]
205+
fn sanitize_drops_whitespace() {
206+
assert_eq!(sanitize_filename("hello world"), "helloworld");
207+
assert_eq!(sanitize_filename("a\tb\nc"), "abc");
208+
}
209+
210+
#[test]
211+
fn sanitize_drops_unicode_non_ascii() {
212+
assert_eq!(sanitize_filename("café"), "caf");
213+
assert_eq!(sanitize_filename("naïve"), "nave");
214+
}
215+
216+
#[test]
217+
fn sanitize_empty_input_returns_underscore() {
218+
assert_eq!(sanitize_filename(""), "_");
219+
}
220+
221+
#[test]
222+
fn sanitize_all_unsafe_returns_underscore() {
223+
assert_eq!(sanitize_filename("🚀🌟"), "_");
224+
assert_eq!(sanitize_filename(" "), "_");
225+
assert_eq!(sanitize_filename("///"), "_");
226+
}
227+
228+
#[test]
229+
fn sanitize_keeps_dotfiles() {
230+
assert_eq!(sanitize_filename(".gitignore"), ".gitignore");
231+
assert_eq!(sanitize_filename(".env"), ".env");
232+
}
233+
234+
// ─────────────────────────────────────────────────────────────────
235+
// Property tests — semantic equivalence with proven reference
236+
// ─────────────────────────────────────────────────────────────────
237+
//
238+
// Each property mirrors a lemma the Idris2 reference would (or
239+
// does) state about its corresponding function. proptest with the
240+
// default config runs 256 random cases per invariant — small
241+
// enough to be fast (under 50ms) but enough coverage to catch
242+
// semantic regressions.
243+
244+
proptest! {
245+
/// **Invariant 1 — sanitize output is always non-empty.**
246+
/// Reference: SafePath's sanitizeSegment always returns
247+
/// `"_"` on empty / all-unsafe input.
248+
#[test]
249+
fn prop_sanitize_output_nonempty(s in ".*") {
250+
let out = sanitize_filename(&s);
251+
prop_assert!(!out.is_empty(), "sanitize_filename({:?}) was empty", s);
252+
}
253+
254+
/// **Invariant 2 — sanitize output contains only safe characters.**
255+
/// Reference: sanitizeSegment filters to `isAlphaNum c ||
256+
/// elem c (unpack "-_.~")` and never reintroduces unsafe chars.
257+
#[test]
258+
fn prop_sanitize_output_only_safe(s in ".*") {
259+
let out = sanitize_filename(&s);
260+
prop_assert!(
261+
all_chars_safe(&out),
262+
"sanitize_filename({:?}) produced unsafe characters in {:?}",
263+
s, out
264+
);
265+
}
266+
267+
/// **Invariant 3 — sanitize is idempotent.**
268+
/// Reference: applying sanitizeSegment to its own output is the
269+
/// identity (output is already safe).
270+
#[test]
271+
fn prop_sanitize_idempotent(s in ".*") {
272+
let once = sanitize_filename(&s);
273+
let twice = sanitize_filename(&once);
274+
prop_assert_eq!(
275+
once.clone(), twice,
276+
"sanitize_filename not idempotent on {:?}",
277+
s
278+
);
279+
}
280+
281+
/// **Invariant 4 — has_traversal operational spec.**
282+
/// `has_traversal(s)` is true iff splitting `s` on `/` or `\\`
283+
/// yields at least one segment equal to "..".
284+
#[test]
285+
fn prop_has_traversal_operational(s in ".*") {
286+
let split_says: bool = s
287+
.split(|c| c == '/' || c == '\\')
288+
.any(|seg| seg == "..");
289+
prop_assert_eq!(
290+
has_traversal(&s),
291+
split_says,
292+
"has_traversal({:?}) disagreed with the split-based oracle",
293+
s
294+
);
295+
}
296+
297+
/// **Invariant 5 — has_traversal is preserved by prefixing
298+
/// safe segments.** If a path has no traversal, prefixing it
299+
/// with a safe segment + separator preserves that property.
300+
#[test]
301+
fn prop_safe_prefix_preserves_no_traversal(
302+
prefix in "[a-zA-Z0-9_-]+",
303+
tail in r"[^/\\]*",
304+
) {
305+
// The tail might contain ".." as a substring but we generated
306+
// it to not contain separators, so it CAN'T have a `..`
307+
// segment. Combining them must still be traversal-free.
308+
let combined = format!("{}/{}", prefix, tail);
309+
// The combined path has traversal iff tail (as a single
310+
// segment) is exactly "..".
311+
let expected = tail == "..";
312+
prop_assert_eq!(has_traversal(&combined), expected);
313+
}
314+
}
315+
}

0 commit comments

Comments
 (0)