use miette::{Context, IntoDiagnostic, Result, bail, ensure};
+use quire::repo::Repo;
use quire::Config;
const GIT_COMMANDS: &[&str] = &[
args.len()
);
- let repo = validate_repo_path(&args[0])?;
+ let path = args[0].trim_start_matches('/');
+ ensure!(!path.is_empty(), "empty repository path");
- let repo_dir = config.repos_dir.join(&repo);
- ensure!(repo_dir.is_dir(), "repository not found: {repo}");
+ let repo = Repo::from_name(path)?;
+ let repo_dir = repo.path(&config.repos_dir);
+ ensure!(repo_dir.is_dir(), "repository not found: {}", repo.name());
- tracing::info!(%git_cmd, %repo, "dispatching git command");
+ tracing::info!(%git_cmd, name = %repo.name(), "dispatching git command");
let err = Command::new(git_cmd)
.arg(".")
.current_dir(&repo_dir)
bail!("exec failed: {err}")
}
-/// Validate a repo path argument from the SSH protocol.
-///
-/// Git sends paths like '/foo.git'. We strip the leading slash,
-/// reject path traversal (..), require a .git suffix, and reject
-/// empty or double-slash paths.
-fn validate_repo_path(raw: &str) -> Result<String> {
- let path = raw.trim_start_matches('/');
-
- ensure!(!path.is_empty(), "empty repository path");
- ensure!(!path.contains(".."), "invalid repository path: {raw}");
- ensure!(
- path.ends_with(".git"),
- "invalid repository path (must end in .git): {raw}"
- );
- ensure!(!path.contains("//"), "invalid repository path: {raw}");
-
- Ok(path.to_string())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn valid_repo_paths() {
- assert_eq!(validate_repo_path("/foo.git").unwrap(), "foo.git");
- assert_eq!(validate_repo_path("foo.git").unwrap(), "foo.git");
- assert_eq!(validate_repo_path("/work/foo.git").unwrap(), "work/foo.git");
- }
-
- #[test]
- fn rejects_traversal() {
- assert!(validate_repo_path("/../etc/passwd").is_err());
- assert!(validate_repo_path("/foo/../../bar.git").is_err());
- }
-
- #[test]
- fn rejects_no_git_suffix() {
- assert!(validate_repo_path("/foo").is_err());
- }
- #[test]
- fn rejects_empty() {
- assert!(validate_repo_path("").is_err());
- assert!(validate_repo_path("/").is_err());
- }
-
- #[test]
- fn rejects_double_slash() {
- assert!(validate_repo_path("/foo//bar.git").is_err());
- }
-}
use miette::{IntoDiagnostic, Result, ensure};
use quire::Config;
-use quire::repo::validate_name;
+use quire::repo::Repo;
pub async fn run(config: &Config, name: &str) -> Result<()> {
- let name = validate_name(name)?;
- let repo_dir = config.repos_dir.join(&name);
+ let repo = Repo::from_name(name)?;
+ let repo_dir = repo.path(&config.repos_dir);
ensure!(
!repo_dir.exists(),
- "repository already exists: {name}"
+ "repository already exists: {}",
+ repo.name()
);
// Create parent directory for grouped repos (e.g. work/foo.git).
}
let status = Command::new("git")
- .args(["init", "--bare", &name])
+ .args(["init", "--bare", repo.name()])
.current_dir(&config.repos_dir)
.status()
.into_diagnostic()?;
ensure!(status.success(), "git init failed");
- tracing::info!(%name, "created repository");
- println!("{name}");
+ tracing::info!(name = %repo.name(), "created repository");
+ println!("{}", repo.name());
Ok(())
}
use miette::{IntoDiagnostic, Result, ensure};
use quire::Config;
-use quire::repo::validate_name;
+use quire::repo::Repo;
pub async fn run(config: &Config, name: &str) -> Result<()> {
- let name = validate_name(name)?;
- let repo_dir = config.repos_dir.join(&name);
+ let repo = Repo::from_name(name)?;
+ let repo_dir = repo.path(&config.repos_dir);
- ensure!(repo_dir.exists(), "repository not found: {name}");
- ensure!(repo_dir.is_dir(), "not a directory: {name}");
+ ensure!(repo_dir.exists(), "repository not found: {}", repo.name());
+ ensure!(repo_dir.is_dir(), "not a directory: {}", repo.name());
fs_err::remove_dir_all(&repo_dir).into_diagnostic()?;
let _ = fs_err::remove_dir(parent);
}
- tracing::info!(%name, "removed repository");
+ tracing::info!(name = %repo.name(), "removed repository");
Ok(())
}
+use std::path::{Path, PathBuf};
+
use miette::{Result, ensure};
-/// Validate a repository name for creation.
+/// A validated repository name relative to the repos directory.
+#[derive(Debug, Clone)]
+pub struct Repo {
+ name: String,
+}
+
+impl Repo {
+ /// Parse a repository name (e.g. `foo.git`, `work/foo.git`).
+ ///
+ /// Rejects path traversal, missing `.git` suffix, empty segments,
+ /// reserved path components, and more than one level of grouping.
+ pub fn from_name(name: &str) -> Result<Self> {
+ validate_segments(name)?;
+ Ok(Repo {
+ name: name.to_string(),
+ })
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn path(&self, repos_dir: &Path) -> PathBuf {
+ repos_dir.join(&self.name)
+ }
+}
+
+/// Validate segments of a repository name.
///
/// Allows at most one level of grouping (e.g. `foo.git` or `work/foo.git`).
/// Rejects path traversal, missing `.git` suffix, empty segments, and
/// reserved path components.
-pub fn validate_name(name: &str) -> Result<String> {
+fn validate_segments(name: &str) -> Result<String> {
ensure!(!name.is_empty(), "repository name cannot be empty");
ensure!(!name.contains(".."), "invalid repository name: {name}");
ensure!(
use super::*;
#[test]
- fn valid_names() {
- assert_eq!(validate_name("foo.git").unwrap(), "foo.git");
- assert_eq!(validate_name("work/foo.git").unwrap(), "work/foo.git");
+ fn from_name_valid() {
+ assert_eq!(Repo::from_name("foo.git").unwrap().name(), "foo.git");
+ assert_eq!(
+ Repo::from_name("work/foo.git").unwrap().name(),
+ "work/foo.git"
+ );
}
#[test]
fn rejects_empty() {
- assert!(validate_name("").is_err());
+ assert!(Repo::from_name("").is_err());
}
#[test]
fn rejects_traversal() {
- assert!(validate_name("../foo.git").is_err());
- assert!(validate_name("foo/../../bar.git").is_err());
- assert!(validate_name("./foo.git").is_err());
+ assert!(Repo::from_name("../foo.git").is_err());
+ assert!(Repo::from_name("foo/../../bar.git").is_err());
+ assert!(Repo::from_name("./foo.git").is_err());
}
#[test]
fn rejects_no_git_suffix() {
- assert!(validate_name("foo").is_err());
+ assert!(Repo::from_name("foo").is_err());
}
#[test]
fn rejects_deep_nesting() {
- assert!(validate_name("a/b/c.git").is_err());
+ assert!(Repo::from_name("a/b/c.git").is_err());
}
#[test]
fn rejects_double_slash() {
- assert!(validate_name("foo//bar.git").is_err());
+ assert!(Repo::from_name("foo//bar.git").is_err());
}
#[test]
- fn rejects_empty_segment() {
- assert!(validate_name("/foo.git").is_err());
- assert!(validate_name("foo//bar.git").is_err());
+ fn rejects_dot_git_segment() {
+ assert!(Repo::from_name("foo/.git").is_err());
}
}