michal/tit
Browse tree · Show commit · Download archive
Diff
5e59e30884fd → 631363186be3
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 100644 → 100644; object 90896b3c72c6 → daefd6aa2c09
@@ -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 100644 → 100644; object 07060b2da296 → 3adddd388cc6
@@ -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 100644 → 100644; object fa6efacd4a09 → a20d4eb225db
@@ -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 100644 → 100644; object 87591d5ee1a4 → 241a2e8bab2c
@@ -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 100644 → 100644; object 1322b247df6e → 9bac87610128
@@ -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 100644 → 100644; object c59dc2a1900c → 1e9b2ef02902
@@ -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 100644 → 100644; object 022bdfdc843d → f43c41209fcd
@@ -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 100644 → 100644; object 933886ab2fa7 → 353eb3086014
@@ -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);