fabrica

hanna/fabrica

1883 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Normalizing and resolving the repository path from an SSH command.
6
7/// Normalize an SSH repo path into `(owner, repo_path)`.
8///
9/// Strips a leading `/` and a trailing `.git`, and rejects `..`, absolute, or
10/// empty paths. The remainder is `owner/group…/repo`; the owner is the first
11/// segment and the repo path is everything after it.
12#[must_use]
13pub fn normalize(raw: &str) -> Option<(String, String)> {
14 let trimmed = raw.trim().trim_matches('\'');
15 let without_lead = trimmed.strip_prefix('/').unwrap_or(trimmed);
16 let path = without_lead.strip_suffix(".git").unwrap_or(without_lead);
17
18 if path.is_empty() || path.starts_with('/') {
19 return None;
20 }
21 // Reject traversal in any segment.
22 if path
23 .split('/')
24 .any(|seg| seg == ".." || seg == "." || seg.is_empty())
25 {
26 return None;
27 }
28 let (owner, repo_path) = path.split_once('/')?;
29 Some((owner.to_string(), repo_path.to_string()))
30}
31
32#[cfg(test)]
33mod tests {
34 use super::*;
35
36 #[test]
37 fn strips_lead_and_git_suffix() {
38 assert_eq!(
39 normalize("/hanna/backend/api.git"),
40 Some(("hanna".to_string(), "backend/api".to_string()))
41 );
42 assert_eq!(
43 normalize("hanna/proj"),
44 Some(("hanna".to_string(), "proj".to_string()))
45 );
46 }
47
48 #[test]
49 fn rejects_traversal_and_malformed() {
50 assert!(normalize("../etc/passwd").is_none());
51 assert!(normalize("hanna/../secret").is_none());
52 assert!(normalize("/absolute//path").is_none());
53 assert!(normalize("").is_none());
54 assert!(normalize("noowner").is_none()); // no repo path
55 }
56}