michal/tit
Browse tree · Show commit · Download archive
Diff
aa696d6812b0 → b68b95d1480e
README.md
Mode 100644 → 100644; object 438e26dcbc79 → 6b1c15b383ab
@@ -69,6 +69,32 @@ The history does not store recovery credentials, login challenges, signatures, session tokens, or SSH private keys. +An authenticated account can create a repository with SSH: + +```text +ssh -p 2222 tit.example repo create project +``` + +The account that owns the SSH key becomes the repository owner. The SSH login +name does not select the owner. New repositories use SHA-1 unless the command +selects SHA-256: + +```text +ssh -p 2222 tit.example repo create project --object-format sha256 +``` + +Use the versioned JSON mode for scripts: + +```text +ssh -p 2222 tit.example repo create project --output json +``` + +The complete command is `repo create NAME [--object-format sha1|sha256] +[--output human|json]`. Human output can change in a later release. The JSON +object has `version`, `status`, and `repository` fields after success. It has +`version`, `status`, and `error.code` fields after failure. The command returns +zero after success and nonzero after failure. + ## Quality gate Install `cargo-deny` version 0.20.2. Then, run this command from the repository @@ -240,3 +266,17 @@ exclusion. Read the [audit history architectural decision record](docs/adr/0014-audit-history.md) for the transaction and recovery rules. + +## Milestone 3.6 gate + +Run the SSH repository command gate: + +```text +./scripts/check-m3-6 +``` + +This command tests repository creation from the Web UI and SSH. It tests +account ownership, both object formats, human output, JSON output, failure +codes, audit events, and clone access. Read the +[SSH repository command architectural decision record](docs/adr/0015-ssh-repository-commands.md) +for the command and authorization rules.
docs/adr/0015-ssh-repository-commands.md
Mode → 100644; object → 20f6038bd52f
@@ -1,0 +1,99 @@
+# Architectural decision record 0015: SSH repository commands
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+An account needs to create a repository without a browser. The SSH command
+must use the account that the SSH public key selects. It must not start a shell
+or parse a shell language. A script needs output that does not depend on the
+human text.
+
+Repository creation is also available in the account Web page. Both transports
+must apply the same name rules, owner rules, storage steps, and audit rules.
+
+## Decision
+
+Use one `RepositoryService` for the Web form, the SSH command, and the offline
+administrator create command. The Web and SSH methods set the owner to the
+authenticated account. The administrator method can name an owner because it
+runs only while the offline instance lock is held. The store rejects an owner
+account that does not exist or is not active.
+
+Accept this SSH command:
+
+```text
+repo create NAME [--object-format sha1|sha256] [--output human|json]
+```
+
+The default object format is SHA-1. The default output is human text. Options
+can occur in either order, but each option can occur one time. Limit the full
+command to 512 ASCII bytes. Reject control characters, unknown words, missing
+values, repeated options, invalid names, and unsupported values. Do not use a
+shell tokenizer or pass command data to a process.
+
+The human success output has two lines. The first line identifies the owner and
+repository. The second line identifies the object format. Human output is for
+a person and is not a script interface.
+
+JSON output is one UTF-8 line. A successful result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project","object_format":"sha1"}}
+```
+
+A failed result has this form:
+
+```json
+{"version":1,"status":"error","error":{"code":"repository-exists"}}
+```
+
+Version 1 error codes are `invalid-command`, `invalid-name`,
+`repository-exists`, `account-unavailable`, `service-unavailable`, and
+`repository-create-failed`. Send JSON results on standard output. Send human
+errors on standard error. Return exit status zero for success and one for
+failure.
+
+Create a bare repository in a random pending path under the managed repository
+root. Rename it to its random final path before the SQLite insert. If a later
+step fails, remove the path that this request created. Insert the repository,
+repository event, and successful audit event in one SQLite transaction. Record
+a failed audit event in a separate transaction after a rejected create
+operation.
+
+## Failure and threat cases
+
+The SSH username does not select the account or owner. The authenticated public
+key selects the account. A current active-account check occurs again in the
+repository transaction. This check rejects a key if its account became inactive
+after SSH authentication.
+
+Repository names use the common domain validator. They cannot contain a path
+separator, begin or end with punctuation, use a reserved route name, or end in
+`.git`. JSON strings contain only values that this validator and the account
+validator accept, so command data cannot add JSON syntax.
+
+The SSH server continues to reject shells, terminal allocation, forwarding,
+subsystems, agent forwarding, and arbitrary commands. The repository command
+uses the existing bounded worker pool. It does not run Git or OpenSSH at
+runtime.
+
+## Evidence
+
+The production server test creates repositories as a non-administrator and as
+an administrator. It compares the exact human and JSON output, checks a stable
+JSON failure, clones the new empty repository with stock Git, and verifies the
+stored owner, object format, and audit outcomes.
+
+The Web production test creates a SHA-256 repository from the account page. It
+checks the CSRF failure, redirect, public route, authenticated owner, and HTTP
+request correlation ID. The existing SSH security tests continue to reject all
+other exec commands and SSH features.
+
+## Consequences
+
+Future SSH issue and pull-request commands can use the same bounded parser and
+versioned output rules. A later JSON version can add fields without changing
+version 1. Scripts select JSON explicitly and do not parse human text.
scripts/check-m3-6
Mode → 100755; object → 385bdfb18c01
@@ -1,0 +1,8 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test cli administers_repositories_with_immutable_canonical_paths +cargo test --locked --release --test ssh +cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh +cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output
src/admin.rs
Mode 100644 → 100644; object 92019bbddc84 → c4d2cc97f3f4
@@ -10,6 +10,7 @@
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::repository::{RepositoryService, RepositoryServiceError};
use crate::store::{
AuditContext, NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store,
StoreError,
@@ -23,17 +24,12 @@
slug: &str,
object_format: Kind,
) -> Result<RepositoryRecord, AdminError> {
- validate_names(owner, slug)?;
- administer_repository(
- instance_dir,
- owner,
- slug,
- RepositoryOrigin::Created,
- |path| {
- GitRepository::create_bare(path, object_format)?;
- Ok(object_format)
- },
- )
+ let _lock = InstanceLock::acquire(instance_dir)?;
+ let database = prepare_database(instance_dir)?;
+ let root = prepare_repository_root(instance_dir)?;
+ RepositoryService::new(&database, &root)
+ .create_for_administrator(owner, slug, object_format, &random_id()?)
+ .map_err(Into::into)
}
pub(crate) fn import_repository(
@@ -409,6 +405,8 @@
Store(#[from] StoreError),
#[error(transparent)]
Git(#[from] GitRepositoryError),
+ #[error(transparent)]
+ RepositoryService(#[from] RepositoryServiceError),
#[error("cannot canonicalize path {path}: {source}")]
Canonicalize {
path: PathBuf,
src/git/transport.rs
Mode 100644 → 100644; object 2346cfb07b4d → 76f3d3c757fa
@@ -208,6 +208,10 @@
self.push_database.as_deref()
}
+ pub(crate) fn repository_root(&self) -> &Path {
+ &self.root
+ }
+
pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.push_jobs.clone().acquire_owned().await
}
src/http/mod.rs
Mode 100644 → 100644; object e5546004dd2d → a2e3d8caea9b
@@ -20,6 +20,7 @@
use crate::account::{AccountError, AccountService};
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
+use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::session::{SessionError, WebLoginService};
use crate::store::StoreError;
@@ -40,6 +41,7 @@
jobs: Arc<Semaphore>,
key_reloader: Option<AccountKeyReloader>,
login: Option<WebLoginService>,
+ repositories: Option<RepositoryService>,
secure_cookies: bool,
}
@@ -71,6 +73,7 @@
jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
key_reloader: None,
login: None,
+ repositories: None,
secure_cookies: false,
},
)
@@ -105,6 +108,7 @@
let secure_cookies = public_url.scheme() == "https";
let login = WebLoginService::new(database, &public_url)?;
let public = PublicWeb::open(config, Arc::clone(&jobs))?;
+ let repositories = RepositoryService::new(public.database(), public.repository_root());
Self::start_with_state(
address,
WebState {
@@ -113,6 +117,7 @@
jobs,
key_reloader,
login: Some(login),
+ repositories: Some(repositories),
secure_cookies,
},
)
@@ -155,6 +160,7 @@
jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
key_reloader: None,
login: None,
+ repositories: None,
secure_cookies: false,
})
}
@@ -194,6 +200,10 @@
axum::routing::post(login_verify_file).layer(DefaultBodyLimit::max(64 * 1024)),
)
.route("/account", get(account_page))
+ .route(
+ "/account/repositories",
+ axum::routing::post(create_repository).layer(DefaultBodyLimit::max(4 * 1024)),
+ )
.route(
"/logout",
axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
@@ -603,6 +613,101 @@
}
}
+async fn create_repository(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "name", "object-format"]) {
+ Ok(fields) => fields,
+ Err(()) => {
+ return repository_form_error(
+ &request_id.0,
+ StatusCode::BAD_REQUEST,
+ "The repository request is not valid.",
+ );
+ }
+ };
+ let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+ return login_redirect(false);
+ };
+ let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+ return login_redirect(true);
+ };
+ if fields[0] != csrf {
+ return repository_form_error(
+ &request_id.0,
+ StatusCode::FORBIDDEN,
+ "The request is not authorized.",
+ );
+ }
+ let object_format = match fields[2].as_str() {
+ "sha1" => gix::hash::Kind::Sha1,
+ "sha256" => gix::hash::Kind::Sha256,
+ _ => {
+ return repository_form_error(
+ &request_id.0,
+ StatusCode::BAD_REQUEST,
+ "The repository request is not valid.",
+ );
+ }
+ };
+ let csrf_for_auth = csrf.clone();
+ let session = match login_job(state.clone(), move |login| {
+ login.authenticate(&session_token, Some(&csrf_for_auth))
+ })
+ .await
+ {
+ Ok(session) => session,
+ Err(_) => return login_redirect(true),
+ };
+ let owner = session.username;
+ let slug = fields[1].clone();
+ let correlation_id = request_id.0.clone();
+ let owner_for_job = owner.clone();
+ let slug_for_job = slug.clone();
+ let result = repository_job(state, move |repositories| {
+ repositories.create_for_account(
+ &owner_for_job,
+ &slug_for_job,
+ object_format,
+ &correlation_id,
+ )
+ })
+ .await;
+ match result {
+ Ok(_) => Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(header::LOCATION, format!("/{owner}/{slug}"))
+ .body(Body::empty())
+ .expect("the repository redirect is valid"),
+ Err(RepositoryServiceError::Auth(_)) | Err(RepositoryServiceError::RepositoryName(_)) => {
+ repository_form_error(
+ &request_id.0,
+ StatusCode::BAD_REQUEST,
+ "The repository name is not valid.",
+ )
+ }
+ Err(RepositoryServiceError::Store(StoreError::RepositoryExists(_, _))) => {
+ repository_form_error(
+ &request_id.0,
+ StatusCode::CONFLICT,
+ "A repository with this name already exists.",
+ )
+ }
+ Err(_) => repository_form_error(
+ &request_id.0,
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "The repository could not be created.",
+ ),
+ }
+}
+
+fn repository_form_error(request_id: &str, status: StatusCode, message: &str) -> Response {
+ render_error(status, request_id, "Repository error", message)
+}
+
async fn logout(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
@@ -661,6 +766,30 @@
})
.await
.map_err(|_| SessionError::Unavailable)?
+}
+
+async fn repository_job<T: Send + 'static>(
+ state: WebState,
+ operation: impl FnOnce(RepositoryService) -> Result<T, RepositoryServiceError> + Send + 'static,
+) -> Result<T, RepositoryServiceError> {
+ let repositories = state.repositories.ok_or_else(|| {
+ RepositoryServiceError::Store(StoreError::Integrity(
+ "repository service is unavailable".to_owned(),
+ ))
+ })?;
+ let permit = state.jobs.acquire_owned().await.map_err(|_| {
+ RepositoryServiceError::Store(StoreError::Integrity(
+ "repository worker pool is unavailable".to_owned(),
+ ))
+ })?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ operation(repositories)
+ })
+ .await
+ .map_err(|_| {
+ RepositoryServiceError::Store(StoreError::Integrity("repository worker failed".to_owned()))
+ })?
}
fn parse_named_form(
src/http/public.rs
Mode 100644 → 100644; object c888b44334b8 → 748c7c6ff631
@@ -1,6 +1,6 @@
use std::fs;
use std::io::Write;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
@@ -72,6 +72,14 @@
jobs,
policy,
})
+ }
+
+ pub(super) fn database(&self) -> &Path {
+ &self.database
+ }
+
+ pub(super) fn repository_root(&self) -> &Path {
+ &self.repositories
}
async fn read<T, F>(
src/main.rs
Mode 100644 → 100644; object c9ebea1ff23f → 0891b158adb8
@@ -18,6 +18,7 @@ mod instance; mod markdown; mod policy; +mod repository; mod serve; mod session; #[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
src/repository.rs
Mode → 100644; object → 36fd40620a73
@@ -1,0 +1,225 @@
+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::store::{
+ NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+};
+
+#[derive(Clone)]
+pub(crate) struct RepositoryService {
+ database: PathBuf,
+ root: PathBuf,
+}
+
+impl RepositoryService {
+ pub(crate) fn new(database: &Path, root: &Path) -> Self {
+ Self {
+ database: database.to_owned(),
+ root: root.to_owned(),
+ }
+ }
+
+ pub(crate) fn create_for_account(
+ &self,
+ actor: &str,
+ slug: &str,
+ object_format: Kind,
+ correlation_id: &str,
+ ) -> Result<RepositoryRecord, RepositoryServiceError> {
+ self.create(actor, actor, slug, object_format, correlation_id)
+ }
+
+ pub(crate) fn create_for_administrator(
+ &self,
+ owner: &str,
+ slug: &str,
+ object_format: Kind,
+ correlation_id: &str,
+ ) -> Result<RepositoryRecord, RepositoryServiceError> {
+ self.create("admin-cli", owner, slug, object_format, correlation_id)
+ }
+
+ fn create(
+ &self,
+ actor: &str,
+ owner: &str,
+ slug: &str,
+ object_format: Kind,
+ correlation_id: &str,
+ ) -> Result<RepositoryRecord, RepositoryServiceError> {
+ validate_username(owner)?;
+ validate_slug(slug)?;
+ let object_format_name = object_format_name(object_format)?;
+ let created_at = timestamp()?;
+ let mut store = Store::open(&self.database)?;
+ let target = format!("{owner}/{slug}");
+ let result = self.create_inner(
+ &mut store,
+ actor,
+ owner,
+ slug,
+ object_format,
+ object_format_name,
+ correlation_id,
+ created_at,
+ );
+ if result.is_err() {
+ store.record_audit_event(&NewAuditEvent {
+ action: "repository.create",
+ actor,
+ target: &target,
+ outcome: "failure",
+ correlation_id,
+ created_at,
+ })?;
+ }
+ result
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn create_inner(
+ &self,
+ store: &mut Store,
+ actor: &str,
+ owner: &str,
+ slug: &str,
+ object_format: Kind,
+ object_format_name: &str,
+ correlation_id: &str,
+ created_at: i64,
+ ) -> Result<RepositoryRecord, RepositoryServiceError> {
+ let id = random_id()?;
+ let pending_path = self.root.join(format!(".pending-{id}.git"));
+ let final_path = self.root.join(format!("{id}.git"));
+ if pending_path.exists() || final_path.exists() {
+ return Err(RepositoryServiceError::IdentifierCollision);
+ }
+
+ if let Err(error) = GitRepository::create_bare(&pending_path, object_format) {
+ remove_created_repository(&pending_path)?;
+ return Err(error.into());
+ }
+ if let Err(source) = fs::rename(&pending_path, &final_path) {
+ remove_created_repository(&pending_path)?;
+ return Err(RepositoryServiceError::Filesystem {
+ path: final_path,
+ source,
+ });
+ }
+ let canonical_path = match fs::canonicalize(&final_path) {
+ Ok(path) => path,
+ Err(source) => {
+ remove_created_repository(&final_path)?;
+ return Err(RepositoryServiceError::Canonicalize {
+ path: final_path,
+ source,
+ });
+ }
+ };
+ if canonical_path.parent() != Some(self.root.as_path()) {
+ remove_created_repository(&canonical_path)?;
+ return Err(RepositoryServiceError::PathEscape(canonical_path));
+ }
+
+ if let Err(error) = store.create_repository(&NewRepository {
+ id: &id,
+ owner,
+ slug,
+ object_format: object_format_name,
+ created_at,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ actor,
+ correlation_id,
+ }) {
+ remove_created_repository(&canonical_path)?;
+ return Err(error.into());
+ }
+ Ok(RepositoryRecord {
+ id,
+ owner: owner.to_owned(),
+ slug: slug.to_owned(),
+ visibility: "public".to_owned(),
+ state: "active".to_owned(),
+ object_format: object_format_name.to_owned(),
+ created_at,
+ archived_at: None,
+ })
+ }
+}
+
+fn random_id() -> Result<String, RepositoryServiceError> {
+ let mut bytes = [0_u8; 16];
+ rand::rngs::SysRng
+ .try_fill_bytes(&mut bytes)
+ .map_err(|_| RepositoryServiceError::Random)?;
+ Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+}
+
+fn timestamp() -> Result<i64, RepositoryServiceError> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|_| RepositoryServiceError::Clock)?
+ .as_secs()
+ .try_into()
+ .map_err(|_| RepositoryServiceError::Clock)
+}
+
+fn object_format_name(kind: Kind) -> Result<&'static str, RepositoryServiceError> {
+ match kind {
+ Kind::Sha1 => Ok("sha1"),
+ Kind::Sha256 => Ok("sha256"),
+ _ => Err(RepositoryServiceError::UnsupportedObjectFormat),
+ }
+}
+
+fn remove_created_repository(path: &Path) -> Result<(), RepositoryServiceError> {
+ match fs::remove_dir_all(path) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(source) => Err(RepositoryServiceError::Filesystem {
+ path: path.to_owned(),
+ source,
+ }),
+ }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum RepositoryServiceError {
+ #[error(transparent)]
+ Auth(#[from] AuthError),
+ #[error(transparent)]
+ RepositoryName(#[from] RepositoryNameError),
+ #[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("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 is not supported")]
+ UnsupportedObjectFormat,
+}
src/ssh.rs
Mode 100644 → 100644; object a76bc9a81d0b → 2ca35dbe3884
@@ -21,10 +21,14 @@
use crate::git::transport::{GitRepositories, GitSshService};
use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
use crate::policy::RepositoryOperation;
+use crate::repository::{RepositoryService, RepositoryServiceError};
const VERSION_COMMAND: &[u8] = b"tit --version";
const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
+const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
+const REPOSITORY_CREATE_USAGE: &str =
+ "repo create NAME [--object-format sha1|sha256] [--output human|json]";
pub(crate) struct RunningSshServer {
address: SocketAddr,
@@ -489,6 +493,25 @@
session.exit_status_request(channel, 0)?;
session.eof(channel)?;
session.close(channel)?;
+ } else if is_repository_command(command) {
+ self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+ session.channel_success(channel)?;
+ let machine_requested = requests_json(command);
+ let result = match parse_repository_command(command) {
+ Ok(command) => match self.active_identity() {
+ Some(identity) => {
+ run_repository_command(
+ self.repositories.clone(),
+ identity.username.clone(),
+ command,
+ )
+ .await
+ }
+ None => Err(RepositoryCommandError::Unavailable),
+ },
+ Err(()) => Err(RepositoryCommandError::Usage),
+ };
+ send_repository_command_result(channel, result, machine_requested, session)?;
} else {
let service = self.open_git_service(command).await;
if let Some(service) = service {
@@ -711,6 +734,198 @@
) -> Result<bool, Self::Error> {
self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
Ok(false)
+ }
+}
+
+#[derive(Clone, Copy)]
+enum RepositoryCommandOutput {
+ Human,
+ Json,
+}
+
+struct RepositoryCreateCommand {
+ slug: String,
+ object_format: gix::hash::Kind,
+ output: RepositoryCommandOutput,
+}
+
+fn is_repository_command(command: &[u8]) -> bool {
+ command == b"repo" || command.starts_with(b"repo ")
+}
+
+fn requests_json(command: &[u8]) -> bool {
+ std::str::from_utf8(command).is_ok_and(|command| {
+ let tokens = command.split_ascii_whitespace().collect::<Vec<_>>();
+ tokens
+ .windows(2)
+ .any(|tokens| tokens == ["--output", "json"])
+ })
+}
+
+fn parse_repository_command(command: &[u8]) -> Result<RepositoryCreateCommand, ()> {
+ if command.len() > MAX_REPOSITORY_COMMAND_BYTES || !command.is_ascii() {
+ return Err(());
+ }
+ let command = std::str::from_utf8(command).map_err(|_| ())?;
+ if command
+ .bytes()
+ .any(|byte| byte.is_ascii_control() && byte != b' ')
+ {
+ return Err(());
+ }
+ let mut tokens = command.split_ascii_whitespace();
+ if tokens.next() != Some("repo") || tokens.next() != Some("create") {
+ return Err(());
+ }
+ let slug = tokens.next().ok_or(())?.to_owned();
+ let mut object_format = None;
+ let mut output = None;
+ while let Some(option) = tokens.next() {
+ let value = tokens.next().ok_or(())?;
+ match option {
+ "--object-format" if object_format.is_none() => {
+ object_format = Some(match value {
+ "sha1" => gix::hash::Kind::Sha1,
+ "sha256" => gix::hash::Kind::Sha256,
+ _ => return Err(()),
+ });
+ }
+ "--output" if output.is_none() => {
+ output = Some(match value {
+ "human" => RepositoryCommandOutput::Human,
+ "json" => RepositoryCommandOutput::Json,
+ _ => return Err(()),
+ });
+ }
+ _ => return Err(()),
+ }
+ }
+ Ok(RepositoryCreateCommand {
+ slug,
+ object_format: object_format.unwrap_or(gix::hash::Kind::Sha1),
+ output: output.unwrap_or(RepositoryCommandOutput::Human),
+ })
+}
+
+enum RepositoryCommandError {
+ Usage,
+ Unavailable,
+ Create(RepositoryServiceError),
+}
+
+async fn run_repository_command(
+ repositories: Option<Arc<GitRepositories>>,
+ actor: String,
+ command: RepositoryCreateCommand,
+) -> Result<(crate::store::RepositoryRecord, RepositoryCommandOutput), RepositoryCommandError> {
+ let repositories = repositories.ok_or(RepositoryCommandError::Unavailable)?;
+ let database = repositories
+ .push_database()
+ .ok_or(RepositoryCommandError::Unavailable)?
+ .to_owned();
+ let root = repositories.repository_root().to_owned();
+ let permit = repositories
+ .blocking_permit()
+ .await
+ .map_err(|_| RepositoryCommandError::Unavailable)?;
+ let output = command.output;
+ let correlation_id = format!("{:032x}", rand::random::<u128>());
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ RepositoryService::new(&database, &root)
+ .create_for_account(
+ &actor,
+ &command.slug,
+ command.object_format,
+ &correlation_id,
+ )
+ .map(|repository| (repository, output))
+ .map_err(RepositoryCommandError::Create)
+ })
+ .await
+ .map_err(|_| RepositoryCommandError::Unavailable)?
+}
+
+fn send_repository_command_result(
+ channel: ChannelId,
+ result: Result<
+ (crate::store::RepositoryRecord, RepositoryCommandOutput),
+ RepositoryCommandError,
+ >,
+ machine_requested: bool,
+ session: &mut Session,
+) -> Result<(), russh::Error> {
+ match result {
+ Ok((repository, RepositoryCommandOutput::Human)) => {
+ session.data(
+ channel,
+ format!(
+ "Created repository {}/{}.\nObject format: {}\n",
+ repository.owner, repository.slug, repository.object_format
+ )
+ .into_bytes(),
+ )?;
+ finish_git_channel(channel, 0, session)
+ }
+ Ok((repository, RepositoryCommandOutput::Json)) => {
+ session.data(
+ channel,
+ format!(
+ "{{\"version\":1,\"status\":\"success\",\"repository\":{{\"owner\":\"{}\",\"name\":\"{}\",\"object_format\":\"{}\"}}}}\n",
+ repository.owner, repository.slug, repository.object_format
+ )
+ .into_bytes(),
+ )?;
+ finish_git_channel(channel, 0, session)
+ }
+ Err(error) => {
+ if machine_requested {
+ session.data(
+ channel,
+ format!(
+ "{{\"version\":1,\"status\":\"error\",\"error\":{{\"code\":\"{}\"}}}}\n",
+ repository_command_error_code(&error)
+ )
+ .into_bytes(),
+ )?;
+ } else {
+ session.extended_data(
+ channel,
+ 1,
+ format!("tit: {}\n", repository_command_error_message(&error)).into_bytes(),
+ )?;
+ }
+ finish_git_channel(channel, 1, session)
+ }
+ }
+}
+
+fn repository_command_error_code(error: &RepositoryCommandError) -> &'static str {
+ match error {
+ RepositoryCommandError::Usage => "invalid-command",
+ RepositoryCommandError::Unavailable => "service-unavailable",
+ RepositoryCommandError::Create(RepositoryServiceError::Store(
+ crate::store::StoreError::RepositoryExists(_, _),
+ )) => "repository-exists",
+ RepositoryCommandError::Create(RepositoryServiceError::Auth(_))
+ | RepositoryCommandError::Create(RepositoryServiceError::RepositoryName(_)) => {
+ "invalid-name"
+ }
+ RepositoryCommandError::Create(RepositoryServiceError::Store(
+ crate::store::StoreError::AccountNotFound(_),
+ )) => "account-unavailable",
+ RepositoryCommandError::Create(_) => "repository-create-failed",
+ }
+}
+
+fn repository_command_error_message(error: &RepositoryCommandError) -> String {
+ match repository_command_error_code(error) {
+ "invalid-command" => format!("usage: {REPOSITORY_CREATE_USAGE}"),
+ "repository-exists" => "A repository with this name already exists.".to_owned(),
+ "invalid-name" => "The repository name is not valid.".to_owned(),
+ "account-unavailable" => "The account is not active.".to_owned(),
+ "service-unavailable" => "The repository service is not available.".to_owned(),
+ _ => "The repository could not be created.".to_owned(),
}
}
templates/account-page.html
Mode 100644 → 100644; object 2e96854719fe → 4a99edbfc48f
@@ -8,6 +8,23 @@
<dt>Administrator</dt>
<dd>{% if administrator %}Yes{% else %}No{% endif %}</dd>
</dl>
+ <h2>Create repository</h2>
+ <form action="/account/repositories" method="post">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <p>
+ <label for="repository-name">Name</label>
+ <input id="repository-name" name="name" required maxlength="100" pattern="([a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9])">
+ </p>
+ <p>
+ <label for="object-format">Object format</label>
+ <select id="object-format" name="object-format">
+ <option value="sha1">SHA-1</option>
+ <option value="sha256">SHA-256</option>
+ </select>
+ </p>
+ <button type="submit">Create repository</button>
+ </form>
+ <h2>Sessions</h2>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">Log out all sessions</button>
tests/git_push_ssh.rs
Mode 100644 → 100644; object 11bbf8127dd7 → b59e5efb19e0
@@ -4,6 +4,9 @@
)]
#[path = "../src/auth.rs"]
mod auth;
+#[allow(dead_code, reason = "the SSH push test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
#[allow(
dead_code,
reason = "the SSH push test does not use each Git service API"
@@ -13,6 +16,9 @@
#[allow(dead_code, reason = "the SSH push test does not use repository policy")]
#[path = "../src/policy.rs"]
mod policy;
+#[allow(dead_code, reason = "the SSH push test does not create repositories")]
+#[path = "../src/repository.rs"]
+mod repository;
#[allow(
dead_code,
reason = "the SSH push test does not inspect the request audit"
tests/git_ssh.rs
Mode 100644 → 100644; object d2aa6ca8d204 → da0c2555fe22
@@ -4,6 +4,9 @@
)]
#[path = "../src/auth.rs"]
mod auth;
+#[allow(dead_code, reason = "the SSH Git test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
#[allow(
dead_code,
reason = "the SSH Git test does not use each Git service API"
@@ -13,6 +16,9 @@
#[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
#[path = "../src/policy.rs"]
mod policy;
+#[allow(dead_code, reason = "the SSH Git test does not create repositories")]
+#[path = "../src/repository.rs"]
+mod repository;
#[allow(
dead_code,
reason = "the SSH Git test does not inspect the request audit"
tests/public_routes.rs
Mode 100644 → 100644; object 6096f4832bf5 → e33ea94466da
@@ -37,6 +37,12 @@ #[allow(dead_code, reason = "the public-route test uses anonymous policy only")] #[path = "../src/policy.rs"] mod policy; +#[allow( + dead_code, + reason = "the public route test does not create repositories through forms" +)] +#[path = "../src/repository.rs"] +mod repository; #[allow(dead_code, reason = "the public-route test does not complete a login")] #[path = "../src/session.rs"] mod session;
tests/serve.rs
Mode 100644 → 100644; object 6968bb1a3aef → ba341695de23
@@ -9,7 +9,7 @@
use std::net::{SocketAddr, TcpStream};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
-use std::process::{Child, Command, ExitStatus, Stdio};
+use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
@@ -60,7 +60,6 @@
source.to_str().expect("a UTF-8 source path"),
],
);
-
let mut server = spawn_server(&config);
wait_for_listener(http, &mut server);
wait_for_listener(ssh, &mut server);
@@ -120,7 +119,36 @@
let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
assert!(account.starts_with("HTTP/1.1 200"));
assert!(account.contains("<dd>alice</dd>"));
+ assert!(account.contains("action=\"/account/repositories\""));
let csrf = cookie_value(&cookies, "tit-csrf");
+ let rejected_repository = http_form_with_headers(
+ http,
+ "/account/repositories",
+ &[
+ ("csrf", &"0".repeat(64)),
+ ("name", "web-created"),
+ ("object-format", "sha1"),
+ ],
+ &[("Cookie", &cookies)],
+ );
+ assert!(rejected_repository.starts_with("HTTP/1.1 403"));
+ let created_repository = http_form_with_headers(
+ http,
+ "/account/repositories",
+ &[
+ ("csrf", csrf),
+ ("name", "web-created"),
+ ("object-format", "sha256"),
+ ],
+ &[("Cookie", &cookies)],
+ );
+ assert!(created_repository.starts_with("HTTP/1.1 303"));
+ assert_eq!(
+ response_header(&created_repository, "location"),
+ "/alice/web-created"
+ );
+ let web_create_id = response_header(&created_repository, "x-request-id").to_owned();
+ assert!(http_get(http, "/alice/web-created").starts_with("HTTP/1.1 200"));
let rejected_logout = http_form_with_headers(
http,
"/logout",
@@ -318,6 +346,13 @@
assert!(audits.iter().any(|event| {
event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
}));
+ assert!(audits.iter().any(|event| {
+ event.0 == "repository.create"
+ && event.1 == "alice"
+ && event.2 == "alice/web-created"
+ && event.3 == "success"
+ && event.4 == web_create_id
+ }));
for event in &audits {
let visible = format!(
"{} {} {} {} {}",
@@ -450,7 +485,6 @@
"private",
],
);
-
let mut server = spawn_server(&config);
wait_for_listener(http, &mut server);
wait_for_listener(ssh, &mut server);
@@ -481,6 +515,172 @@
&instance.path().join("unknown-clone")
));
server.terminate();
+}
+
+#[test]
+fn creates_owned_repositories_with_stable_ssh_command_output() {
+ let instance = TempDir::new().expect("create an instance directory");
+ let http = free_address();
+ let ssh = free_address();
+ let config = instance.path().join("config.toml");
+ fs::write(
+ &config,
+ format!(
+ "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+ ssh.port()
+ ),
+ )
+ .expect("write the server configuration");
+ let private_key = instance.path().join("administrator");
+ 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 config_text = config.to_str().expect("a UTF-8 configuration path");
+ command(
+ instance.path(),
+ [
+ "--config",
+ config_text,
+ "setup",
+ "admin",
+ "alice",
+ public_key.trim(),
+ ],
+ );
+ let member_key = provision_account(instance.path(), "bob", "active", false);
+
+ let mut server = spawn_server(&config);
+ wait_for_listener(http, &mut server);
+ wait_for_listener(ssh, &mut server);
+
+ let human = ssh_exec(ssh, &member_key, &["repo", "create", "example"]);
+ assert!(human.status.success());
+ assert_eq!(
+ String::from_utf8(human.stdout).expect("read human command output"),
+ "Created repository bob/example.\nObject format: sha1\n"
+ );
+ assert!(human.stderr.is_empty());
+ assert!(ssh_clone_repository_succeeds(
+ ssh,
+ &member_key,
+ "bob",
+ "example",
+ &instance.path().join("created-clone")
+ ));
+
+ let machine = ssh_exec(
+ ssh,
+ &private_key,
+ &[
+ "repo",
+ "create",
+ "hash-agile",
+ "--object-format",
+ "sha256",
+ "--output",
+ "json",
+ ],
+ );
+ assert!(machine.status.success());
+ assert_eq!(
+ String::from_utf8(machine.stdout).expect("read machine command output"),
+ "{\"version\":1,\"status\":\"success\",\"repository\":{\"owner\":\"alice\",\"name\":\"hash-agile\",\"object_format\":\"sha256\"}}\n"
+ );
+ assert!(machine.stderr.is_empty());
+
+ let duplicate = ssh_exec(
+ ssh,
+ &member_key,
+ &["repo", "create", "example", "--output", "json"],
+ );
+ assert!(!duplicate.status.success());
+ assert_eq!(
+ String::from_utf8(duplicate.stdout).expect("read machine error output"),
+ "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"repository-exists\"}}\n"
+ );
+ assert!(duplicate.stderr.is_empty());
+
+ let invalid = ssh_exec(ssh, &member_key, &["repo", "create", "../bad"]);
+ assert!(!invalid.status.success());
+ assert!(invalid.stdout.is_empty());
+ assert_eq!(
+ String::from_utf8(invalid.stderr).expect("read human error output"),
+ "tit: The repository name is not valid.\n"
+ );
+ let malformed = ssh_exec(ssh, &member_key, &["repo", "create", "--output", "json"]);
+ assert!(!malformed.status.success());
+ assert_eq!(
+ String::from_utf8(malformed.stdout).expect("read invalid command output"),
+ "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"invalid-command\"}}\n"
+ );
+ assert!(malformed.stderr.is_empty());
+
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the account database");
+ database
+ .execute(
+ "UPDATE account SET state = 'suspended' WHERE username = 'bob'",
+ [],
+ )
+ .expect("suspend the command account");
+ drop(database);
+ let suspended = ssh_exec(
+ ssh,
+ &member_key,
+ &["repo", "create", "blocked", "--output", "json"],
+ );
+ assert!(!suspended.status.success());
+ assert_eq!(
+ String::from_utf8(suspended.stdout).expect("read suspended account output"),
+ "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
+ );
+ assert!(suspended.stderr.is_empty());
+ server.terminate();
+
+ let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the repository database");
+ let repositories: Vec<(String, String, String)> = database
+ .prepare(
+ "SELECT account.username, repository.slug, repository.object_format
+ FROM repository JOIN account ON account.id = repository.owner_account_id
+ ORDER BY repository.slug",
+ )
+ .expect("prepare the repository query")
+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
+ .expect("query created repositories")
+ .collect::<Result<_, _>>()
+ .expect("read created repositories");
+ assert_eq!(
+ repositories,
+ vec![
+ ("bob".to_owned(), "example".to_owned(), "sha1".to_owned()),
+ (
+ "alice".to_owned(),
+ "hash-agile".to_owned(),
+ "sha256".to_owned()
+ ),
+ ]
+ );
+ let audit: Vec<(String, String)> = database
+ .prepare(
+ "SELECT target, outcome FROM audit_event
+ WHERE action = 'repository.create' AND actor IN ('alice', 'bob')
+ ORDER BY id",
+ )
+ .expect("prepare the repository audit query")
+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
+ .expect("query repository audit events")
+ .collect::<Result<_, _>>()
+ .expect("read repository audit events");
+ assert_eq!(
+ audit,
+ vec![
+ ("bob/example".to_owned(), "success".to_owned()),
+ ("alice/hash-agile".to_owned(), "success".to_owned()),
+ ("bob/example".to_owned(), "failure".to_owned()),
+ ("bob/blocked".to_owned(), "failure".to_owned()),
+ ]
+ );
}
#[test]
@@ -986,6 +1186,31 @@
.expect("clone through the tit SSH server")
.status
.success()
+}
+
+fn ssh_exec(address: SocketAddr, private_key: &Path, command: &[&str]) -> Output {
+ Command::new("ssh")
+ .args([
+ "-F",
+ "/dev/null",
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "IdentitiesOnly=yes",
+ "-o",
+ "StrictHostKeyChecking=no",
+ "-o",
+ "UserKnownHostsFile=/dev/null",
+ "-o",
+ "LogLevel=ERROR",
+ "-i",
+ ])
+ .arg(private_key)
+ .args(["-p", &address.port().to_string()])
+ .arg(format!("ignored@{}", address.ip()))
+ .args(command)
+ .output()
+ .expect("run an SSH repository command")
}
fn provision_account(
tests/ssh.rs
Mode 100644 → 100644; object 104abe95a5f7 → 8ef49d75e78d
@@ -1,6 +1,9 @@
#[allow(dead_code, reason = "the SSH test uses only the shared key boundary")]
#[path = "../src/auth.rs"]
mod auth;
+#[allow(dead_code, reason = "the SSH identity test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
#[allow(
dead_code,
reason = "the SSH identity test does not use each Git service API"
@@ -13,6 +16,12 @@
)]
#[path = "../src/policy.rs"]
mod policy;
+#[allow(
+ dead_code,
+ reason = "the SSH identity test does not create repositories"
+)]
+#[path = "../src/repository.rs"]
+mod repository;
#[allow(
dead_code,
reason = "the SSH identity test does not start a Git service"
tests/web_shell.rs
Mode 100644 → 100644; object 35e4a9a350f2 → 3db8b42848b7
@@ -35,6 +35,9 @@ #[allow(dead_code, reason = "the Web shell test has no repository catalog")] #[path = "../src/policy.rs"] mod policy; +#[allow(dead_code, reason = "the Web shell test does not create repositories")] +#[path = "../src/repository.rs"] +mod repository; #[allow(dead_code, reason = "the Web shell test does not complete a login")] #[path = "../src/session.rs"] mod session;