michal/tit

Browse tree · Show commit · Download archive

Diff

5e59e30884fd631363186be3

scripts/check-m2-2

Mode 100755; object fa4b116c3131

@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test cli --test git_repository --test sqlite
+cargo test --locked --release --test sqlite_workload -- --ignored --nocapture

src/admin.rs

Mode 100644; object b97e46935980

@@ -1,0 +1,242 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use gix::hash::Kind;
+use rand::TryRng;
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
+use crate::store::{NewRepository, RepositoryRecord, Store, StoreError};
+
+pub(crate) fn create_repository(
+    instance_dir: &Path,
+    owner: &str,
+    slug: &str,
+    object_format: Kind,
+) -> Result<RepositoryRecord, AdminError> {
+    validate_names(owner, slug)?;
+    administer_repository(instance_dir, owner, slug, |path| {
+        GitRepository::create_bare(path, object_format)?;
+        Ok(object_format)
+    })
+}
+
+pub(crate) fn import_repository(
+    instance_dir: &Path,
+    owner: &str,
+    slug: &str,
+    source: &Path,
+) -> Result<RepositoryRecord, AdminError> {
+    validate_names(owner, slug)?;
+    let source = fs::canonicalize(source).map_err(|source_error| AdminError::Canonicalize {
+        path: source.to_owned(),
+        source: source_error,
+    })?;
+    administer_repository(instance_dir, owner, slug, |path| {
+        if source.starts_with(path.parent().expect("a managed repository has a parent")) {
+            return Err(AdminError::ManagedImport(source));
+        }
+        GitRepository::copy_bare(&source, path).map_err(Into::into)
+    })
+}
+
+pub(crate) fn rename_repository(
+    instance_dir: &Path,
+    owner: &str,
+    old_slug: &str,
+    new_slug: &str,
+) -> Result<RepositoryRecord, AdminError> {
+    validate_names(owner, old_slug)?;
+    validate_slug(new_slug)?;
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let mut store = Store::open(&database)?;
+    store.rename_repository(owner, old_slug, new_slug)?;
+    inspect_with_store(instance_dir, &store, owner, new_slug)
+}
+
+pub(crate) fn archive_repository(
+    instance_dir: &Path,
+    owner: &str,
+    slug: &str,
+) -> Result<RepositoryRecord, AdminError> {
+    validate_names(owner, slug)?;
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let mut store = Store::open(&database)?;
+    store.archive_repository(owner, slug, timestamp()?)?;
+    inspect_with_store(instance_dir, &store, owner, slug)
+}
+
+pub(crate) fn inspect_repository(
+    instance_dir: &Path,
+    owner: &str,
+    slug: &str,
+) -> Result<RepositoryRecord, AdminError> {
+    validate_names(owner, slug)?;
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let store = Store::open(&database)?;
+    inspect_with_store(instance_dir, &store, owner, slug)
+}
+
+pub(crate) fn repository_path(
+    instance_dir: &Path,
+    repository: &RepositoryRecord,
+) -> Result<PathBuf, AdminError> {
+    let root = prepare_repository_root(instance_dir)?;
+    let path = root.join(format!("{}.git", repository.id));
+    fs::canonicalize(&path).map_err(|source| AdminError::Canonicalize { path, source })
+}
+
+fn administer_repository(
+    instance_dir: &Path,
+    owner: &str,
+    slug: &str,
+    prepare: impl FnOnce(&Path) -> Result<Kind, AdminError>,
+) -> Result<RepositoryRecord, AdminError> {
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let mut store = Store::open(&database)?;
+    let root = prepare_repository_root(instance_dir)?;
+    let id = random_id()?;
+    let pending_path = root.join(format!(".pending-{id}.git"));
+    let final_path = root.join(format!("{id}.git"));
+    if pending_path.exists() || final_path.exists() {
+        return Err(AdminError::IdentifierCollision);
+    }
+
+    let object_format = match prepare(&pending_path) {
+        Ok(object_format) => object_format,
+        Err(error) => {
+            remove_created_repository(&pending_path)?;
+            return Err(error);
+        }
+    };
+    fs::rename(&pending_path, &final_path).map_err(|source| AdminError::Filesystem {
+        path: final_path.clone(),
+        source,
+    })?;
+    let canonical_path =
+        fs::canonicalize(&final_path).map_err(|source| AdminError::Canonicalize {
+            path: final_path.clone(),
+            source,
+        })?;
+    if canonical_path.parent() != Some(root.as_path()) {
+        remove_created_repository(&canonical_path)?;
+        return Err(AdminError::PathEscape(canonical_path));
+    }
+
+    let created_at = timestamp()?;
+    let object_format = object_format_name(object_format)?;
+    if let Err(error) = store.create_repository(&NewRepository {
+        id: &id,
+        owner,
+        slug,
+        object_format,
+        created_at,
+    }) {
+        remove_created_repository(&canonical_path)?;
+        return Err(error.into());
+    }
+    store.repository(owner, slug).map_err(Into::into)
+}
+
+fn inspect_with_store(
+    instance_dir: &Path,
+    store: &Store,
+    owner: &str,
+    slug: &str,
+) -> Result<RepositoryRecord, AdminError> {
+    let repository = store.repository(owner, slug)?;
+    let path = repository_path(instance_dir, &repository)?;
+    let git = GitRepository::open(&path)?;
+    if object_format_name(git.object_format())? != repository.object_format {
+        return Err(AdminError::ObjectFormatMismatch);
+    }
+    Ok(repository)
+}
+
+fn validate_names(owner: &str, slug: &str) -> Result<(), AdminError> {
+    validate_username(owner)?;
+    validate_slug(slug)?;
+    Ok(())
+}
+
+fn random_id() -> Result<String, AdminError> {
+    let mut bytes = [0_u8; 16];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| AdminError::Random)?;
+    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+}
+
+fn timestamp() -> Result<i64, AdminError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| AdminError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| AdminError::Clock)
+}
+
+fn object_format_name(kind: Kind) -> Result<&'static str, AdminError> {
+    match kind {
+        Kind::Sha1 => Ok("sha1"),
+        Kind::Sha256 => Ok("sha256"),
+        _ => Err(AdminError::UnsupportedObjectFormat),
+    }
+}
+
+fn remove_created_repository(path: &Path) -> Result<(), AdminError> {
+    match fs::remove_dir_all(path) {
+        Ok(()) => Ok(()),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(source) => Err(AdminError::Filesystem {
+            path: path.to_owned(),
+            source,
+        }),
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum AdminError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    RepositoryName(#[from] RepositoryNameError),
+    #[error(transparent)]
+    Instance(#[from] InstanceError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error(transparent)]
+    Git(#[from] GitRepositoryError),
+    #[error("cannot canonicalize path {path}: {source}")]
+    Canonicalize {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("cannot access repository path {path}: {source}")]
+    Filesystem {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("repository path leaves the repository directory: {0}")]
+    PathEscape(PathBuf),
+    #[error("cannot import a repository from the managed repository directory: {0}")]
+    ManagedImport(PathBuf),
+    #[error("random repository ID collision")]
+    IdentifierCollision,
+    #[error("cannot create a random repository ID")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+    #[error("repository object format does not match the database")]
+    ObjectFormatMismatch,
+    #[error("repository object format is not supported")]
+    UnsupportedObjectFormat,
+}

src/cli.rs

Mode 100644100644; object 90896b3c72c6daefd6aa2c09

@@ -1,7 +1,7 @@
 use std::net::SocketAddr;
 use std::path::PathBuf;
 
-use clap::{Parser, Subcommand};
+use clap::{Parser, Subcommand, ValueEnum};
 use url::Url;
 
 #[derive(Debug, Parser)]
@@ -53,6 +53,54 @@
         #[command(subcommand)]
         command: SetupCommand,
     },
+    /// Run an offline administrator command
+    Admin {
+        #[command(subcommand)]
+        command: AdminCommand,
+    },
+}
+
+#[derive(Clone, Debug, Subcommand)]
+pub(crate) enum AdminCommand {
+    /// Administer repositories
+    Repository {
+        #[command(subcommand)]
+        command: RepositoryCommand,
+    },
+}
+
+#[derive(Clone, Debug, Subcommand)]
+pub(crate) enum RepositoryCommand {
+    /// Create an empty repository
+    Create {
+        owner: String,
+        slug: String,
+        #[arg(long, value_enum, default_value_t = ObjectFormat::Sha1)]
+        object_format: ObjectFormat,
+    },
+    /// Import a bare repository
+    Import {
+        owner: String,
+        slug: String,
+        source: PathBuf,
+    },
+    /// Rename a repository
+    Rename {
+        owner: String,
+        old_slug: String,
+        new_slug: String,
+    },
+    /// Archive a repository
+    Archive { owner: String, slug: String },
+    /// Inspect a repository
+    Inspect { owner: String, slug: String },
+}
+
+#[derive(Clone, Copy, Debug, Default, ValueEnum)]
+pub(crate) enum ObjectFormat {
+    #[default]
+    Sha1,
+    Sha256,
 }
 
 #[derive(Clone, Debug, Subcommand)]

src/domain/mod.rs

Mode 100644; object a6ad4d1841a3

@@ -1,0 +1,1 @@
+pub(crate) mod repository;

src/domain/repository.rs

Mode 100644; object f6fc3f075f99

@@ -1,0 +1,54 @@
+use thiserror::Error;
+
+const MAX_SLUG_BYTES: usize = 100;
+const RESERVED_SLUGS: [&str; 6] = ["admin", "api", "assets", "feeds", "issues", "setup"];
+
+pub(crate) fn validate_slug(slug: &str) -> Result<(), RepositoryNameError> {
+    if slug.is_empty()
+        || slug.len() > MAX_SLUG_BYTES
+        || slug.ends_with(".git")
+        || RESERVED_SLUGS.contains(&slug)
+        || !slug.bytes().all(|byte| {
+            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
+        })
+        || !slug
+            .as_bytes()
+            .first()
+            .is_some_and(u8::is_ascii_alphanumeric)
+        || !slug
+            .as_bytes()
+            .last()
+            .is_some_and(u8::is_ascii_alphanumeric)
+    {
+        return Err(RepositoryNameError::InvalidSlug);
+    }
+    Ok(())
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum RepositoryNameError {
+    #[error("repository slug is not valid")]
+    InvalidSlug,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn accepts_canonical_repository_slugs() {
+        for slug in ["a", "tit", "tit.cde", "tit_cde", "tit-cde", "a.1_b-2"] {
+            validate_slug(slug).expect("accept a canonical slug");
+        }
+    }
+
+    #[test]
+    fn rejects_aliases_routes_and_unsafe_repository_slugs() {
+        for slug in [
+            "", ".tit", "tit.", "Tit", "tit.git", "admin", "api", "a/b", "a b", "é",
+        ] {
+            assert!(validate_slug(slug).is_err(), "accepted {slug:?}");
+        }
+        assert!(validate_slug(&"a".repeat(101)).is_err());
+    }
+}

src/git/repository.rs

Mode 100644100644; object 07060b2da2963adddd388cc6

@@ -1,4 +1,5 @@
 use std::collections::HashSet;
+use std::fs;
 use std::path::{Path, PathBuf};
 
 use gix::hash::{Kind, ObjectId};
@@ -37,6 +38,41 @@
 
     pub(crate) fn object_format(&self) -> Kind {
         self.repository.object_hash()
+    }
+
+    pub(crate) fn create_bare(path: &Path, object_format: Kind) -> Result<(), GitRepositoryError> {
+        let options = gix::create::Options {
+            object_hash: (object_format == Kind::Sha256).then_some(Kind::Sha256),
+            ..Default::default()
+        };
+        let repository = gix::ThreadSafeRepository::init(path, gix::create::Kind::Bare, options)
+            .map_err(|error| GitRepositoryError::Create {
+                path: path.to_owned(),
+                reason: error.to_string(),
+            })?;
+        drop(repository);
+        fs::write(path.join("HEAD"), b"ref: refs/heads/main\n").map_err(|source| {
+            GitRepositoryError::Filesystem {
+                path: path.join("HEAD"),
+                source,
+            }
+        })?;
+        let created = Self::open(path)?;
+        if created.object_format() != object_format {
+            return Err(GitRepositoryError::WrongObjectFormat);
+        }
+        Ok(())
+    }
+
+    pub(crate) fn copy_bare(source: &Path, destination: &Path) -> Result<Kind, GitRepositoryError> {
+        let source_repository = Self::open(source)?;
+        let object_format = source_repository.object_format();
+        copy_repository_tree(source, destination)?;
+        let copy = Self::open(destination)?;
+        if copy.object_format() != object_format {
+            return Err(GitRepositoryError::WrongObjectFormat);
+        }
+        Ok(object_format)
     }
 
     pub(crate) fn references(&self) -> Result<Vec<GitReference>, GitRepositoryError> {
@@ -255,6 +291,8 @@
 
 #[derive(Debug, Error)]
 pub(crate) enum GitRepositoryError {
+    #[error("cannot create Git repository {path}: {reason}")]
+    Create { path: PathBuf, reason: String },
     #[error("cannot open Git repository {path}: {reason}")]
     Open { path: PathBuf, reason: String },
     #[error("Git repository is not bare: {0}")]
@@ -271,10 +309,55 @@
     UnadvertisedWant,
     #[error("object ID uses the wrong repository hash format")]
     WrongObjectFormat,
+    #[error("Git repository contains a symbolic link or special file: {0}")]
+    UnsafeFile(PathBuf),
+    #[error("cannot access Git repository path {path}: {source}")]
+    Filesystem {
+        path: PathBuf,
+        source: std::io::Error,
+    },
     #[error("Git object count or decoded size exceeds the limit")]
     ObjectLimit,
     #[error("generated Git pack exceeds the limit")]
     PackLimit,
     #[error("cannot generate Git pack: {0}")]
     Pack(String),
+}
+
+fn copy_repository_tree(source: &Path, destination: &Path) -> Result<(), GitRepositoryError> {
+    fs::create_dir(destination).map_err(|source_error| GitRepositoryError::Filesystem {
+        path: destination.to_owned(),
+        source: source_error,
+    })?;
+    for entry in fs::read_dir(source).map_err(|source_error| GitRepositoryError::Filesystem {
+        path: source.to_owned(),
+        source: source_error,
+    })? {
+        let entry = entry.map_err(|source_error| GitRepositoryError::Filesystem {
+            path: source.to_owned(),
+            source: source_error,
+        })?;
+        let source_path = entry.path();
+        let destination_path = destination.join(entry.file_name());
+        let file_type =
+            entry
+                .file_type()
+                .map_err(|source_error| GitRepositoryError::Filesystem {
+                    path: source_path.clone(),
+                    source: source_error,
+                })?;
+        if file_type.is_dir() {
+            copy_repository_tree(&source_path, &destination_path)?;
+        } else if file_type.is_file() {
+            fs::copy(&source_path, &destination_path).map_err(|source_error| {
+                GitRepositoryError::Filesystem {
+                    path: source_path,
+                    source: source_error,
+                }
+            })?;
+        } else {
+            return Err(GitRepositoryError::UnsafeFile(source_path));
+        }
+    }
+    Ok(())
 }

src/instance.rs

Mode 100644100644; object fa6efacd4a09a20d4eb225db

@@ -8,6 +8,8 @@
 
 const LOCK_FILE: &str = "tit.lock";
 const PRIVATE_MODE: u32 = 0o600;
+const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
+pub(crate) const REPOSITORY_DIRECTORY: &str = "repositories";
 
 pub(crate) struct InstanceLock {
     _file: File,
@@ -62,6 +64,45 @@
     Ok(path)
 }
 
+pub(crate) fn prepare_repository_root(instance_dir: &Path) -> Result<PathBuf, InstanceError> {
+    let path = instance_dir.join(REPOSITORY_DIRECTORY);
+    reject_symlink(&path)?;
+    match std::fs::create_dir(&path) {
+        Ok(()) => {
+            let mut permissions = std::fs::metadata(&path)
+                .map_err(|source| InstanceError::Open {
+                    path: path.clone(),
+                    source,
+                })?
+                .permissions();
+            permissions.set_mode(PRIVATE_DIRECTORY_MODE);
+            std::fs::set_permissions(&path, permissions).map_err(|source| InstanceError::Open {
+                path: path.clone(),
+                source,
+            })?;
+        }
+        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+        Err(source) => {
+            return Err(InstanceError::Open {
+                path: path.clone(),
+                source,
+            });
+        }
+    }
+    let metadata = std::fs::metadata(&path).map_err(|source| InstanceError::Open {
+        path: path.clone(),
+        source,
+    })?;
+    if !metadata.file_type().is_dir() {
+        return Err(InstanceError::InvalidDirectory(path));
+    }
+    let mode = metadata.permissions().mode() & 0o777;
+    if mode & 0o077 != 0 {
+        return Err(InstanceError::DirectoryPermissions { path, mode });
+    }
+    std::fs::canonicalize(&path).map_err(|source| InstanceError::Open { path, source })
+}
+
 fn reject_symlink(path: &Path) -> Result<(), InstanceError> {
     match std::fs::symlink_metadata(path) {
         Ok(metadata) if metadata.file_type().is_symlink() => {
@@ -109,6 +150,10 @@
     InvalidFile(PathBuf),
     #[error("instance file permissions for {path} are {mode:o}, expected 600 or more restrictive")]
     Permissions { path: PathBuf, mode: u32 },
+    #[error(
+        "instance directory permissions for {path} are {mode:o}, expected 700 or more restrictive"
+    )]
+    DirectoryPermissions { path: PathBuf, mode: u32 },
     #[error("cannot open instance file {path}: {source}")]
     Open {
         path: PathBuf,
@@ -174,6 +219,31 @@
         assert!(matches!(
             prepare_database(directory.path()),
             Err(InstanceError::Symlink(path)) if path == database
+        ));
+    }
+
+    #[test]
+    fn creates_a_private_canonical_repository_directory() {
+        let directory = TempDir::new().expect("create an instance directory");
+        let root = prepare_repository_root(directory.path()).expect("prepare repositories");
+        assert!(root.is_absolute());
+        assert_eq!(
+            fs::metadata(&root)
+                .expect("inspect repositories")
+                .permissions()
+                .mode()
+                & 0o777,
+            PRIVATE_DIRECTORY_MODE
+        );
+
+        let mut permissions = fs::metadata(&root)
+            .expect("inspect repositories")
+            .permissions();
+        permissions.set_mode(0o755);
+        fs::set_permissions(&root, permissions).expect("make repositories unsafe");
+        assert!(matches!(
+            prepare_repository_root(directory.path()),
+            Err(InstanceError::DirectoryPermissions { mode: 0o755, .. })
         ));
     }
 }

src/main.rs

Mode 100644100644; object 87591d5ee1a4241a2e8bab2c

@@ -1,3 +1,4 @@
+mod admin;
 #[allow(
     dead_code,
     reason = "the bootstrap command uses only part of the authentication API"
@@ -6,6 +7,7 @@
 mod bootstrap;
 mod cli;
 mod config;
+mod domain;
 #[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
 mod git;
 mod instance;
@@ -18,7 +20,7 @@
 
 use clap::Parser;
 
-use crate::cli::{Cli, Command, SetupCommand};
+use crate::cli::{AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand};
 
 fn main() -> ExitCode {
     let cli = match Cli::try_parse() {
@@ -66,7 +68,80 @@
                     ExitCode::FAILURE
                 }
             },
+            Some(Command::Admin {
+                command: AdminCommand::Repository { command },
+            }) => run_repository_command(&config.instance_dir, command),
         },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run_repository_command(instance_dir: &std::path::Path, command: RepositoryCommand) -> ExitCode {
+    let result = match command {
+        RepositoryCommand::Create {
+            owner,
+            slug,
+            object_format,
+        } => admin::create_repository(
+            instance_dir,
+            &owner,
+            &slug,
+            match object_format {
+                ObjectFormat::Sha1 => gix::hash::Kind::Sha1,
+                ObjectFormat::Sha256 => gix::hash::Kind::Sha256,
+            },
+        ),
+        RepositoryCommand::Import {
+            owner,
+            slug,
+            source,
+        } => admin::import_repository(instance_dir, &owner, &slug, &source),
+        RepositoryCommand::Rename {
+            owner,
+            old_slug,
+            new_slug,
+        } => admin::rename_repository(instance_dir, &owner, &old_slug, &new_slug),
+        RepositoryCommand::Archive { owner, slug } => {
+            admin::archive_repository(instance_dir, &owner, &slug)
+        }
+        RepositoryCommand::Inspect { owner, slug } => {
+            admin::inspect_repository(instance_dir, &owner, &slug)
+        }
+    };
+
+    match result {
+        Ok(repository) => {
+            let path = match admin::repository_path(instance_dir, &repository) {
+                Ok(path) => path,
+                Err(error) => {
+                    eprintln!("tit: {error}");
+                    return ExitCode::FAILURE;
+                }
+            };
+            let archived_at = repository
+                .archived_at
+                .map_or_else(|| "-".to_owned(), |value| value.to_string());
+            let mut output = io::stdout().lock();
+            let written = writeln!(output, "id={}", repository.id)
+                .and_then(|()| writeln!(output, "owner={}", repository.owner))
+                .and_then(|()| writeln!(output, "slug={}", repository.slug))
+                .and_then(|()| writeln!(output, "visibility={}", repository.visibility))
+                .and_then(|()| writeln!(output, "state={}", repository.state))
+                .and_then(|()| writeln!(output, "object-format={}", repository.object_format))
+                .and_then(|()| writeln!(output, "created-at={}", repository.created_at))
+                .and_then(|()| writeln!(output, "archived-at={archived_at}"))
+                .and_then(|()| writeln!(output, "path={}", path.display()));
+            match written {
+                Ok(()) => ExitCode::SUCCESS,
+                Err(error) => {
+                    eprintln!("tit: cannot write repository information: {error}");
+                    ExitCode::FAILURE
+                }
+            }
+        }
         Err(error) => {
             eprintln!("tit: {error}");
             ExitCode::FAILURE

src/store/migrations/005_repository.sql

Mode 100644; object 8ce93d613748

@@ -1,0 +1,29 @@
+CREATE TABLE repository (
+    id TEXT PRIMARY KEY
+        CHECK (length(id) = 32 AND id NOT GLOB '*[^0-9a-f]*'),
+    owner_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    slug TEXT NOT NULL
+        CHECK (
+            length(slug) BETWEEN 1 AND 100
+            AND slug NOT GLOB '*[^a-z0-9._-]*'
+            AND substr(slug, 1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -4) != '.git'
+            AND slug NOT IN ('admin', 'api', 'assets', 'feeds', 'issues', 'setup')
+        ),
+    visibility TEXT NOT NULL DEFAULT 'public'
+        CHECK (visibility IN ('public', 'private')),
+    state TEXT NOT NULL DEFAULT 'active'
+        CHECK (state IN ('active', 'archived')),
+    object_format TEXT NOT NULL
+        CHECK (object_format IN ('sha1', 'sha256')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    archived_at INTEGER
+        CHECK (archived_at IS NULL OR archived_at >= created_at),
+    UNIQUE (owner_account_id, slug),
+    CHECK (
+        (state = 'active' AND archived_at IS NULL)
+        OR (state = 'archived' AND archived_at IS NOT NULL)
+    )
+) STRICT;

src/store/mod.rs

Mode 100644100644; object 1322b247df6e9bac87610128

@@ -9,7 +9,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 4;
+const SCHEMA_VERSION: i64 = 5;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -19,11 +19,12 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 4] = [
+const MIGRATIONS: [&str; 5] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
     include_str!("migrations/004_identity.sql"),
+    include_str!("migrations/005_repository.sql"),
 ];
 
 #[derive(Debug, Error)]
@@ -54,6 +55,16 @@
     IntentState(String),
     #[error("the instance already has an administrator")]
     AlreadyInitialized,
+    #[error("account does not exist or is not active: {0}")]
+    AccountNotFound(String),
+    #[error("repository does not exist: {0}/{1}")]
+    RepositoryNotFound(String, String),
+    #[error("repository already exists: {0}/{1}")]
+    RepositoryExists(String, String),
+    #[error("repository ID already exists")]
+    RepositoryIdentifierCollision,
+    #[error("repository is already archived: {0}/{1}")]
+    RepositoryArchived(String, String),
 }
 
 pub(crate) struct Store {
@@ -326,6 +337,133 @@
         transaction.commit()?;
         Ok(())
     }
+
+    pub(crate) fn create_repository(
+        &mut self,
+        repository: &NewRepository<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let owner_id = active_account_id(&transaction, repository.owner)?;
+        let result = transaction.execute(
+            "INSERT INTO repository
+             (id, owner_account_id, slug, visibility, state, object_format, created_at, archived_at)
+             VALUES (?1, ?2, ?3, 'public', 'active', ?4, ?5, NULL)",
+            rusqlite::params![
+                repository.id,
+                owner_id,
+                repository.slug,
+                repository.object_format,
+                repository.created_at,
+            ],
+        );
+        match result {
+            Ok(1) => transaction.commit()?,
+            Ok(_) => unreachable!("an INSERT changes one row"),
+            Err(error) if is_unique_constraint(&error) => {
+                let duplicate_id: bool = transaction.query_row(
+                    "SELECT EXISTS(SELECT 1 FROM repository WHERE id = ?1)",
+                    [repository.id],
+                    |row| row.get(0),
+                )?;
+                return if duplicate_id {
+                    Err(StoreError::RepositoryIdentifierCollision)
+                } else {
+                    Err(StoreError::RepositoryExists(
+                        repository.owner.to_owned(),
+                        repository.slug.to_owned(),
+                    ))
+                };
+            }
+            Err(error) => return Err(error.into()),
+        }
+        Ok(())
+    }
+
+    pub(crate) fn rename_repository(
+        &mut self,
+        owner: &str,
+        old_slug: &str,
+        new_slug: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let owner_id = active_account_id(&transaction, owner)?;
+        let result = transaction.execute(
+            "UPDATE repository SET slug = ?3
+             WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+            rusqlite::params![owner_id, old_slug, new_slug],
+        );
+        match result {
+            Ok(1) => transaction.commit()?,
+            Ok(0) => {
+                return Err(repository_state_error(
+                    &transaction,
+                    owner_id,
+                    owner,
+                    old_slug,
+                )?);
+            }
+            Ok(_) => unreachable!("an owner and slug identify one repository"),
+            Err(error) if is_unique_constraint(&error) => {
+                return Err(StoreError::RepositoryExists(
+                    owner.to_owned(),
+                    new_slug.to_owned(),
+                ));
+            }
+            Err(error) => return Err(error.into()),
+        }
+        Ok(())
+    }
+
+    pub(crate) fn archive_repository(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        archived_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let owner_id = active_account_id(&transaction, owner)?;
+        let changed = transaction.execute(
+            "UPDATE repository SET state = 'archived', archived_at = ?3
+             WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+            rusqlite::params![owner_id, slug, archived_at],
+        )?;
+        if changed == 0 {
+            return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
+        }
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn repository(
+        &self,
+        owner: &str,
+        slug: &str,
+    ) -> Result<RepositoryRecord, StoreError> {
+        let result = self.connection.query_row(
+            "SELECT repository.id, account.username, repository.slug,
+                    repository.visibility, repository.state, repository.object_format,
+                    repository.created_at, repository.archived_at
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             WHERE account.username = ?1 AND repository.slug = ?2",
+            rusqlite::params![owner, slug],
+            repository_from_row,
+        );
+        match result {
+            Ok(repository) => Ok(repository),
+            Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::RepositoryNotFound(
+                owner.to_owned(),
+                slug.to_owned(),
+            )),
+            Err(error) => Err(error.into()),
+        }
+    }
 }
 
 pub(crate) struct InitialAdministrator<'a> {
@@ -334,6 +472,26 @@
     pub(crate) fingerprint: &'a str,
     pub(crate) recovery_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
+}
+
+pub(crate) struct NewRepository<'a> {
+    pub(crate) id: &'a str,
+    pub(crate) owner: &'a str,
+    pub(crate) slug: &'a str,
+    pub(crate) object_format: &'a str,
+    pub(crate) created_at: i64,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) struct RepositoryRecord {
+    pub(crate) id: String,
+    pub(crate) owner: String,
+    pub(crate) slug: String,
+    pub(crate) visibility: String,
+    pub(crate) state: String,
+    pub(crate) object_format: String,
+    pub(crate) created_at: i64,
+    pub(crate) archived_at: Option<i64>,
 }
 
 pub(crate) struct GitOperationIntent<'a> {
@@ -363,6 +521,71 @@
     } else {
         Err(StoreError::IntentState(id.to_owned()))
     }
+}
+
+fn active_account_id(
+    transaction: &rusqlite::Transaction<'_>,
+    username: &str,
+) -> Result<i64, StoreError> {
+    let result = transaction.query_row(
+        "SELECT id FROM account WHERE username = ?1 AND state = 'active'",
+        [username],
+        |row| row.get(0),
+    );
+    match result {
+        Ok(id) => Ok(id),
+        Err(rusqlite::Error::QueryReturnedNoRows) => {
+            Err(StoreError::AccountNotFound(username.to_owned()))
+        }
+        Err(error) => Err(error.into()),
+    }
+}
+
+fn repository_state_error(
+    transaction: &rusqlite::Transaction<'_>,
+    owner_id: i64,
+    owner: &str,
+    slug: &str,
+) -> Result<StoreError, StoreError> {
+    let state = transaction.query_row(
+        "SELECT state FROM repository WHERE owner_account_id = ?1 AND slug = ?2",
+        rusqlite::params![owner_id, slug],
+        |row| row.get::<_, String>(0),
+    );
+    match state {
+        Ok(state) if state == "archived" => Ok(StoreError::RepositoryArchived(
+            owner.to_owned(),
+            slug.to_owned(),
+        )),
+        Ok(_) => unreachable!("repository state has a database constraint"),
+        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(StoreError::RepositoryNotFound(
+            owner.to_owned(),
+            slug.to_owned(),
+        )),
+        Err(error) => Err(error.into()),
+    }
+}
+
+fn repository_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
+    Ok(RepositoryRecord {
+        id: row.get(0)?,
+        owner: row.get(1)?,
+        slug: row.get(2)?,
+        visibility: row.get(3)?,
+        state: row.get(4)?,
+        object_format: row.get(5)?,
+        created_at: row.get(6)?,
+        archived_at: row.get(7)?,
+    })
+}
+
+fn is_unique_constraint(error: &rusqlite::Error) -> bool {
+    matches!(
+        error,
+        rusqlite::Error::SqliteFailure(code, _)
+            if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
+                || code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
+    )
 }
 
 #[allow(

tests/cli.rs

Mode 100644100644; object c59dc2a1900c1e9b2ef02902

@@ -2,6 +2,7 @@
 
 use std::fs;
 use std::os::unix::fs::PermissionsExt;
+use std::path::PathBuf;
 use std::process::{Command, Stdio};
 
 use sha2::{Digest, Sha256};
@@ -11,7 +12,7 @@
 };
 use tempfile::TempDir;
 
-const V4_DATABASE: &str = include_str!("fixtures/sqlite/v4.sql");
+const V5_DATABASE: &str = include_str!("fixtures/sqlite/v5.sql");
 
 #[test]
 fn help_and_version_use_standard_output() {
@@ -66,7 +67,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V4_DATABASE)
+        .execute_batch(V5_DATABASE)
         .expect("create the current database");
     drop(database);
 
@@ -118,7 +119,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V4_DATABASE)
+        .execute_batch(V5_DATABASE)
         .expect("create the current database");
     database
         .pragma_update(None, "foreign_keys", false)
@@ -355,4 +356,229 @@
             .expect("count accounts"),
         1
     );
+}
+
+#[test]
+fn administers_repositories_with_immutable_canonical_paths() {
+    let instance = TestInstance::new();
+    create_administrator(&instance, "alice");
+    let config = instance.config().to_str().expect("a UTF-8 path");
+
+    let created = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config,
+            "admin",
+            "repository",
+            "create",
+            "alice",
+            "project",
+            "--object-format",
+            "sha256",
+        ])
+        .env("PATH", "")
+        .output()
+        .expect("create a repository without runtime Git");
+    assert!(
+        created.status.success(),
+        "create failed: {}",
+        String::from_utf8_lossy(&created.stderr)
+    );
+    let created = repository_output(&created.stdout);
+    assert_eq!(created.get("owner").map(String::as_str), Some("alice"));
+    assert_eq!(created.get("slug").map(String::as_str), Some("project"));
+    assert_eq!(
+        created.get("visibility").map(String::as_str),
+        Some("public")
+    );
+    assert_eq!(created.get("state").map(String::as_str), Some("active"));
+    assert_eq!(
+        created.get("object-format").map(String::as_str),
+        Some("sha256")
+    );
+    let id = created.get("id").expect("read the repository ID");
+    assert_eq!(id.len(), 32);
+    assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit()));
+    let path = PathBuf::from(created.get("path").expect("read the repository path"));
+    let repository_root = fs::canonicalize(instance.path().join("repositories"))
+        .expect("canonicalize managed repositories");
+    assert_eq!(path.parent(), Some(repository_root.as_path()));
+    let expected_name = format!("{id}.git");
+    assert_eq!(
+        path.file_name().and_then(|name| name.to_str()),
+        Some(expected_name.as_str())
+    );
+    assert_eq!(
+        fs::read_to_string(path.join("HEAD")).expect("read repository HEAD"),
+        "ref: refs/heads/main\n"
+    );
+
+    let renamed = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "rename",
+        "alice",
+        "project",
+        "renamed",
+    ]);
+    assert!(renamed.status.success());
+    let renamed = repository_output(&renamed.stdout);
+    assert_eq!(renamed.get("slug").map(String::as_str), Some("renamed"));
+    assert_eq!(renamed.get("id"), Some(id));
+    assert_eq!(renamed.get("path"), created.get("path"));
+
+    let old_name = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "inspect",
+        "alice",
+        "project",
+    ]);
+    assert_eq!(old_name.status.code(), Some(1));
+    let archived = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "archive",
+        "alice",
+        "renamed",
+    ]);
+    assert!(archived.status.success());
+    let archived = repository_output(&archived.stdout);
+    assert_eq!(archived.get("state").map(String::as_str), Some("archived"));
+    assert_ne!(archived.get("archived-at").map(String::as_str), Some("-"));
+    let second_archive = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "archive",
+        "alice",
+        "renamed",
+    ]);
+    assert_eq!(second_archive.status.code(), Some(1));
+
+    let duplicate = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "create",
+        "alice",
+        "renamed",
+    ]);
+    assert_eq!(duplicate.status.code(), Some(1));
+    let missing_owner = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "create",
+        "bob",
+        "project",
+    ]);
+    assert_eq!(missing_owner.status.code(), Some(1));
+}
+
+#[test]
+fn imports_bare_repositories_and_rejects_unsafe_sources() {
+    let instance = TestInstance::new();
+    create_administrator(&instance, "alice");
+    let config = instance.config().to_str().expect("a UTF-8 path");
+    let source = instance.path().join("source.git");
+    create_bare_git_fixture(&source, "sha1");
+
+    let imported = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "import",
+        "alice",
+        "imported",
+        source.to_str().expect("a UTF-8 path"),
+    ]);
+    assert!(
+        imported.status.success(),
+        "import failed: {}",
+        String::from_utf8_lossy(&imported.stderr)
+    );
+    let imported = repository_output(&imported.stdout);
+    assert_eq!(
+        imported.get("object-format").map(String::as_str),
+        Some("sha1")
+    );
+    assert_ne!(imported.get("path"), Some(&source.display().to_string()));
+
+    let unsafe_source = instance.path().join("unsafe.git");
+    create_bare_git_fixture(&unsafe_source, "sha1");
+    std::os::unix::fs::symlink("HEAD", unsafe_source.join("unsafe-link"))
+        .expect("create a repository symlink");
+    let unsafe_import = instance.run(&[
+        "--config",
+        config,
+        "admin",
+        "repository",
+        "import",
+        "alice",
+        "unsafe",
+        unsafe_source.to_str().expect("a UTF-8 path"),
+    ]);
+    assert_eq!(unsafe_import.status.code(), Some(1));
+    assert!(unsafe_import.stdout.is_empty());
+    assert!(
+        fs::read_dir(instance.path().join("repositories"))
+            .expect("read managed repositories")
+            .all(|entry| !entry
+                .expect("read a managed repository")
+                .file_name()
+                .to_string_lossy()
+                .starts_with(".pending-"))
+    );
+
+    for slug in ["Project", "project.git", "../project", "admin"] {
+        let rejected = instance.run(&[
+            "--config",
+            config,
+            "admin",
+            "repository",
+            "create",
+            "alice",
+            slug,
+        ]);
+        assert_eq!(rejected.status.code(), Some(1), "accepted {slug:?}");
+        assert!(rejected.stdout.is_empty());
+    }
+}
+
+fn create_administrator(instance: &TestInstance, username: &str) {
+    let private_key = instance.path().join("admin-key");
+    create_ssh_key_fixture(&private_key);
+    let public_key = fs::read_to_string(private_key.with_extension("pub"))
+        .expect("read the administrator public key");
+    let output = instance.run(&[
+        "--config",
+        instance.config().to_str().expect("a UTF-8 path"),
+        "setup",
+        "admin",
+        username,
+        public_key.trim(),
+    ]);
+    assert!(output.status.success());
+}
+
+fn repository_output(output: &[u8]) -> std::collections::BTreeMap<String, String> {
+    String::from_utf8(output.to_vec())
+        .expect("read repository output")
+        .lines()
+        .map(|line| {
+            let (name, value) = line.split_once('=').expect("read a repository field");
+            (name.to_owned(), value.to_owned())
+        })
+        .collect()
 }

tests/fixtures/sqlite/v5.sql

Mode 100644; object b1be132a36a3

@@ -1,0 +1,120 @@
+CREATE TABLE m1a_parent (
+    id INTEGER PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE CHECK (length(name) BETWEEN 1 AND 64),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX m1a_parent_created_at ON m1a_parent (created_at, id);
+
+CREATE TABLE m1a_child (
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL REFERENCES m1a_parent (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 0),
+    body TEXT NOT NULL,
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+    UNIQUE (parent_id, sequence)
+) STRICT;
+
+CREATE INDEX m1a_child_state_parent
+ON m1a_child (state, parent_id, sequence);
+
+CREATE TABLE git_operation_intent (
+    id TEXT PRIMARY KEY CHECK (length(id) = 32),
+    repository_path TEXT NOT NULL CHECK (length(repository_path) BETWEEN 1 AND 4096),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    initial_refs BLOB NOT NULL,
+    proposed_refs BLOB NOT NULL,
+    event_payload BLOB NOT NULL,
+    quarantine_path TEXT NOT NULL CHECK (length(quarantine_path) BETWEEN 1 AND 4096),
+    state TEXT NOT NULL CHECK (state IN ('pending', 'promoted', 'completed', 'abandoned')),
+    pack_name TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX git_operation_intent_incomplete
+ON git_operation_intent (state, created_at, id)
+WHERE state IN ('pending', 'promoted');
+
+CREATE TABLE git_operation_event (
+    id INTEGER PRIMARY KEY,
+    intent_id TEXT NOT NULL UNIQUE
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    payload BLOB NOT NULL
+) STRICT;
+
+CREATE TABLE account (
+    id INTEGER PRIMARY KEY,
+    username TEXT NOT NULL UNIQUE
+        CHECK (
+            length(username) BETWEEN 1 AND 40
+            AND username NOT GLOB '*[^a-z0-9-]*'
+            AND substr(username, 1, 1) != '-'
+            AND substr(username, -1, 1) != '-'
+        ),
+    is_administrator INTEGER NOT NULL
+        CHECK (is_administrator IN (0, 1)),
+    state TEXT NOT NULL
+        CHECK (state IN ('active', 'suspended')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE ssh_public_key (
+    id INTEGER PRIMARY KEY,
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    canonical_key TEXT NOT NULL
+        CHECK (length(canonical_key) BETWEEN 1 AND 16384),
+    fingerprint TEXT NOT NULL UNIQUE
+        CHECK (length(fingerprint) BETWEEN 1 AND 256),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (account_id, canonical_key)
+) STRICT;
+
+CREATE INDEX ssh_public_key_account
+ON ssh_public_key (account_id, id);
+
+CREATE TABLE recovery_credential (
+    account_id INTEGER PRIMARY KEY
+        REFERENCES account (id) ON DELETE RESTRICT,
+    credential_hash BLOB NOT NULL UNIQUE
+        CHECK (length(credential_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE repository (
+    id TEXT PRIMARY KEY
+        CHECK (length(id) = 32 AND id NOT GLOB '*[^0-9a-f]*'),
+    owner_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    slug TEXT NOT NULL
+        CHECK (
+            length(slug) BETWEEN 1 AND 100
+            AND slug NOT GLOB '*[^a-z0-9._-]*'
+            AND substr(slug, 1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -4) != '.git'
+            AND slug NOT IN ('admin', 'api', 'assets', 'feeds', 'issues', 'setup')
+        ),
+    visibility TEXT NOT NULL DEFAULT 'public'
+        CHECK (visibility IN ('public', 'private')),
+    state TEXT NOT NULL DEFAULT 'active'
+        CHECK (state IN ('active', 'archived')),
+    object_format TEXT NOT NULL
+        CHECK (object_format IN ('sha1', 'sha256')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    archived_at INTEGER
+        CHECK (archived_at IS NULL OR archived_at >= created_at),
+    UNIQUE (owner_account_id, slug),
+    CHECK (
+        (state = 'active' AND archived_at IS NULL)
+        OR (state = 'archived' AND archived_at IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO m1a_parent (id, name, created_at)
+VALUES (1, 'fixture', 1);
+
+INSERT INTO m1a_child (id, parent_id, sequence, body, state)
+VALUES (1, 1, 1, 'version five', 'closed');
+
+PRAGMA user_version = 5;

tests/git_repository.rs

Mode 100644100644; object 022bdfdc843df43c41209fcd

@@ -36,6 +36,40 @@
 }
 
 #[test]
+fn creates_main_bare_repositories_and_copies_without_git() {
+    let directory = TempDir::new().expect("create a repository directory");
+    for kind in [gix::hash::Kind::Sha1, gix::hash::Kind::Sha256] {
+        let source = directory.path().join(format!("source-{kind}"));
+        GitRepository::create_bare(&source, kind).expect("create a bare repository");
+        assert_eq!(
+            fs::read_to_string(source.join("HEAD")).expect("read HEAD"),
+            "ref: refs/heads/main\n"
+        );
+        let destination = directory.path().join(format!("copy-{kind}"));
+        assert_eq!(
+            GitRepository::copy_bare(&source, &destination).expect("copy a bare repository"),
+            kind
+        );
+        assert_eq!(
+            GitRepository::open(&destination)
+                .expect("open the copy")
+                .object_format(),
+            kind
+        );
+    }
+
+    let unsafe_source = directory.path().join("unsafe");
+    GitRepository::create_bare(&unsafe_source, gix::hash::Kind::Sha1)
+        .expect("create an unsafe-source repository");
+    std::os::unix::fs::symlink("HEAD", unsafe_source.join("unsafe-link"))
+        .expect("create a repository symlink");
+    assert!(matches!(
+        GitRepository::copy_bare(&unsafe_source, &directory.path().join("unsafe-copy")),
+        Err(GitRepositoryError::UnsafeFile(_))
+    ));
+}
+
+#[test]
 fn reads_sorted_refs_and_generates_complete_packs_for_both_hashes() {
     let directory = TempDir::new().expect("create a repository directory");
     for format in ["sha1", "sha256"] {

tests/sqlite.rs

Mode 100644100644; object 933886ab2fa7353eb3086014

@@ -9,12 +9,13 @@
 use std::{env, ffi::OsString, fs};
 
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
-use store::{GitOperationIntent, InitialAdministrator, Store, StoreError};
+use store::{GitOperationIntent, InitialAdministrator, NewRepository, Store, StoreError};
 use tempfile::TempDir;
 
 const V1_FIXTURE: &str = include_str!("fixtures/sqlite/v1.sql");
 const V2_FIXTURE: &str = include_str!("fixtures/sqlite/v2.sql");
 const V3_FIXTURE: &str = include_str!("fixtures/sqlite/v3.sql");
+const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -132,7 +133,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 4);
+    assert_eq!(store.schema_version().expect("read the schema version"), 5);
     assert_eq!(
         store
             .connection()
@@ -287,6 +288,92 @@
             .expect("count accounts"),
         1
     );
+}
+
+#[test]
+fn creates_renames_archives_and_reads_owned_repositories() {
+    let directory = TempDir::new().expect("create a temporary directory");
+    let mut store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
+    let recovery_hash = [7_u8; 32];
+    store
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: "ssh-ed25519 AAAAexample",
+            fingerprint: "SHA256:example",
+            recovery_hash: &recovery_hash,
+            created_at: 10,
+        })
+        .expect("create an account");
+    let repository = NewRepository {
+        id: "00112233445566778899aabbccddeeff",
+        owner: "alice",
+        slug: "project",
+        object_format: "sha256",
+        created_at: 20,
+    };
+    store
+        .create_repository(&repository)
+        .expect("create a repository");
+    let created = store
+        .repository("alice", "project")
+        .expect("read a repository");
+    assert_eq!(created.id, repository.id);
+    assert_eq!(created.owner, "alice");
+    assert_eq!(created.slug, "project");
+    assert_eq!(created.visibility, "public");
+    assert_eq!(created.state, "active");
+    assert_eq!(created.object_format, "sha256");
+    assert_eq!(created.created_at, 20);
+    assert_eq!(created.archived_at, None);
+
+    assert!(matches!(
+        store.create_repository(&repository),
+        Err(StoreError::RepositoryIdentifierCollision)
+    ));
+    let duplicate_name = NewRepository {
+        id: "ffeeddccbbaa99887766554433221100",
+        ..repository
+    };
+    assert!(matches!(
+        store.create_repository(&duplicate_name),
+        Err(StoreError::RepositoryExists(owner, slug))
+            if owner == "alice" && slug == "project"
+    ));
+    let missing_owner = NewRepository {
+        id: "1234567890abcdef1234567890abcdef",
+        owner: "bob",
+        slug: "project",
+        object_format: "sha1",
+        created_at: 20,
+    };
+    assert!(matches!(
+        store.create_repository(&missing_owner),
+        Err(StoreError::AccountNotFound(owner)) if owner == "bob"
+    ));
+
+    store
+        .rename_repository("alice", "project", "renamed")
+        .expect("rename a repository");
+    assert!(matches!(
+        store.repository("alice", "project"),
+        Err(StoreError::RepositoryNotFound(_, _))
+    ));
+    store
+        .archive_repository("alice", "renamed", 30)
+        .expect("archive a repository");
+    let archived = store
+        .repository("alice", "renamed")
+        .expect("read the archived repository");
+    assert_eq!(archived.state, "archived");
+    assert_eq!(archived.archived_at, Some(30));
+    assert!(matches!(
+        store.archive_repository("alice", "renamed", 31),
+        Err(StoreError::RepositoryArchived(_, _))
+    ));
+    assert!(matches!(
+        store.rename_repository("alice", "renamed", "again"),
+        Err(StoreError::RepositoryArchived(_, _))
+    ));
 }
 
 #[test]
@@ -616,13 +703,18 @@
 
 #[test]
 fn migrates_each_committed_historical_fixture() {
-    for (fixture, initial_version) in [(V1_FIXTURE, 1), (V2_FIXTURE, 2), (V3_FIXTURE, 3)] {
+    for (fixture, initial_version) in [
+        (V1_FIXTURE, 1),
+        (V2_FIXTURE, 2),
+        (V3_FIXTURE, 3),
+        (V4_FIXTURE, 4),
+    ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 4);
+        assert_eq!(store.schema_version().expect("read the schema version"), 5);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -651,7 +743,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 4)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 5)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);