// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Normalizing and resolving the repository path from an SSH command. /// Normalize an SSH repo path into `(owner, repo_path)`. /// /// Strips a leading `/` and a trailing `.git`, and rejects `..`, absolute, or /// empty paths. The remainder is `owner/group…/repo`; the owner is the first /// segment and the repo path is everything after it. #[must_use] pub fn normalize(raw: &str) -> Option<(String, String)> { let trimmed = raw.trim().trim_matches('\''); let without_lead = trimmed.strip_prefix('/').unwrap_or(trimmed); let path = without_lead.strip_suffix(".git").unwrap_or(without_lead); if path.is_empty() || path.starts_with('/') { return None; } // Reject traversal in any segment. if path .split('/') .any(|seg| seg == ".." || seg == "." || seg.is_empty()) { return None; } let (owner, repo_path) = path.split_once('/')?; Some((owner.to_string(), repo_path.to_string())) } #[cfg(test)] mod tests { use super::*; #[test] fn strips_lead_and_git_suffix() { assert_eq!( normalize("/hanna/backend/api.git"), Some(("hanna".to_string(), "backend/api".to_string())) ); assert_eq!( normalize("hanna/proj"), Some(("hanna".to_string(), "proj".to_string())) ); } #[test] fn rejects_traversal_and_malformed() { assert!(normalize("../etc/passwd").is_none()); assert!(normalize("hanna/../secret").is_none()); assert!(normalize("/absolute//path").is_none()); assert!(normalize("").is_none()); assert!(normalize("noowner").is_none()); // no repo path } }