1use std::fs::File;
23use std::io::Read;
24use std::path::{Component, Path};
25
26pub const MAX_SOURCE_FILE_BYTES: u64 = 8 * 1024 * 1024;
28pub const MAX_ADVISORY_FILE_BYTES: u64 = 1024 * 1024;
30pub const MAX_DIR_DEPTH: usize = 32;
32pub const MAX_DIR_ENTRIES: usize = 200_000;
34pub const MAX_NAME_LEN: usize = 64;
36pub const MAX_VERSION_LEN: usize = 64;
37
38pub fn is_safe_crate_name(name: &str) -> bool {
45 !name.is_empty()
46 && name.len() <= MAX_NAME_LEN
47 && name
48 .bytes()
49 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
50}
51
52pub fn is_safe_version(version: &str) -> bool {
55 !version.is_empty()
56 && version.len() <= MAX_VERSION_LEN
57 && version
58 .bytes()
59 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-' | b'_'))
60 && version != ".."
62 && !version.contains("..")
63}
64
65pub fn is_safe_path_segment(segment: &str) -> bool {
68 if segment.is_empty() || segment.len() > 255 {
69 return false;
70 }
71 if segment.contains('/') || segment.contains('\\') || segment.contains('\0') {
72 return false;
73 }
74 !matches!(segment, "." | "..")
75}
76
77pub fn is_contained_within(base: &Path, child: &Path) -> bool {
80 match (base.canonicalize(), child.canonicalize()) {
81 (Ok(b), Ok(c)) => c.starts_with(&b),
82 _ => false,
83 }
84}
85
86pub fn has_no_parent_components(path: &Path) -> bool {
89 !path.components().any(|c| matches!(c, Component::ParentDir))
90}
91
92pub fn read_file_capped(path: &Path, max_bytes: u64) -> Option<String> {
100 let file = File::open(path).ok()?;
101 let meta = file.metadata().ok()?;
102 if !meta.is_file() {
103 return None;
104 }
105 if meta.len() > max_bytes {
106 return None;
107 }
108 let mut bytes = Vec::new();
109 file.take(max_bytes).read_to_end(&mut bytes).ok()?;
111 Some(String::from_utf8_lossy(&bytes).into_owned())
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn crate_name_allows_normal() {
123 assert!(is_safe_crate_name("serde"));
124 assert!(is_safe_crate_name("openssl-sys"));
125 assert!(is_safe_crate_name("wasm_bindgen"));
126 assert!(is_safe_crate_name("a1"));
127 }
128
129 #[test]
130 fn crate_name_rejects_traversal_and_injection() {
131 assert!(!is_safe_crate_name(""));
132 assert!(!is_safe_crate_name(".."));
133 assert!(!is_safe_crate_name("../etc"));
134 assert!(!is_safe_crate_name("foo/bar"));
135 assert!(!is_safe_crate_name("foo\\bar"));
136 assert!(!is_safe_crate_name("foo bar"));
137 assert!(!is_safe_crate_name("foo\0"));
138 assert!(!is_safe_crate_name("a/../../b"));
139 assert!(!is_safe_crate_name("café")); assert!(!is_safe_crate_name(&"a".repeat(65)));
141 assert!(!is_safe_crate_name("evil.com"));
143 assert!(!is_safe_crate_name("crate@host"));
144 }
145
146 #[test]
147 fn version_validation() {
148 assert!(is_safe_version("1.0.0"));
149 assert!(is_safe_version("0.9.99"));
150 assert!(is_safe_version("1.0.0+spec-1.1.0"));
151 assert!(!is_safe_version("../1.0.0"));
152 assert!(!is_safe_version("1.0.0/.."));
153 assert!(!is_safe_version(".."));
154 assert!(!is_safe_version("1 0"));
155 assert!(!is_safe_version(""));
156 }
157
158 #[test]
159 fn path_segment_validation() {
160 assert!(is_safe_path_segment("serde-1.0.0"));
161 assert!(!is_safe_path_segment(".."));
162 assert!(!is_safe_path_segment("a/b"));
163 assert!(!is_safe_path_segment(""));
164 }
165
166 #[test]
167 fn containment_blocks_escape() {
168 let base = std::env::temp_dir();
169 assert!(is_contained_within(&base, &base));
170 assert!(!is_contained_within(&base, std::path::Path::new("/")));
172 }
173
174 #[test]
175 fn no_parent_components_detects_dotdot() {
176 assert!(has_no_parent_components(Path::new("a/b/c")));
177 assert!(!has_no_parent_components(Path::new("a/../b")));
178 }
179
180 #[test]
181 fn read_cap_rejects_oversize() {
182 let dir = std::env::temp_dir();
183 let path = dir.join("rustinel_safety_big.txt");
184 std::fs::write(&path, vec![b'a'; 1024]).unwrap();
185 assert!(read_file_capped(&path, 4096).is_some());
186 assert!(read_file_capped(&path, 512).is_none());
187 let _ = std::fs::remove_file(&path);
188 }
189
190 #[test]
191 fn read_cap_decodes_non_utf8_lossily() {
192 let dir = std::env::temp_dir();
195 let path = dir.join("rustinel_safety_nonutf8.rs");
196 let mut bytes = b"fn x(){ reqwest::get(\"https://x.workers.dev\");".to_vec();
197 bytes.push(0xFF);
198 bytes.extend_from_slice(b" }");
199 std::fs::write(&path, &bytes).unwrap();
200 let got = read_file_capped(&path, 4096).expect("file must not be dropped");
201 assert!(got.contains(".workers.dev"));
202 let _ = std::fs::remove_file(&path);
203 }
204}