solid_pod_rs_forge/guard.rs
1//! Request-path guards: the percent-encoding refusal and the owner / repo /
2//! ref / path validators the JSON API and the HTML lanes share.
3//!
4//! Cites the JSS `forge` dispatcher (the `%2e|%2f|%5c|%00` refusal ahead of
5//! routing) and `helpers.js` (`okSegment`, `okPath`, `okRef`) by name only.
6
7use crate::error::ForgeError;
8
9/// Refuse a request path that still carries a percent-form of `.`, `/`, `\`
10/// or NUL, or a literal NUL, backslash or control character.
11///
12/// [`crate::ForgeRequest::path`] is documented as already percent-decoded by
13/// the embedding server, so a surviving `%2e`, `%2f`, `%5c` or `%00` means the
14/// client double-encoded it, and a decoded NUL or backslash means it single
15/// encoded one. Neither names a real object in the forge, so both are refused
16/// with `400` rather than guessed at (JSS dispatcher behaviour). A server that
17/// passes the still-encoded path is caught by the same test.
18///
19/// # Examples
20///
21/// ```
22/// use solid_pod_rs_forge::guard::percent_guard;
23///
24/// assert!(percent_guard("/alice/repo/blob/main/src/lib.rs").is_ok());
25/// assert!(percent_guard("/alice/repo/blob/main/%2e%2e/x").is_err());
26/// assert!(percent_guard("/alice/repo/blob/main/a%2Fb").is_err());
27/// assert!(percent_guard("/alice/repo/a\\b").is_err());
28/// assert!(percent_guard("/me/../victim/demo.git/info/refs").is_err());
29/// assert!(percent_guard("/alice/repo?q=a/../b").is_ok());
30/// ```
31pub fn percent_guard(path: &str) -> Result<(), ForgeError> {
32 if path.chars().any(|c| c == '\\' || c.is_control()) {
33 return Err(ForgeError::BadRequest("bad path".into()));
34 }
35 // `.` and `..` segments never name a forge object. git's CGI refuses an
36 // aliased path today, but relying on that is brittle (security review).
37 let target = path.split(['?', '#']).next().unwrap_or(path);
38 if target.split('/').any(|seg| seg == "." || seg == "..") {
39 return Err(ForgeError::BadRequest("bad path".into()));
40 }
41 let b = path.as_bytes();
42 for i in 0..b.len().saturating_sub(2) {
43 if b[i] == b'%' {
44 let hex = [b[i + 1].to_ascii_lowercase(), b[i + 2].to_ascii_lowercase()];
45 if matches!(&hex, b"2e" | b"2f" | b"5c" | b"00") {
46 return Err(ForgeError::BadRequest("bad path".into()));
47 }
48 }
49 }
50 Ok(())
51}
52
53/// JSS `OWNER_NAME`: `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, no `..`.
54///
55/// # Examples
56///
57/// ```
58/// use solid_pod_rs_forge::guard::ok_owner;
59/// assert!(ok_owner("casey"));
60/// assert!(!ok_owner("a..b"));
61/// assert!(!ok_owner(".hidden"));
62/// ```
63#[must_use]
64pub fn ok_owner(s: &str) -> bool {
65 name_ok(s, 64) && !s.contains("..") && !reserved_owner(s)
66}
67
68/// First path segments the router gives a meaning of its own. An owner with
69/// one of these names would be routed one way and gated another (for example
70/// `/api/<name>.git/info/refs` is smart-HTTP git but looks like the API
71/// namespace to a path-segment check), so they can never be owner names.
72pub const RESERVED_OWNERS: &[&str] = &["api", "xlogin.js"];
73
74/// `true` when `s` is a [`RESERVED_OWNERS`] word (ASCII case-insensitive).
75///
76/// # Examples
77///
78/// ```
79/// use solid_pod_rs_forge::guard::{ok_owner, reserved_owner};
80/// assert!(reserved_owner("API"));
81/// assert!(!ok_owner("api"));
82/// assert!(ok_owner("apiary"));
83/// ```
84#[must_use]
85pub fn reserved_owner(s: &str) -> bool {
86 RESERVED_OWNERS.iter().any(|r| r.eq_ignore_ascii_case(s))
87}
88
89/// JSS `REPO_NAME`: `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`, no `..`, no `.git`
90/// suffix.
91///
92/// # Examples
93///
94/// ```
95/// use solid_pod_rs_forge::guard::ok_repo;
96/// assert!(ok_repo("demo"));
97/// assert!(!ok_repo("demo.git"));
98/// ```
99#[must_use]
100pub fn ok_repo(s: &str) -> bool {
101 name_ok(s, 128) && !s.contains("..") && !s.ends_with(".git")
102}
103
104fn name_ok(s: &str, max: usize) -> bool {
105 let mut it = s.chars();
106 match it.next() {
107 Some(c) if c.is_ascii_alphanumeric() => {}
108 _ => return false,
109 }
110 s.len() <= max && it.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
111}
112
113/// JSS `okSegment`: a non-empty path segment of at most 255 bytes that does
114/// not start with `.`, contain `..`, a backslash, a control character or a
115/// percent-form of `. / \`.
116///
117/// # Examples
118///
119/// ```
120/// use solid_pod_rs_forge::guard::ok_segment;
121/// assert!(ok_segment("main.rs"));
122/// assert!(!ok_segment(".git"));
123/// assert!(!ok_segment("a%2fb"));
124/// ```
125#[must_use]
126pub fn ok_segment(s: &str) -> bool {
127 if s.is_empty() || s.len() > 255 || s.starts_with('.') || s.contains("..") {
128 return false;
129 }
130 percent_guard(s).is_ok()
131}
132
133/// JSS `okRef`: `^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$` without `..`, a
134/// trailing `/` or `//`.
135///
136/// # Examples
137///
138/// ```
139/// use solid_pod_rs_forge::guard::ok_ref;
140/// assert!(ok_ref("feature/x"));
141/// assert!(!ok_ref("-bad"));
142/// assert!(!ok_ref("a..b"));
143/// ```
144#[must_use]
145pub fn ok_ref(s: &str) -> bool {
146 let mut it = s.chars();
147 match it.next() {
148 Some(c) if c.is_ascii_alphanumeric() => {}
149 _ => return false,
150 }
151 s.len() <= 201
152 && it.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
153 && !s.contains("..")
154 && !s.ends_with('/')
155 && !s.contains("//")
156}
157
158/// JSS `SHA_RE`: `^[0-9a-f]{4,64}$`.
159///
160/// # Examples
161///
162/// ```
163/// use solid_pod_rs_forge::guard::is_sha;
164/// assert!(is_sha("deadbeef"));
165/// assert!(!is_sha("xyz1"));
166/// ```
167#[must_use]
168pub fn is_sha(s: &str) -> bool {
169 (4..=64).contains(&s.len()) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
170}
171
172/// Segment-wise [`ok_segment`] over a path split.
173#[must_use]
174pub fn ok_path(segs: &[String]) -> bool {
175 segs.iter().all(|s| ok_segment(s))
176}
177
178#[cfg(test)]
179mod tests {
180 #[test]
181 fn dot_segments_are_refused_but_dotted_names_are_not() {
182 use super::percent_guard;
183 for bad in [
184 "/a/./b",
185 "/a/../b",
186 "/..",
187 "/me/../victim/demo.git/info/refs",
188 ] {
189 assert!(percent_guard(bad).is_err(), "{bad}");
190 }
191 for ok in ["/a/.b", "/a/b.c/d", "/a/...x", "/a/b?x=/../y"] {
192 assert!(percent_guard(ok).is_ok(), "{ok}");
193 }
194 }
195
196 use super::*;
197
198 #[test]
199 fn percent_forms_are_refused_case_insensitively() {
200 for p in [
201 "/a/%2e%2e/x",
202 "/a/%2E/x",
203 "/a/b%2Fc",
204 "/a/b%5Cc",
205 "/a/b%5cc",
206 "/a/b%00c",
207 "/a/b%2f",
208 ] {
209 assert!(percent_guard(p).is_err(), "{p}");
210 }
211 }
212
213 #[test]
214 fn decoded_nul_backslash_and_controls_are_refused() {
215 assert!(percent_guard("/a/b\0c").is_err());
216 assert!(percent_guard("/a/b\\c").is_err());
217 assert!(percent_guard("/a/b\nc").is_err());
218 }
219
220 #[test]
221 fn ordinary_paths_and_other_escapes_pass() {
222 assert!(percent_guard("/a/b/c.txt").is_ok());
223 assert!(percent_guard("/a/100%/x").is_ok());
224 assert!(percent_guard("/a/%41/x").is_ok());
225 assert!(percent_guard("/").is_ok());
226 assert!(percent_guard("").is_ok());
227 assert!(percent_guard("/a/%2").is_ok());
228 }
229
230 #[test]
231 fn validators_mirror_jss() {
232 assert!(ok_owner("casey") && ok_owner(&"a".repeat(64)));
233 assert!(!ok_owner(&"a".repeat(65)) && !ok_owner("") && !ok_owner("a/b"));
234 assert!(ok_repo("my.repo_1") && !ok_repo("x.git") && !ok_repo("a..b"));
235 assert!(ok_ref("v1.0") && ok_ref("feature/x") && !ok_ref("a//b") && !ok_ref("a/"));
236 assert!(!ok_ref("--upload-pack=x"));
237 assert!(is_sha("abcd") && !is_sha("abc") && !is_sha("ABCD"));
238 assert!(ok_segment("README.md") && !ok_segment("..") && !ok_segment(""));
239 assert!(!ok_segment("a%5Cb") && !ok_segment("a\tb"));
240 assert!(ok_path(&["src".to_string(), "lib.rs".to_string()]));
241 assert!(!ok_path(&["src".to_string(), ".git".to_string()]));
242 }
243}