{"id":36236300,"url":"https://github.com/tenuo-ai/path_jail","last_synced_at":"2026-01-11T06:00:02.225Z","repository":{"id":330948040,"uuid":"1124502789","full_name":"tenuo-ai/path_jail","owner":"tenuo-ai","description":"Secure filesystem sandbox for Rust. Blocks path traversal and symlink escapes.","archived":false,"fork":false,"pushed_at":"2025-12-30T00:42:15.000Z","size":54,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-01-01T06:14:28.960Z","etag":null,"topics":["directory-traversal","filesystem","filesystem-security","rust","security"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tenuo-ai.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE-APACHE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-29T06:08:47.000Z","updated_at":"2025-12-30T00:39:35.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tenuo-ai/path_jail","commit_stats":null,"previous_names":["aimable100/path_jail"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/tenuo-ai/path_jail","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tenuo-ai%2Fpath_jail","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tenuo-ai%2Fpath_jail/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tenuo-ai%2Fpath_jail/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tenuo-ai%2Fpath_jail/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tenuo-ai","download_url":"https://codeload.github.com/tenuo-ai/path_jail/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tenuo-ai%2Fpath_jail/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28293188,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-11T04:44:51.577Z","status":"ssl_error","status_checked_at":"2026-01-11T04:44:44.232Z","response_time":60,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["directory-traversal","filesystem","filesystem-security","rust","security"],"created_at":"2026-01-11T06:00:01.368Z","updated_at":"2026-01-11T06:00:02.203Z","avatar_url":"https://github.com/tenuo-ai.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# path_jail\n\n[![CI](https://github.com/tenuo-ai/path_jail/actions/workflows/ci.yml/badge.svg)](https://github.com/tenuo-ai/path_jail/actions/workflows/ci.yml)\n[![Crates.io](https://img.shields.io/crates/v/path_jail.svg)](https://crates.io/crates/path_jail)\n[![docs.rs](https://img.shields.io/docsrs/path_jail)](https://docs.rs/path_jail)\n[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](https://github.com/tenuo-ai/path_jail#license)\n[![MSRV](https://img.shields.io/badge/MSRV-1.80-blue.svg)](https://github.com/tenuo-ai/path_jail)\n\nA zero-dependency filesystem sandbox for Rust. Restricts paths to a root directory, preventing traversal attacks while supporting files that don't exist yet.\n\n**Python bindings:** [`path-jail`](https://github.com/tenuo-ai/path-jail-python) on PyPI\n\n## Installation\n\n```bash\ncargo add path_jail\n```\n\n## The Problem\n\nThe standard approach fails for new files:\n\n```rust\n// This breaks if the file doesn't exist yet!\nlet path = root.join(user_input).canonicalize()?;\nif !path.starts_with(\u0026root) {\n    return Err(\"escape attempt\");\n}\n```\n\n## The Solution\n\n```rust\n// One-liner for simple cases\nlet path = path_jail::join(\"/var/uploads\", user_input)?;\nstd::fs::write(\u0026path, data)?;\n\n// Blocked: returns Err(EscapedRoot)\npath_jail::join(\"/var/uploads\", \"../../etc/passwd\")?;\n```\n\nFor multiple paths, create a `Jail` and reuse it:\n\n```rust\nuse path_jail::Jail;\n\nlet jail = Jail::new(\"/var/uploads\")?;\nlet path1 = jail.join(\"report.pdf\")?;\nlet path2 = jail.join(\"data.csv\")?;\n```\n\n## Features\n\n- **Zero dependencies** - only stdlib (optional `secure-open` feature for TOCTOU protection)\n- **Symlink-safe** - resolves and validates symlinks\n- **Works for new files** - validates paths that don't exist yet\n- **Type-safe paths** - optional `JailedPath` newtype prevents confused deputy bugs\n- **Segment joining** - safely build paths from user IDs, filenames, etc.\n- **Helpful errors** - tells you what went wrong and why\n\n## Security\n\n| Attack | Example | Blocked |\n|--------|---------|---------|\n| Path traversal | `../../etc/passwd` | Yes |\n| Symlink escape | `link -\u003e /etc` | Yes |\n| Symlink chains | `a -\u003e b -\u003e /etc` | Yes |\n| Broken symlinks | `link -\u003e /nonexistent` | Yes |\n| Absolute injection | `/etc/passwd` | Yes |\n| Parent escape | `foo/../../secret` | Yes |\n| Null byte injection | `file\\x00.txt` | Yes |\n\n### Limitations\n\nThis library validates paths. It does not hold file descriptors.\n\n**Rejected at construction:**\n- Filesystem roots (`/`, `C:\\`, `\\\\server\\share`) are rejected because they defeat the purpose of jailing.\n\n**Defends against:**\n- Logic errors in path construction\n- Confused deputy attacks from untrusted input\n\n**Does not defend against:**\n- Malicious local processes racing your I/O\n\nFor kernel-enforced sandboxing, use [`cap-std`](https://docs.rs/cap-std).\n\n### Platform-Specific Edge Cases\n\n#### Hard Links\n\nHard links cannot be detected by path inspection. If an attacker has shell access and creates a hard link to a sensitive file inside your jail, path_jail will allow access.\n\n**Mitigations:**\n- Use a separate partition for the jail (hard links cannot cross partitions)\n- Use container isolation\n\n#### Mount Points\n\nIf an attacker can mount a filesystem inside the jail, they can escape:\n\n```rust\nlet jail = Jail::new(\"/var/uploads\")?;\n// Attacker (with root): mount /dev/sda1 /var/uploads/mnt\njail.join(\"mnt/etc/passwd\")?;  // Passes check, but accesses root filesystem!\n```\n\nDetecting mount points would require `stat()` on every path component (expensive) or parsing `/proc/mounts` (Linux-only).\n\n**Mitigations:**\n- Mounting requires root privileges. If attacker has root, path validation is moot.\n- Use container isolation (separate mount namespace)\n\n#### TOCTOU Race Conditions\n\npath_jail validates paths at call time. A symlink could be created between validation and use:\n\n```rust\nlet path = jail.join(\"file.txt\")?;  // Validated\n// Attacker creates symlink here\nstd::fs::write(\u0026path, data)?;        // Escapes!\n```\n\n**Mitigations:**\n- Enable the `secure-open` feature for `O_NOFOLLOW`-protected file operations (see below)\n- Use container/chroot isolation\n\n#### Windows Reserved Device Names\n\nOn Windows, filenames like `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9` are special device names.\n\n```rust\nlet path = jail.join(\"CON.txt\")?;   // Returns C:\\uploads\\CON.txt\nstd::fs::File::open(\u0026path)?;         // Opens console device, not file!\n```\n\n**Impact:** Denial of Service (not a filesystem escape).\n\n**Mitigation:** Validate filenames against a blocklist before calling path_jail, or use UUIDs for stored filenames.\n\n#### Unicode Normalization (macOS)\n\nmacOS automatically converts filenames to NFD (decomposed) form. A file saved as `café.txt` (NFC) may be stored as `café.txt` (NFD).\n\npath_jail handles this correctly (all paths are canonicalized). The issue arises when storing paths externally:\n\n```rust\nlet user_input = \"café\";  // NFC from web form\nlet jail = Jail::new(format!(\"/uploads/{}\", user_input))?;\n\n// Wrong: storing original input\ndb.insert(\"root\", user_input);  // NFC bytes\n\n// Later: comparison fails\ndb.get(\"root\") == jail.root().to_str();  // NFC != NFD\n```\n\n**Mitigation:** Always store `jail.root()` or `jail.relative()`, never the original input. These are already canonicalized.\n\n#### Case Sensitivity (Windows/macOS)\n\nWindows and macOS (by default) have case-insensitive filesystems.\n\npath_jail handles this correctly for existing paths because `canonicalize()` normalizes case to what's on disk:\n\n```rust\nlet jail = Jail::new(\"/var/Uploads\")?;           // Canonicalized\njail.contains(\"/var/uploads/file.txt\")?;          // Also canonicalized - works!\n```\n\nThe issue is for blocklist checks on user input before calling path_jail:\n\n```rust\nlet blocklist = [\"secret.txt\"];\nlet input = \"SECRET.TXT\";\n\n// Wrong: case-sensitive comparison\nif blocklist.contains(\u0026input) { /* won't match */ }\n\n// Right: normalize first\nif blocklist.contains(\u0026input.to_lowercase().as_str()) { /* matches */ }\n```\n\n**Mitigation:** Normalize case before blocklist checks.\n\n#### Trailing Dots and Spaces (Windows)\n\nWindows silently strips trailing dots and spaces:\n\n```rust\njail.join(\"file.txt.\")?;   // Becomes \"file.txt\"\njail.join(\"file.txt \")?;   // Becomes \"file.txt\"\n```\n\n**Mitigation:** Strip trailing dots/spaces before validation.\n\n#### Alternate Data Streams (Windows NTFS)\n\nNTFS supports alternate data streams: `file.txt:hidden`. Consider rejecting filenames containing `:`.\n\n#### Unicode Display Attacks\n\nFilenames can contain Unicode control characters that manipulate display:\n\n```rust\njail.join(\"\\u{202E}txt.exe\")?;  // Right-to-left override: displays as \"exe.txt\"\n```\n\npath_jail passes these through (they're valid filenames). This is a UI attack, not a path attack. Sanitize filenames before displaying to users.\n\n#### Special Filesystems (Linux)\n\n`/proc` and `/dev` contain symlinks that can escape any jail:\n\n```rust\nlet jail = Jail::new(\"/proc\")?;\njail.join(\"self/root/etc/passwd\")?;  // /proc/self/root → /\n```\n\npath_jail catches this via symlink resolution (the above returns `EscapedRoot`). However, these filesystems have many such escape vectors. Avoid using them as jail roots.\n\n### Path Canonicalization\n\nAll returned paths are canonicalized (symlinks resolved, `..` eliminated):\n\n```rust\n// macOS: /var is a symlink to /private/var\nlet jail = Jail::new(\"/var/uploads\")?;\nassert!(jail.root().starts_with(\"/private/var\"));\n\n// Windows: Long paths (\u003e260 chars) use \\\\?\\ prefix\nlet long_name = \"a\".repeat(300);\nlet path = jail.join(\u0026long_name)?;\nassert!(path.to_string_lossy().starts_with(r\"\\\\?\\\"));\n```\n\nWhen comparing paths, always canonicalize your expected values.\n\n## API\n\n### One-shot validation\n\n```rust\n// Validate and join in one call\nlet safe: PathBuf = path_jail::join(\"/var/uploads\", \"subdir/file.txt\")?;\n```\n\n### Reusable jail\n\n```rust\nuse path_jail::Jail;\n\n// Create a jail (root must exist, be a directory, and not be filesystem root)\nlet jail = Jail::new(\"/var/uploads\")?;\n\n// Get the canonicalized root\nlet root: \u0026Path = jail.root();\n\n// Safely join a relative path\nlet path: PathBuf = jail.join(\"subdir/file.txt\")?;\n\n// Check if an absolute path is inside the jail\nlet verified: PathBuf = jail.contains(\"/var/uploads/file.txt\")?;\n\n// Get relative path for database storage\nlet rel: PathBuf = jail.relative(\u0026path)?;  // \"subdir/file.txt\"\n```\n\n### Type-safe paths\n\nUse `JailedPath` for compile-time guarantees:\n\n```rust\nuse path_jail::{Jail, JailedPath};\n\nfn save_upload(path: JailedPath, data: \u0026[u8]) -\u003e std::io::Result\u003c()\u003e {\n    // path is guaranteed to be inside the jail - no runtime check needed\n    std::fs::write(\u0026path, data)\n}\n\nlet jail = Jail::new(\"/var/uploads\")?;\nlet path: JailedPath = jail.join_typed(\"report.pdf\")?;\nsave_upload(path, b\"data\")?;\n```\n\n### Segment joining\n\nSafely build paths from multiple user inputs:\n\n```rust\nuse path_jail::Jail;\n\nlet jail = Jail::new(\"/var/uploads\")?;\nlet user_id = \"alice\";\nlet filename = \"photo.jpg\";\n\n// Safe: each segment is validated (no /, \\, or .. allowed in segments)\nlet path = jail.join_segments([user_id, \"files\", filename])?;\n\n// These would fail:\n// jail.join_segments([\"../etc\", \"passwd\"])?;     // \"..\" rejected\n// jail.join_segments([\"users/files\"])?;          // \"/\" in segment rejected\n\n// Type-safe version:\nlet path: JailedPath = jail.segments([user_id, \"files\", filename])?;\n```\n\n## Error Handling\n\n### Construction errors\n\n```rust\nuse path_jail::{Jail, JailError};\n\nmatch Jail::new(\"/var/uploads\") {\n    Ok(jail) =\u003e { /* use jail */ }\n    Err(JailError::InvalidRoot(path)) =\u003e {\n        // Tried to use filesystem root (/, C:\\) or non-directory\n        panic!(\"Config error: {}\", path.display());\n    }\n    Err(JailError::Io(e)) =\u003e {\n        // Root doesn't exist\n        panic!(\"Config error: {}\", e);\n    }\n    Err(e) =\u003e panic!(\"Unexpected error: {}\", e),  // Future-proof\n}\n```\n\n### Path validation errors\n\n```rust\nuse path_jail::{Jail, JailError};\n\nlet jail = Jail::new(\"/var/uploads\")?;\n\nmatch jail.join(user_input) {\n    Ok(path) =\u003e {\n        // Safe to use\n        std::fs::write(\u0026path, data)?;\n    }\n    Err(JailError::EscapedRoot { attempted, root }) =\u003e {\n        // Path traversal attempt\n        eprintln!(\"Blocked: {} escapes {}\", attempted.display(), root.display());\n    }\n    Err(JailError::BrokenSymlink(path)) =\u003e {\n        // Symlink target doesn't exist (can't verify it's safe)\n        eprintln!(\"Broken symlink: {}\", path.display());\n    }\n    Err(JailError::InvalidPath(reason)) =\u003e {\n        // Absolute path or other invalid input\n        eprintln!(\"Invalid: {}\", reason);\n    }\n    Err(JailError::Io(e)) =\u003e {\n        // Filesystem error (e.g., permission denied)\n        eprintln!(\"I/O error: {}\", e);\n    }\n    Err(e) =\u003e eprintln!(\"Error: {}\", e),  // Future-proof (non_exhaustive)\n}\n```\n\n## Example: File Uploads\n\n```rust\nuse path_jail::Jail;\nuse std::path::PathBuf;\n\nstruct UploadService {\n    jail: Jail,\n}\n\nimpl UploadService {\n    fn new(root: \u0026str) -\u003e Result\u003cSelf, path_jail::JailError\u003e {\n        Ok(Self { jail: Jail::new(root)? })\n    }\n\n    fn save(\u0026self, user_id: \u0026str, filename: \u0026str, data: \u0026[u8]) -\u003e std::io::Result\u003cPathBuf\u003e {\n        let path = self.jail.join(format!(\"{}/{}\", user_id, filename))\n            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;\n        \n        if let Some(parent) = path.parent() {\n            std::fs::create_dir_all(parent)?;\n        }\n        std::fs::write(\u0026path, data)?;\n        Ok(path)\n    }\n}\n```\n\n## Framework Integration\n\n### Axum\n\n```rust\nuse axum::{extract::Path, http::StatusCode, response::IntoResponse};\nuse bytes::Bytes;\nuse path_jail::Jail;\nuse std::sync::LazyLock;\n\nstatic UPLOADS: LazyLock\u003cJail\u003e = LazyLock::new(|| {\n    Jail::new(\"/var/uploads\").expect(\"uploads dir must exist\")\n});\n\nasync fn upload(\n    Path(filename): Path\u003cString\u003e,\n    body: Bytes,\n) -\u003e Result\u003cimpl IntoResponse, StatusCode\u003e {\n    let path = UPLOADS.join(\u0026filename).map_err(|_| StatusCode::BAD_REQUEST)?;\n    \n    if let Some(parent) = path.parent() {\n        std::fs::create_dir_all(parent).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n    }\n    std::fs::write(\u0026path, \u0026body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n    \n    Ok(StatusCode::CREATED)\n}\n```\n\n### Actix-web\n\n```rust\nuse actix_web::{web, HttpResponse, Result};\nuse path_jail::Jail;\nuse std::sync::LazyLock;\n\nstatic UPLOADS: LazyLock\u003cJail\u003e = LazyLock::new(|| {\n    Jail::new(\"/var/uploads\").expect(\"uploads dir must exist\")\n});\n\nasync fn upload(\n    path: web::Path\u003cString\u003e,\n    body: web::Bytes,\n) -\u003e Result\u003cHttpResponse\u003e {\n    let safe_path = UPLOADS.join(path.as_str())\n        .map_err(|_| actix_web::error::ErrorBadRequest(\"invalid path\"))?;\n    \n    std::fs::write(\u0026safe_path, \u0026body)?;\n    Ok(HttpResponse::Created().finish())\n}\n```\n\n## TOCTOU-Safe File Operations (Unix)\n\nEnable the `secure-open` feature for `O_NOFOLLOW`-protected file operations:\n\n```toml\n[dependencies]\npath_jail = { version = \"0.3\", features = [\"secure-open\"] }\n```\n\n```rust\nuse path_jail::Jail;\nuse std::io::{Read, Write};\n\nlet jail = Jail::new(\"/var/uploads\")?;\n\n// Open with O_NOFOLLOW - fails if path is a symlink\nlet mut file = jail.open(\"config.txt\")?;\nlet mut contents = String::new();\nfile.read_to_string(\u0026mut contents)?;\n\n// Create with O_CREAT | O_EXCL | O_NOFOLLOW - fails if file exists or is symlink\nlet mut file = jail.create(\"new.txt\")?;\nfile.write_all(b\"hello\")?;\n\n// Other options\nlet file = jail.create_or_truncate(\"data.txt\")?;  // Truncate if exists\nlet file = jail.open_append(\"log.txt\")?;           // Append mode\n```\n\nThis protects against symlink swap attacks between validation and file open. Zero additional dependencies.\n\n**Limitation:** Protects the final path component only. For full TOCTOU protection against intermediate directory attacks, use `cap-std`.\n\n## Alternatives\n\n| | path_jail | strict-path | cap-std |\n|-|-----------|-------------|---------|\n| Approach | Path validation | Type-safe path system | File descriptors |\n| Returns | `PathBuf` / `JailedPath` | Custom `StrictPath\u003cT\u003e` | Custom `Dir`/`File` |\n| Dependencies | 0 | ~5 | ~10 |\n| TOCTOU-safe | With `secure-open`* | No | Yes |\n| Best for | Simple file sandboxing | Complex type-safe paths | Kernel-enforced security |\n\n- [`strict-path`](https://crates.io/crates/strict-path) - More comprehensive, uses marker types for compile-time guarantees\n- [`cap-std`](https://docs.rs/cap-std) - Capability-based, TOCTOU-safe, but different API than `std::fs`\n\n*With `secure-open`: Safe against remote attackers and symlink attacks on the final path component. Not safe against local attackers who can swap intermediate directories. See [TOCTOU Race Conditions](#toctou-race-conditions).\n\n## Thread Safety\n\n`Jail` implements `Clone`, `Send`, and `Sync`. It can be safely shared across threads:\n\n```rust\nuse std::sync::Arc;\nuse path_jail::Jail;\n\nlet jail = Arc::new(Jail::new(\"/var/uploads\")?);\n\nlet jail_clone = Arc::clone(\u0026jail);\nstd::thread::spawn(move || {\n    let path = jail_clone.join(\"file.txt\").unwrap();\n    // ...\n});\n```\n\n## MSRV\n\nMinimum Supported Rust Version: **1.80**\n\nThis crate tracks recent stable Rust. We use `LazyLock` for ergonomic static initialization in examples.\n\n## Development\n\n```bash\ngit clone https://github.com/tenuo-ai/path_jail.git\ncd path_jail\ncargo test\ncargo clippy\n```\n\n## License\n\nMIT OR Apache-2.0\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftenuo-ai%2Fpath_jail","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftenuo-ai%2Fpath_jail","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftenuo-ai%2Fpath_jail/lists"}