src/paths.rs 4.2 KiB 133 lines Raw
1 //! Output-path safety (f0rge #60).2 //!3 //! Every path that becomes a file under the build output dir must be4 //! checked here first: `.forge/` frontmatter (`id`, release `version`) is5 //! untrusted input, and `out.join("issues/{id}.html")` with6 //! `id = "../../tmp/x"` writes outside `out/`.7 //!8 //! Rules: no absolute paths, no `\` or NUL, no empty/`.`/`..` segments.9 //! The checks are lexical on purpose — they run before anything is10 //! created, so there is nothing to canonicalize yet. Filesystem readers11 //! additionally use [`repo_subdir`] so a symlinked `.forge` cannot point12 //! outside the checkout (f0rge #107).13 14 use std::path::{Path, PathBuf};15 16 /// `true` when `segment` is a single safe path component.17 #[must_use]18 pub fn is_safe_segment(segment: &str) -> bool {19     !segment.is_empty() && segment != "." && segment != ".." && !segment.contains(['/', '\\', '\0'])20 }21 22 /// `true` when `rel` is a relative path made only of safe segments.23 #[must_use]24 pub fn is_safe_relative(rel: &str) -> bool {25     !rel.is_empty()26         && !rel.starts_with('/')27         && !rel.contains(['\\', '\0'])28         && rel.split('/').all(is_safe_segment)29 }30 31 /// Resolve `repo/rel` through symlinks and require the result to stay32 /// inside the resolved repo root. Returns `None` for missing paths,33 /// symlink escapes, or the repo root itself. Used for untrusted34 /// `.forge` trees (f0rge #107).35 #[must_use]36 pub fn repo_subdir(repo: &Path, rel: &str) -> Option<PathBuf> {37     let root = std::fs::canonicalize(repo).ok()?;38     let target = std::fs::canonicalize(root.join(rel)).ok()?;39     if target == root || !target.starts_with(&root) {40         return None;41     }42     Some(target)43 }44 45 /// `true` for names that would overwrite a generated section index46 /// (`releases/index.html`, `issues/index.html`, `pulls/index.html`,47 /// `wiki/index.html`). Case-insensitive so a case-insensitive host48 /// cannot collide either (f0rge #110).49 #[must_use]50 pub fn is_reserved_index(name: &str) -> bool {51     name.eq_ignore_ascii_case("index")52 }53 54 /// `true` when `id` is safe both as a path segment and as a displayed55 /// forge id: ASCII alphanumerics, `-`, `_` only.56 #[must_use]57 pub fn is_safe_id(id: &str) -> bool {58     is_safe_segment(id)59         && id60             .bytes()61             .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')62 }63 64 #[cfg(test)]65 mod tests {66     use super::*;67 68     #[test]69     fn rejects_traversal_and_absolute() {70         for bad in [71             "..",72             "../x",73             "a/../../b",74             "/etc/passwd",75             "a\\b",76             "a\0b",77             "",78             "a//b",79             "./a",80             "a/",81         ] {82             assert!(!is_safe_relative(bad), "{bad:?} must be rejected");83         }84     }85 86     #[test]87     fn accepts_normal_relative_paths() {88         for ok in ["a", "a/b", "a-b_c.html", "issues/1.html"] {89             assert!(is_safe_relative(ok), "{ok:?} must be accepted");90         }91     }92 93     #[test]94     fn ids_are_strict() {95         assert!(is_safe_id("06b5602f-4327-4377-99f6-3dca67b5bd75"));96         assert!(is_safe_id("PWNED2"));97         for bad in ["../../x", "a/b", "a b", "a.b", "..", "", "a\\b"] {98             assert!(!is_safe_id(bad), "{bad:?} must be rejected");99         }100     }101 102     #[test]103     fn reserved_index_names() {104         for name in ["index", "INDEX", "Index"] {105             assert!(is_reserved_index(name));106         }107         for name in ["index.html", "indexes", "my-index", ""] {108             assert!(!is_reserved_index(name));109         }110     }111 112     #[test]113     fn subdir_stays_inside_repo() {114         let dir = std::env::temp_dir().join(format!(115             "hu13-paths-{}-{}",116             std::process::id(),117             std::time::SystemTime::now()118                 .duration_since(std::time::UNIX_EPOCH)119                 .map(|d| d.as_nanos())120                 .unwrap_or(0)121         ));122         std::fs::create_dir_all(dir.join("inside")).unwrap();123         std::fs::write(dir.join("inside/x"), "x").unwrap();124         assert!(repo_subdir(&dir, "inside").is_some());125         assert!(repo_subdir(&dir, "missing").is_none());126         #[cfg(unix)]127         {128             std::os::unix::fs::symlink("/etc", dir.join("escape")).unwrap();129             assert!(repo_subdir(&dir, "escape").is_none());130         }131         let _ = std::fs::remove_dir_all(&dir);132     }133 }