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 }