Skip to content

Commit c7dca58

Browse files
authored
Reject path separators in temp file/dir affixes (#14)
## What `TempFile`/`TempDir` builders compose the name as `{prefix}{random}{suffix}`. `create_with_affixes` passed that straight to `dir.join(...)` with no validation, so a path separator in `prefix`/`suffix` could make the name escape the target directory: - `Path::join` treats an absolute fragment as a full replacement of `dir` — prefix `/tmp/abs_` created a file **outside** the target dir. - `../` is a parent traversal — escaped one level up into the parent. Both were confirmed by failing tests before the fix. ## Fix - Add `affix_is_safe` (in `lib.rs`): rejects any affix containing a path separator via `std::path::is_separator` (`/` on all platforms, `\` on Windows). - Check it in both `create_with_affixes` before touching the filesystem. File path returns `Error::InvalidFile`; dir path returns `Error::InvalidDirectory`. No public API change. - Matches the `tempfile` crate, which likewise forbids separators in affixes. Severity is low (affixes are programmer-supplied, not typically end-user input), but it's an undocumented footgun worth closing. ## Tests - `tests/adversarial_affixes.rs`: - separator / `../` traversal / absolute affixes rejected for both file and dir - traversal containment verified by scanning the parent dir (nothing leaks up) - embedded NUL and a 5000-char prefix surface as a clean `Err` (no panic, no infinite retry loop) - ordinary affixes still work and stay inside the target dir - `src/random_name.rs`: 10k-iteration uniqueness + prefix guard for the name generator, plus an `affix_is_safe` truth table ## Review order 1. `src/lib.rs` — the `affix_is_safe` helper + rationale. 2. `src/tempfile.rs` / `src/tempdir.rs` — the two checkpoints (identical shape). 3. `tests/adversarial_affixes.rs` — the behavior being locked in. ## Verification `cargo test`, `cargo test --features uuid`, `cargo clippy --all-features --all-targets`, `cargo fmt --check` all clean. ## Note Considered a `proptest` over generated affixes but skipped it: the name-uniqueness property has no input domain to generate/shrink over, and random affixes hitting the real filesystem is just slow fuzzing. Explicit adversarial cases pin the risk better. Easy to add later if generated-input coverage is wanted.
1 parent 6d1cd65 commit c7dca58

6 files changed

Lines changed: 223 additions & 0 deletions

File tree

src/errors.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ pub enum Error {
77
InvalidDirectory,
88
/// An invalid or missing file was specified.
99
InvalidFile,
10+
/// A name affix (prefix or suffix) contained a path separator.
11+
///
12+
/// Affixes are composed into a single path component
13+
/// (`{prefix}{random}{suffix}`); a separator would let the name escape the
14+
/// target directory, so such affixes are rejected before any filesystem
15+
/// access. The target directory itself is unaffected (and still valid).
16+
InvalidAffix,
1017
/// An I/O error occurred.
1118
Io(std::io::Error),
1219
}
@@ -16,6 +23,9 @@ impl Display for Error {
1623
match self {
1724
Self::InvalidDirectory => write!(f, "An invalid directory was specified"),
1825
Self::InvalidFile => write!(f, "An invalid file name was specified"),
26+
Self::InvalidAffix => {
27+
write!(f, "A name prefix or suffix contained a path separator")
28+
}
1929
Self::Io(e) => Display::fmt(e, f),
2030
}
2131
}

src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ pub(crate) async fn path_is_file(path: &Path) -> bool {
6565
.unwrap_or(false)
6666
}
6767

68+
/// Returns `true` if `affix` is safe to splice into a file name.
69+
///
70+
/// `prefix`/`suffix` are composed into a single path component
71+
/// (`{prefix}{random}{suffix}`). A path separator in either would let the
72+
/// composed name escape the target directory once joined onto it: `Path::join`
73+
/// replaces the base entirely when the fragment is absolute, and a fragment
74+
/// containing `..` resolves to a parent directory when the OS interprets the
75+
/// resulting path. We therefore reject any affix containing a separator
76+
/// ([`std::path::is_separator`], which is platform-aware: `/` everywhere, plus
77+
/// `\` on Windows) rather than letting it reach the filesystem.
78+
pub(crate) fn affix_is_safe(affix: &str) -> bool {
79+
!affix.chars().any(std::path::is_separator)
80+
}
81+
6882
/// Determines the ownership of a temporary file or directory.
6983
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
7084
pub enum Ownership {

src/random_name.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,51 @@ mod tests {
6161
assert!(second.as_str().starts_with("test"));
6262
assert_ne!(first.as_str(), second.as_str());
6363
}
64+
65+
/// Property guard for the name generator: across many rapid, same-prefix
66+
/// calls every name must be distinct (no collisions from the monotonic
67+
/// counter or entropy mixing) and carry the requested prefix. Guards against
68+
/// regressions in the counter / entropy / format wiring.
69+
#[test]
70+
fn names_are_unique_and_prefixed_over_many_iterations() {
71+
use std::collections::HashSet;
72+
73+
const ITERATIONS: usize = 10_000;
74+
let mut seen = HashSet::with_capacity(ITERATIONS);
75+
for _ in 0..ITERATIONS {
76+
let name = RandomName::new("px_");
77+
assert!(
78+
name.as_str().starts_with("px_"),
79+
"missing prefix: {}",
80+
name.as_str()
81+
);
82+
assert!(
83+
seen.insert(name.as_str().to_string()),
84+
"duplicate name generated: {}",
85+
name.as_str()
86+
);
87+
}
88+
}
89+
90+
/// Affixes containing a path separator must be flagged unsafe so they can be
91+
/// rejected before reaching the filesystem (see `crate::affix_is_safe`).
92+
#[test]
93+
fn separator_bearing_affixes_are_unsafe() {
94+
assert!(crate::affix_is_safe("ok_"));
95+
assert!(crate::affix_is_safe(".log"));
96+
assert!(crate::affix_is_safe("")); // empty is fine
97+
assert!(crate::affix_is_safe("..")); // no separator -> still a plain name fragment
98+
99+
assert!(!crate::affix_is_safe("../"));
100+
assert!(!crate::affix_is_safe("a/b"));
101+
assert!(!crate::affix_is_safe("/etc/passwd"));
102+
103+
// Backslash is a path separator only on Windows. `is_separator` is
104+
// platform-aware, so the expectation differs by target: rejected on
105+
// Windows, an ordinary filename character elsewhere.
106+
#[cfg(windows)]
107+
assert!(!crate::affix_is_safe("a\\b"));
108+
#[cfg(not(windows))]
109+
assert!(crate::affix_is_safe("a\\b"));
110+
}
64111
}

src/tempdir.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,12 @@ impl TempDir {
449449
if !crate::path_is_dir(root).await {
450450
return Err(Error::InvalidDirectory);
451451
}
452+
// Affixes are name fragments, not paths: a separator would let the
453+
// composed name escape `root` (`../` traversal, or an absolute prefix
454+
// replacing it via `Path::join`).
455+
if !crate::affix_is_safe(prefix) || !crate::affix_is_safe(suffix) {
456+
return Err(Error::InvalidAffix);
457+
}
452458
let mut last_err = None;
453459
for _ in 0..MAX_NAME_ATTEMPTS {
454460
let name = format!("{prefix}{}{suffix}", Self::random_core_name());

src/tempfile.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,12 @@ impl TempFile {
517517
if !crate::path_is_dir(dir).await {
518518
return Err(Error::InvalidDirectory);
519519
}
520+
// Affixes are filename fragments, not paths: a separator would let the
521+
// composed name escape `dir` (`../` traversal, or an absolute prefix
522+
// replacing it via `Path::join`).
523+
if !crate::affix_is_safe(prefix) || !crate::affix_is_safe(suffix) {
524+
return Err(Error::InvalidAffix);
525+
}
520526
let mut last_err = None;
521527
for _ in 0..MAX_NAME_ATTEMPTS {
522528
let name = format!("{prefix}{}{suffix}", Self::random_core_name());

tests/adversarial_affixes.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! Adversarial `prefix` / `suffix` handling.
2+
//!
3+
//! Affixes are composed into a single path component
4+
//! (`{prefix}{random}{suffix}`). These tests pin down the behavior for hostile
5+
//! affixes:
6+
//!
7+
//! * Path separators / traversal must be **rejected** so a name can never escape
8+
//! the target directory (`../` traversal, or an absolute prefix that would
9+
//! otherwise replace the target via `Path::join`).
10+
//! * Names the OS itself rejects (an embedded NUL, an over-long component) must
11+
//! surface as a clean `Err`, never a panic or an infinite retry loop.
12+
//! * Ordinary affixes keep working and stay inside the target directory.
13+
14+
use async_tempfile::{TempDir, TempFile};
15+
16+
// --- path separators / traversal are rejected -------------------------------
17+
18+
#[tokio::test]
19+
async fn file_prefix_with_path_separator_is_rejected() {
20+
let dir = TempDir::new().await.unwrap();
21+
let result = TempFile::builder()
22+
.prefix("sub/evil_")
23+
.dir(dir.dir_path().clone())
24+
.create()
25+
.await;
26+
assert!(result.is_err(), "separator in prefix must be rejected");
27+
}
28+
29+
#[tokio::test]
30+
async fn file_suffix_with_path_separator_is_rejected() {
31+
let dir = TempDir::new().await.unwrap();
32+
let result = TempFile::builder()
33+
.suffix("/etc/passwd")
34+
.dir(dir.dir_path().clone())
35+
.create()
36+
.await;
37+
assert!(result.is_err(), "separator in suffix must be rejected");
38+
}
39+
40+
#[tokio::test]
41+
async fn file_traversal_prefix_cannot_escape_target_dir() {
42+
// A nested target dir so an escape would be observable: `../` would land the
43+
// file in the parent (`root`) instead of `target`.
44+
let root = TempDir::new().await.unwrap();
45+
let target = TempDir::new_in(root.dir_path().as_path()).await.unwrap();
46+
47+
let result = TempFile::builder()
48+
.prefix("../")
49+
.dir(target.dir_path().clone())
50+
.create()
51+
.await;
52+
53+
assert!(result.is_err(), "`../` traversal must be rejected");
54+
55+
// Nothing leaked one level up into `root`.
56+
let mut entries = tokio::fs::read_dir(root.dir_path()).await.unwrap();
57+
while let Some(entry) = entries.next_entry().await.unwrap() {
58+
assert_eq!(
59+
entry.path(),
60+
*target.dir_path(),
61+
"unexpected entry escaped into the parent dir: {:?}",
62+
entry.path()
63+
);
64+
}
65+
}
66+
67+
#[tokio::test]
68+
async fn file_absolute_prefix_is_rejected() {
69+
let dir = TempDir::new().await.unwrap();
70+
let result = TempFile::builder()
71+
.prefix("/tmp/abs_")
72+
.dir(dir.dir_path().clone())
73+
.create()
74+
.await;
75+
assert!(
76+
result.is_err(),
77+
"absolute (separator-bearing) prefix must be rejected"
78+
);
79+
}
80+
81+
#[tokio::test]
82+
async fn dir_prefix_with_path_separator_is_rejected() {
83+
let root = TempDir::new().await.unwrap();
84+
let result = TempDir::builder()
85+
.prefix("../escaped_")
86+
.dir(root.dir_path().clone())
87+
.create()
88+
.await;
89+
assert!(result.is_err(), "separator in dir prefix must be rejected");
90+
}
91+
92+
// --- OS-rejected names surface as a clean `Err`, never a panic --------------
93+
94+
#[tokio::test]
95+
async fn file_nul_byte_in_suffix_errors_without_panic() {
96+
let dir = TempDir::new().await.unwrap();
97+
let result = TempFile::builder()
98+
.suffix("\0bad")
99+
.dir(dir.dir_path().clone())
100+
.create()
101+
.await;
102+
assert!(result.is_err(), "embedded NUL must error, not panic");
103+
}
104+
105+
#[tokio::test]
106+
async fn file_overlong_prefix_errors_without_panic() {
107+
let dir = TempDir::new().await.unwrap();
108+
let result = TempFile::builder()
109+
.prefix("a".repeat(5000))
110+
.dir(dir.dir_path().clone())
111+
.create()
112+
.await;
113+
assert!(result.is_err(), "over-long name must error, not panic");
114+
}
115+
116+
// --- ordinary affixes keep working and stay contained -----------------------
117+
118+
#[tokio::test]
119+
async fn ordinary_affixes_still_work_and_stay_contained() {
120+
let dir = TempDir::new().await.unwrap();
121+
let file = TempFile::builder()
122+
.prefix("ok_")
123+
.suffix(".log")
124+
.dir(dir.dir_path().clone())
125+
.create()
126+
.await
127+
.unwrap();
128+
129+
let name = file
130+
.file_path()
131+
.file_name()
132+
.unwrap()
133+
.to_string_lossy()
134+
.into_owned();
135+
assert!(name.starts_with("ok_"), "name was {name}");
136+
assert!(name.ends_with(".log"), "name was {name}");
137+
138+
// The file lives directly inside the requested directory.
139+
assert_eq!(file.file_path().parent().unwrap(), dir.dir_path());
140+
}

0 commit comments

Comments
 (0)