michal/tit
Diff
b2db8e312f8f → 360d3f97fad0
.github/workflows/ci.yml
Mode 100644 → 100644; object 9d0d0500710b → 0e619c444441
@@ -34,10 +34,9 @@
persist-credentials: false
- run: cargo --version
- run: cargo test --locked --all-targets --all-features
- - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session --test repository_policy
- - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes
- - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
- - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
+ - run: cargo test --locked --release --lib
+ - run: cargo test --locked --release --lib git_reads_tests::measures_bounded_search_without_an_index -- --ignored --nocapture
+ - run: cargo test --locked --release --lib sqlite_workload_tests:: -- --ignored --nocapture
- run: cargo build --locked --release
- run: wc -c target/release/tit
- run: ./scripts/package-release target/release/tit dist
Cargo.toml
Mode 100644 → 100644; object d345a7602e0a → ae0036c6d6ec
@@ -3,12 +3,21 @@ version = "0.1.0" edition = "2024" rust-version = "1.96" +autotests = false license = "MIT" description = "A small self-hosted collaborative development environment" [[bin]] name = "tit" path = "src/main.rs" + +[[test]] +name = "cli" +path = "tests/cli.rs" + +[[test]] +name = "serve" +path = "tests/serve.rs" [dependencies] ammonia = "4.1.4"
README.md
Mode 100644 → 100644; object 01bda513ad51 → 0614f38501ef
@@ -266,12 +266,13 @@ The ignored workload tests are explicit performance checks: ```text -cargo test --locked --release --test git_reads \ - measures_bounded_search_without_an_index -- --ignored --nocapture -cargo test --locked --release --test metadata_search \ - measures_bounded_repository_name_search_without_an_index \ +cargo test --locked --release --lib \ + git_reads_tests::measures_bounded_search_without_an_index \ -- --ignored --nocapture -cargo test --locked --release --test sqlite_workload \ +cargo test --locked --release --lib \ + metadata_search_tests::measures_bounded_repository_name_search_without_an_index \ + -- --ignored --nocapture +cargo test --locked --release --lib sqlite_workload_tests:: \ -- --ignored --nocapture ```
src/account.rs
Mode 100644 → 100644; object 8eca2830f84d → 322c6ba645f4
@@ -1,15 +1,16 @@
use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
use rand::TryRng;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::auth::{AuthError, SshPublicKey, validate_username};
+use crate::codec::encode_lower_hex;
use crate::store::{
AccountKeyAuthorization, AccountRecovery, InvitedAccount, KeyInspection, NewAuditEvent,
NewSshKey, PublicProfile, Store, StoreError,
};
+use crate::system::unix_timestamp;
const INVITATION_PREFIX: &str = "tit-invite-v1:";
const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
@@ -432,10 +433,7 @@
.map_err(|_| AccountError::Random)?;
let mut value = String::with_capacity(prefix.len() + SECRET_BYTES * 2);
value.push_str(prefix);
- for byte in bytes {
- use std::fmt::Write as _;
- write!(value, "{byte:02x}").expect("writing to a string cannot fail");
- }
+ value.push_str(&encode_lower_hex(&bytes));
Ok(value)
}
@@ -444,12 +442,7 @@
}
fn now() -> Result<i64, AccountError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| AccountError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| AccountError::Clock)
+ unix_timestamp().ok_or(AccountError::Clock)
}
#[derive(Debug, Error)]
src/admin.rs
Mode 100644 → 100644; object 9773e8d9a778 → 95a52f7afb01
@@ -1,9 +1,7 @@
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};
@@ -15,6 +13,7 @@
AuditContext, NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store,
StoreError,
};
+use crate::system::{random_lower_hex, unix_timestamp};
const ADMIN_ACTOR: &str = "admin-cli";
@@ -425,20 +424,11 @@
}
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())
+ random_lower_hex::<16>().ok_or(AdminError::Random)
}
fn timestamp() -> Result<i64, AdminError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| AdminError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| AdminError::Clock)
+ unix_timestamp().ok_or(AdminError::Clock)
}
fn object_format_name(kind: Kind) -> Result<&'static str, AdminError> {
src/app.rs
Mode → 100644; object → 3f2282587361
@@ -1,0 +1,427 @@
+use std::process::ExitCode;
+use std::{io, io::Write};
+
+use crate::{
+ account, admin, backup, bootstrap,
+ cli::{
+ AccountCommand, AdminCommand, Cli, CollaboratorRole, Command, InspectCommand,
+ RepairCommand, RepositoryCommand, RepositoryVisibility, SetupCommand,
+ },
+ config, control, diagnostics, instance, repair, serve, store,
+};
+use clap::Parser;
+
+pub async fn run() -> ExitCode {
+ let cli = match Cli::try_parse() {
+ Ok(cli) => cli,
+ Err(error) => {
+ let code = error.exit_code();
+ let _ = error.print();
+ return ExitCode::from(u8::try_from(code).unwrap_or(2));
+ }
+ };
+
+ if let Some(Command::Restore { archive, target }) = &cli.command {
+ return run_restore(archive, target);
+ }
+
+ match config::load(&cli) {
+ Ok(config) => match cli.command {
+ None => ExitCode::SUCCESS,
+ Some(Command::Serve) => match serve::run(&config).await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Some(Command::InviteCode) => {
+ match control::request_invitation(&config.instance_dir).await {
+ Ok(code) => match writeln!(io::stdout().lock(), "Signup code: {code}") {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write the signup code: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+ }
+ Some(Command::Doctor { backups }) => match diagnostics::doctor(&config, &backups) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Some(Command::Inspect { command }) => run_inspect_command(&config, command),
+ Some(Command::Dump) => run_dump_command(&config),
+ Some(Command::Repair { command }) => {
+ let result = match command {
+ RepairCommand::Intents => repair::intents(&config.instance_dir),
+ RepairCommand::Quarantine => repair::quarantine(&config.instance_dir),
+ };
+ match result {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+ }
+ Some(Command::Maintenance { retention_days }) => {
+ match admin::maintain(&config.instance_dir, retention_days) {
+ Ok(result) => match writeln!(
+ io::stdout().lock(),
+ "Pruned {} terminal records.",
+ result.deleted
+ ) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write maintenance information: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+ }
+ Some(Command::Backup { output }) => run_backup(&config, &output).await,
+ Some(Command::Restore { .. }) => {
+ unreachable!("the restore command runs before configuration is loaded")
+ }
+ Some(Command::Setup {
+ command:
+ SetupCommand::Admin {
+ username,
+ ssh_public_key,
+ },
+ }) => match bootstrap::setup_administrator(
+ &config.instance_dir,
+ &username,
+ &ssh_public_key,
+ ) {
+ Ok(recovery_code) => {
+ let mut output = io::stdout().lock();
+ match writeln!(output, "Recovery code: {recovery_code}") {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write the recovery code: {error}");
+ ExitCode::FAILURE
+ }
+ }
+ }
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Some(Command::Admin {
+ command: AdminCommand::Repository { command },
+ }) => run_repository_command(&config.instance_dir, command),
+ Some(Command::Admin {
+ command: AdminCommand::Account { command },
+ }) => run_account_command(&config.instance_dir, command),
+ Some(Command::Admin {
+ command: AdminCommand::Audit { limit },
+ }) => run_audit_command(&config.instance_dir, limit),
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn run_inspect_command(config: &config::Config, command: InspectCommand) -> ExitCode {
+ let result = match command {
+ InspectCommand::Account { username } => {
+ serialize_inspection(diagnostics::inspect_account(config, &username))
+ }
+ InspectCommand::Repository { owner, slug } => {
+ serialize_inspection(diagnostics::inspect_repository(config, &owner, &slug))
+ }
+ InspectCommand::Intent { id } => {
+ serialize_inspection(diagnostics::inspect_intent(config, &id))
+ }
+ };
+ match result {
+ Ok(line) => match writeln!(io::stdout().lock(), "{line}") {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write inspect information: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn serialize_inspection(
+ result: Result<impl serde::Serialize, diagnostics::DiagnosticError>,
+) -> Result<String, Box<dyn std::error::Error>> {
+ Ok(serde_json::to_string(&result?)?)
+}
+
+fn run_dump_command(config: &config::Config) -> ExitCode {
+ let result = (|| -> Result<(), Box<dyn std::error::Error>> {
+ let mut output = io::stdout().lock();
+ let mut output_error = None;
+ diagnostics::dump(config, |row| {
+ let result = serde_json::to_writer(&mut output, &row)
+ .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
+ .and_then(|()| {
+ writeln!(output).map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
+ });
+ match result {
+ Ok(()) => true,
+ Err(error) => {
+ output_error = Some(error);
+ false
+ }
+ }
+ })?;
+ if let Some(error) = output_error {
+ return Err(error);
+ }
+ Ok(())
+ })();
+ match result {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+async fn run_backup(config: &config::Config, output: &std::path::Path) -> ExitCode {
+ let result = match backup::create_offline(&config.instance_dir, &config.config_path, output) {
+ Ok(()) => Ok(()),
+ Err(backup::BackupError::Instance(instance::InstanceError::Locked)) => {
+ control::request_backup(&config.instance_dir, output)
+ .await
+ .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
+ }
+ Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
+ };
+ match result {
+ Ok(()) => match writeln!(
+ io::stdout().lock(),
+ "Backup: {}\nWarning: This backup contains credentials.",
+ output.display()
+ ) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write backup information: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn run_restore(archive: &std::path::Path, target: &std::path::Path) -> ExitCode {
+ match backup::restore(archive, target) {
+ Ok(()) => match writeln!(
+ io::stdout().lock(),
+ "Restore: {}\nThe restored instance is not active.",
+ target.display()
+ ) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write restore information: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn run_audit_command(instance_dir: &std::path::Path, limit: usize) -> ExitCode {
+ let result = (|| -> Result<(), Box<dyn std::error::Error>> {
+ let _lock = instance::InstanceLock::acquire(instance_dir)?;
+ let database = instance::prepare_database(instance_dir)?;
+ let events = store::Store::open(&database)?.audit_events(limit)?;
+ let mut output = io::stdout().lock();
+ for event in events {
+ writeln!(output, "id={}", event.id)?;
+ writeln!(output, "action={}", event.action)?;
+ writeln!(output, "actor={}", event.actor)?;
+ writeln!(output, "target={}", event.target)?;
+ writeln!(output, "outcome={}", event.outcome)?;
+ writeln!(output, "correlation-id={}", event.correlation_id)?;
+ writeln!(output, "created-at={}", event.created_at)?;
+ writeln!(output)?;
+ }
+ Ok(())
+ })();
+ match result {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn run_account_command(instance_dir: &std::path::Path, command: AccountCommand) -> ExitCode {
+ let result = (|| -> Result<Option<String>, Box<dyn std::error::Error>> {
+ let _lock = instance::InstanceLock::acquire(instance_dir)?;
+ let database = instance::prepare_database(instance_dir)?;
+ let accounts = account::AccountService::new(database);
+ let correlation_id = format!("{:032x}", rand::random::<u128>());
+ match command {
+ AccountCommand::KeyAdd {
+ username,
+ label,
+ ssh_public_key,
+ } => {
+ let fingerprint = accounts.add_key(
+ &username,
+ &label,
+ &ssh_public_key,
+ "admin-cli",
+ &correlation_id,
+ )?;
+ Ok(Some(fingerprint))
+ }
+ AccountCommand::KeyRevoke {
+ username,
+ fingerprint,
+ } => {
+ accounts.revoke_key(&username, &fingerprint, "admin-cli", &correlation_id)?;
+ Ok(None)
+ }
+ AccountCommand::Suspend { username } => {
+ accounts.suspend(&username, true, "admin-cli", &correlation_id)?;
+ Ok(None)
+ }
+ AccountCommand::Resume { username } => {
+ accounts.suspend(&username, false, "admin-cli", &correlation_id)?;
+ Ok(None)
+ }
+ }
+ })();
+ match result {
+ Ok(Some(fingerprint)) => match writeln!(io::stdout().lock(), "fingerprint={fingerprint}") {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: cannot write account information: {error}");
+ ExitCode::FAILURE
+ }
+ },
+ Ok(None) => ExitCode::SUCCESS,
+ 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 } => {
+ admin::create_repository(instance_dir, &owner, &slug, gix::hash::Kind::Sha1)
+ }
+ 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::Visibility {
+ owner,
+ slug,
+ visibility,
+ } => admin::set_repository_visibility(
+ instance_dir,
+ &owner,
+ &slug,
+ match visibility {
+ RepositoryVisibility::Public => "public",
+ RepositoryVisibility::Private => "private",
+ },
+ ),
+ RepositoryCommand::CollaboratorSet {
+ owner,
+ slug,
+ username,
+ role,
+ } => admin::set_repository_collaborator(
+ instance_dir,
+ &owner,
+ &slug,
+ &username,
+ match role {
+ CollaboratorRole::Maintainer => "maintainer",
+ CollaboratorRole::Writer => "writer",
+ CollaboratorRole::Reader => "reader",
+ },
+ ),
+ RepositoryCommand::CollaboratorRemove {
+ owner,
+ slug,
+ username,
+ } => admin::remove_repository_collaborator(instance_dir, &owner, &slug, &username),
+ 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, "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/auth.rs
Mode 100644 → 100644; object 77d812fb2775 → b7adc33b23b9
@@ -5,6 +5,8 @@
use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PublicKey, SshSig};
use thiserror::Error;
+
+use crate::codec::{decode_lower_hex, encode_lower_hex};
use url::Url;
const CHALLENGE_HEADER: &str = "tit-auth-v1";
@@ -70,7 +72,7 @@
) -> String {
format!(
"{KEYLESS_CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
- encode_hex(nonce)
+ encode_lower_hex(nonce)
)
}
@@ -232,7 +234,7 @@
format!(
"{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
key.fingerprint(),
- encode_hex(nonce)
+ encode_lower_hex(nonce)
)
}
@@ -489,31 +491,10 @@
Sha256::digest(nonce).into()
}
-fn encode_hex(bytes: &[u8]) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut encoded = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- encoded.push(char::from(HEX[usize::from(byte >> 4)]));
- encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- encoded
-}
-
fn decode_hex(value: &str) -> Result<[u8; NONCE_BYTES], AuthError> {
- if value.len() != NONCE_BYTES * 2 {
- return Err(AuthError::MalformedChallenge);
- }
- let mut bytes = [0_u8; NONCE_BYTES];
- for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
- bytes[index] = (decode_hex_digit(pair[0])? << 4) | decode_hex_digit(pair[1])?;
- }
- Ok(bytes)
-}
-
-fn decode_hex_digit(value: u8) -> Result<u8, AuthError> {
- match value {
- b'0'..=b'9' => Ok(value - b'0'),
- b'a'..=b'f' => Ok(value - b'a' + 10),
- _ => Err(AuthError::MalformedChallenge),
- }
+ let decoded = decode_lower_hex(value.as_bytes()).ok_or(AuthError::MalformedChallenge)?;
+ let decoded: [u8; NONCE_BYTES] = decoded
+ .try_into()
+ .map_err(|_| AuthError::MalformedChallenge)?;
+ Ok(decoded)
}
src/backup.rs
Mode 100644 → 100644; object 1b4e679f5af0 → 660d5149e67c
@@ -11,6 +11,7 @@
use tar::{Archive, Builder, EntryType, Header};
use thiserror::Error;
+use crate::codec::{decode_lower_hex, encode_lower_hex};
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::instance::{InstanceError, InstanceLock, REPOSITORY_DIRECTORY};
use crate::maintenance::MaintenanceGate;
@@ -654,37 +655,11 @@
}
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let bytes = bytes.as_ref();
- let mut encoded = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- encoded.push(char::from(HEX[(byte >> 4) as usize]));
- encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
- }
- encoded
+ encode_lower_hex(bytes.as_ref())
}
fn decode_hex(encoded: &str) -> Result<Vec<u8>, BackupError> {
- if !encoded.len().is_multiple_of(2) {
- return Err(BackupError::InvalidManifest);
- }
- encoded
- .as_bytes()
- .chunks_exact(2)
- .map(|pair| {
- let high = decode_nibble(pair[0])?;
- let low = decode_nibble(pair[1])?;
- Ok((high << 4) | low)
- })
- .collect()
-}
-
-fn decode_nibble(byte: u8) -> Result<u8, BackupError> {
- match byte {
- b'0'..=b'9' => Ok(byte - b'0'),
- b'a'..=b'f' => Ok(byte - b'a' + 10),
- _ => Err(BackupError::InvalidManifest),
- }
+ decode_lower_hex(encoded.as_bytes()).ok_or(BackupError::InvalidManifest)
}
struct DigestWriter<'a>(&'a mut Sha256);
src/codec.rs
Mode → 100644; object → 1a9295a4a918
@@ -1,0 +1,43 @@
+#![allow(
+ dead_code,
+ reason = "integration test crates use only the codec policies required by their imported modules"
+)]
+
+pub(crate) fn encode_lower_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
+ for byte in bytes {
+ encoded.push(char::from(HEX[usize::from(byte >> 4)]));
+ encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ encoded
+}
+
+pub(crate) fn decode_lower_hex(encoded: &[u8]) -> Option<Vec<u8>> {
+ decode_hex(encoded, false)
+}
+
+pub(crate) fn decode_ascii_hex(encoded: &[u8]) -> Option<Vec<u8>> {
+ decode_hex(encoded, true)
+}
+
+fn decode_hex(encoded: &[u8], uppercase: bool) -> Option<Vec<u8>> {
+ if !encoded.len().is_multiple_of(2) {
+ return None;
+ }
+ encoded
+ .chunks_exact(2)
+ .map(|pair| {
+ Some((decode_nibble(pair[0], uppercase)? << 4) | decode_nibble(pair[1], uppercase)?)
+ })
+ .collect()
+}
+
+fn decode_nibble(byte: u8, uppercase: bool) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' if uppercase => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
src/control.rs
Mode 100644 → 100644; object 583aac731c2a → 1091d56f52d1
@@ -13,6 +13,8 @@
use crate::account::{AccountError, AccountService};
use crate::backup::OnlineBackupService;
+use crate::codec::{decode_lower_hex, encode_lower_hex};
+use crate::telemetry::Telemetry;
pub(crate) const CONTROL_SOCKET_FILE: &str = "control.sock";
const REQUEST: &[u8] = b"invite-code\n";
@@ -36,21 +38,23 @@
instance_dir: &Path,
accounts: AccountService,
) -> Result<Self, ControlError> {
- Self::start_inner(instance_dir, accounts, None)
+ Self::start_inner(instance_dir, accounts, None, Telemetry::default())
}
- pub(crate) fn start_with_backup(
+ pub(crate) fn start_with_backup_and_telemetry(
instance_dir: &Path,
accounts: AccountService,
backup: OnlineBackupService,
+ telemetry: Telemetry,
) -> Result<Self, ControlError> {
- Self::start_inner(instance_dir, accounts, Some(backup))
+ Self::start_inner(instance_dir, accounts, Some(backup), telemetry)
}
fn start_inner(
instance_dir: &Path,
accounts: AccountService,
backup: Option<OnlineBackupService>,
+ telemetry: Telemetry,
) -> Result<Self, ControlError> {
let path = instance_dir.join(CONTROL_SOCKET_FILE);
refuse_existing_path(&path)?;
@@ -100,14 +104,25 @@
let (stream, _) = accepted.map_err(ControlError::Accept)?;
let service = accounts.clone();
let backup = backup.clone();
+ let telemetry = telemetry.clone();
connections.spawn(async move {
- let _ = handle(stream, service, backup).await;
+ if let Err(error) = handle(stream, service, backup).await {
+ telemetry.failure("control.request", None, &error.to_string());
+ }
});
},
- _ = connections.join_next(), if !connections.is_empty() => {}
+ joined = connections.join_next(), if !connections.is_empty() => {
+ if let Some(Err(error)) = joined {
+ telemetry.failure("control.task", None, &error.to_string());
+ }
+ }
}
}
- while connections.join_next().await.is_some() {}
+ while let Some(joined) = connections.join_next().await {
+ if let Err(error) = joined {
+ telemetry.failure("control.task", None, &error.to_string());
+ }
+ }
Ok(())
});
Ok(Self { shutdown, task })
@@ -145,7 +160,7 @@
pub(crate) async fn request_backup(instance_dir: &Path, output: &Path) -> Result<(), ControlError> {
let mut request_bytes = BACKUP_REQUEST_PREFIX.to_vec();
- request_bytes.extend_from_slice(encode_hex(output.as_os_str().as_bytes()).as_bytes());
+ request_bytes.extend_from_slice(encode_lower_hex(output.as_os_str().as_bytes()).as_bytes());
request_bytes.push(b'\n');
let response = request(instance_dir, &request_bytes, BACKUP_TIMEOUT).await?;
if response == "ok" {
@@ -229,7 +244,7 @@
stream.write_all(b"error backup-unavailable\n").await?;
return Ok(());
};
- let output = match decode_hex(encoded) {
+ let output = match decode_lower_hex(encoded) {
Some(path) => PathBuf::from(OsString::from_vec(path)),
None => {
stream.write_all(b"error invalid-request\n").await?;
@@ -245,34 +260,6 @@
}
stream.shutdown().await?;
Ok(())
-}
-
-fn encode_hex(bytes: &[u8]) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut encoded = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- encoded.push(char::from(HEX[(byte >> 4) as usize]));
- encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
- }
- encoded
-}
-
-fn decode_hex(encoded: &[u8]) -> Option<Vec<u8>> {
- if !encoded.len().is_multiple_of(2) {
- return None;
- }
- encoded
- .chunks_exact(2)
- .map(|pair| Some((decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?))
- .collect()
-}
-
-fn decode_nibble(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- _ => None,
- }
}
fn refuse_existing_path(path: &Path) -> Result<(), ControlError> {
@@ -430,10 +417,11 @@
let backup_directory = TempDir::new().expect("create a backup directory");
let output = backup_directory.path().join("instance.tar");
let service = OnlineBackupService::new(directory.path().to_owned(), config, gate.clone());
- let server = RunningControlServer::start_with_backup(
+ let server = RunningControlServer::start_with_backup_and_telemetry(
directory.path(),
AccountService::new(database),
service,
+ Telemetry::default(),
)
.expect("start the control server");
src/feed_token.rs
Mode 100644 → 100644; object 149f16773513 → 6a5ca8e821c3
@@ -1,14 +1,15 @@
use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
use rand::TryRng;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::auth::{AuthError, validate_username};
+use crate::codec::encode_lower_hex;
use crate::store::{
ActivityCursor, ActivityPage, FeedTokenRecord, Store, StoreError, TokenFeedPage,
};
+use crate::system::unix_timestamp;
const TOKEN_BYTES: usize = 32;
@@ -86,7 +87,7 @@
rand::rngs::SysRng
.try_fill_bytes(&mut bytes)
.map_err(|_| FeedTokenError::Random)?;
- Ok(encode_hex(&bytes))
+ Ok(encode_lower_hex(&bytes))
}
fn hash(token: &str) -> [u8; 32] {
@@ -107,22 +108,8 @@
Ok(())
}
-fn encode_hex(bytes: &[u8]) -> String {
- let mut output = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- use std::fmt::Write as _;
- write!(output, "{byte:02x}").expect("writing to a string cannot fail");
- }
- output
-}
-
fn now() -> Result<i64, FeedTokenError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| FeedTokenError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| FeedTokenError::Clock)
+ unix_timestamp().ok_or(FeedTokenError::Clock)
}
#[derive(Debug, Error)]
src/git/read.rs
Mode 100644 → 100644; object 0aedb2803695 → 619287c199cb
@@ -278,7 +278,13 @@
.iter()
.map(|id| {
budget.check()?;
- Ok(self.read_commit(*id, &budget).ok())
+ match self.read_commit(*id, &budget) {
+ Ok(commit) => Ok(Some(commit)),
+ Err(ReadError::ObjectNotFound(_) | ReadError::WrongObjectKind { .. }) => {
+ Ok(None)
+ }
+ Err(error) => Err(error),
+ }
})
.collect::<Result<Vec<_>, ReadError>>()?;
budget.check()?;
@@ -291,7 +297,20 @@
cancellation: &ReadCancellation,
) -> Result<Vec<CommitInfo>, ReadError> {
let budget = self.budget(cancellation);
- self.history_with_budget(start, &budget)
+ self.history_with_budget(start, &budget, self.limits.max_history_commits, false)
+ }
+
+ pub(crate) fn history_prefix(
+ &self,
+ start: ObjectId,
+ maximum: usize,
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<CommitInfo>, ReadError> {
+ if maximum == 0 || maximum > self.limits.max_history_commits {
+ return Err(ReadError::InvalidLimits);
+ }
+ let budget = self.budget(cancellation);
+ self.history_with_budget(start, &budget, maximum, true)
}
pub(crate) fn tree(
@@ -434,8 +453,10 @@
cancellation: &ReadCancellation,
) -> Result<Comparison, ReadError> {
let budget = self.budget(cancellation);
- let base_history = self.history_with_budget(base_commit, &budget)?;
- let head_history = self.history_with_budget(head_commit, &budget)?;
+ let base_history =
+ self.history_with_budget(base_commit, &budget, self.limits.max_history_commits, false)?;
+ let head_history =
+ self.history_with_budget(head_commit, &budget, self.limits.max_history_commits, false)?;
if base_history.len().saturating_add(head_history.len()) > self.limits.max_history_commits {
return Err(ReadError::Limit("comparison commits"));
}
@@ -592,7 +613,8 @@
) -> Result<Vec<BlameHunk>, ReadError> {
validate_path(path, false, self.limits.max_path_bytes)?;
let budget = self.budget(cancellation);
- let history = self.history_with_budget(commit, &budget)?;
+ let history =
+ self.history_with_budget(commit, &budget, self.limits.max_history_commits, false)?;
let mut candidate_bytes = 0_usize;
for candidate in &history {
budget.check()?;
@@ -807,6 +829,8 @@
&self,
start: ObjectId,
budget: &ReadBudget<'_>,
+ maximum: usize,
+ truncate: bool,
) -> Result<Vec<CommitInfo>, ReadError> {
let mut pending = VecDeque::from([start]);
let mut seen = HashSet::new();
@@ -816,7 +840,10 @@
if !seen.insert(id) {
continue;
}
- if history.len() >= self.limits.max_history_commits {
+ if history.len() >= maximum {
+ if truncate {
+ break;
+ }
return Err(ReadError::Limit("history commits"));
}
let commit = self.read_commit(id, budget)?;
src/git/receive_pack.rs
Mode 100644 → 100644; object e28b0255ac19 → 0d565cc91cc3
@@ -4,20 +4,21 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use std::time::{Duration, Instant};
use gix::bstr::ByteSlice;
use gix::hash::{Kind, ObjectId};
use gix::objs::{CommitRef, Kind as ObjectKind, TagRef, TreeRefIter};
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
-use rand::TryRng;
use thiserror::Error;
use super::packetline::{Packet, PacketLineError, decode, encode_data, encode_flush};
+use super::repository::GitRepository;
use super::upload_pack::hash_name;
use crate::policy::{PolicyError, RefChange, RepositoryPolicy};
use crate::store::{GitIntentRecord, GitOperationIntent, NewAuditEvent, Store};
+use crate::system::{random_lower_hex, unix_timestamp};
const MAX_COMMANDS: usize = 256;
const MAX_OBJECTS: usize = 100_000;
@@ -103,19 +104,13 @@
}
pub(crate) fn advertisement(&self) -> Result<Vec<u8>, ReceivePackError> {
- let repository = open_bare(&self.repository_path)?;
- let mut references = repository
- .references()
- .map_err(|error| ReceivePackError::Repository(error.to_string()))?
- .all()
- .map_err(|error| ReceivePackError::Repository(error.to_string()))?
- .filter_map(|reference| reference.ok())
- .filter_map(|reference| {
- let id = reference.try_id()?.detach();
- Some((reference.name().as_bstr().to_vec(), id))
- })
+ let references = GitRepository::open(&self.repository_path)
+ .and_then(|repository| repository.references())
+ .map_err(|error| ReceivePackError::Repository(error.to_string()))?;
+ let references = references
+ .into_iter()
+ .filter(|reference| reference.name != b"HEAD")
.collect::<Vec<_>>();
- references.sort_by(|left, right| left.0.cmp(&right.0));
let capabilities = format!(
"report-status report-status-v2 delete-refs atomic ofs-delta object-format={} agent=tit/{}",
hash_name(self.object_format),
@@ -132,14 +127,14 @@
&mut output,
)?;
} else {
- for (index, (name, id)) in references.iter().enumerate() {
+ for (index, reference) in references.iter().enumerate() {
let suffix = if index == 0 {
- format!("\0{capabilities}")
+ Some(capabilities.as_bytes())
} else {
- String::new()
+ None
};
encode_data(
- format!("{id} {}{suffix}\n", String::from_utf8_lossy(name)).as_bytes(),
+ &advertised_ref(reference.target, &reference.name, suffix),
&mut output,
)?;
}
@@ -163,12 +158,7 @@
let proposed = serialize_refs(&commands, true);
let repository_text = path_text(&self.repository_path)?;
let quarantine_text = path_text(&self.quarantine)?;
- let created_at = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| ReceivePackError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| ReceivePackError::Clock)?;
+ let created_at = unix_timestamp().ok_or(ReceivePackError::Clock)?;
let mut store = Store::open(&self.database_path)?;
store.begin_git_intent(&GitOperationIntent {
id: &self.intent_id,
@@ -230,12 +220,7 @@
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".git"))
.unwrap_or("repository");
- let created_at = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| ReceivePackError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| ReceivePackError::Clock)?;
+ let created_at = unix_timestamp().ok_or(ReceivePackError::Clock)?;
store.record_audit_event(&NewAuditEvent {
action: "ref.update",
actor: &self.actor,
@@ -265,9 +250,8 @@
return Vec::new();
}
for command in commands {
- let name = String::from_utf8_lossy(command.name.as_bstr());
- let line = format!("ng {name} {}\n", error.client_reason());
- if encode_data(line.as_bytes(), &mut output).is_err() {
+ let line = status_line(b"ng ", command.name.as_bstr(), Some(error.client_reason()));
+ if encode_data(&line, &mut output).is_err() {
return Vec::new();
}
}
@@ -958,12 +942,11 @@
let mut output = Vec::new();
encode_data(b"unpack ok\n", &mut output)?;
for command in commands {
- let name = String::from_utf8_lossy(command.name.as_bstr());
let line = match error {
- Some(error) => format!("ng {name} {error}\n"),
- None => format!("ok {name}\n"),
+ Some(error) => status_line(b"ng ", command.name.as_bstr(), Some(error)),
+ None => status_line(b"ok ", command.name.as_bstr(), None),
};
- encode_data(line.as_bytes(), &mut output)?;
+ encode_data(&line, &mut output)?;
}
encode_flush(&mut output);
Ok(output)
@@ -973,13 +956,34 @@
let mut output = Vec::new();
for command in commands {
let id = if proposed { command.new } else { command.old };
- writeln!(
- output,
- "{id} {}",
- String::from_utf8_lossy(command.name.as_bstr())
- )
- .expect("a vector write cannot fail");
+ write!(output, "{id} ").expect("a vector write cannot fail");
+ output.extend_from_slice(command.name.as_bstr());
+ output.push(b'\n');
}
+ output
+}
+
+fn advertised_ref(target: ObjectId, name: &[u8], capabilities: Option<&[u8]>) -> Vec<u8> {
+ let mut output = Vec::new();
+ write!(output, "{target} ").expect("a vector write cannot fail");
+ output.extend_from_slice(name);
+ if let Some(capabilities) = capabilities {
+ output.push(0);
+ output.extend_from_slice(capabilities);
+ }
+ output.push(b'\n');
+ output
+}
+
+fn status_line(prefix: &[u8], name: &[u8], error: Option<&str>) -> Vec<u8> {
+ let mut output = Vec::new();
+ output.extend_from_slice(prefix);
+ output.extend_from_slice(name);
+ if let Some(error) = error {
+ output.push(b' ');
+ output.extend_from_slice(error.as_bytes());
+ }
+ output.push(b'\n');
output
}
@@ -993,11 +997,7 @@
}
fn random_id() -> Result<String, ReceivePackError> {
- let mut bytes = [0_u8; 16];
- rand::rngs::SysRng
- .try_fill_bytes(&mut bytes)
- .map_err(|_| ReceivePackError::Random)?;
- Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+ random_lower_hex::<16>().ok_or(ReceivePackError::Random)
}
fn path_text(path: &Path) -> Result<&str, ReceivePackError> {
src/git/repository.rs
Mode 100644 → 100644; object 6a1ef5c21725 → dc6134ed2a40
@@ -12,6 +12,8 @@
use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
use thiserror::Error;
+pub(crate) const MAX_REFERENCES: usize = 10_000;
+const MAX_REFERENCE_NAME_BYTES: usize = 4_096;
const MAX_OBJECTS_PER_PACK: usize = 100_000;
const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
const MAX_PACK_BYTES: usize = 256 * 1024 * 1024;
@@ -80,6 +82,16 @@
}
pub(crate) fn references(&self) -> Result<Vec<GitReference>, GitRepositoryError> {
+ self.references_with_limit(MAX_REFERENCES)
+ }
+
+ pub(crate) fn references_with_limit(
+ &self,
+ maximum: usize,
+ ) -> Result<Vec<GitReference>, GitRepositoryError> {
+ if maximum == 0 || maximum > MAX_REFERENCES {
+ return Err(GitRepositoryError::ReferenceLimit);
+ }
let mut references = Vec::new();
let platform = self
.repository
@@ -90,12 +102,18 @@
.map_err(|error| GitRepositoryError::References(error.to_string()))?;
for reference in iterator {
+ if references.len() >= maximum {
+ return Err(GitRepositoryError::ReferenceLimit);
+ }
let reference =
reference.map_err(|error| GitRepositoryError::References(error.to_string()))?;
let Some(target) = reference.try_id().map(gix::Id::detach) else {
continue;
};
let name = reference.name().as_bstr().to_vec();
+ if name.len() > MAX_REFERENCE_NAME_BYTES {
+ return Err(GitRepositoryError::ReferenceLimit);
+ }
let peeled = if name.starts_with(b"refs/tags/") {
let mut candidate = reference.clone();
let candidate = candidate
@@ -120,6 +138,9 @@
.head_ref()
.map_err(|error| GitRepositoryError::References(error.to_string()))?
{
+ if references.len() >= maximum {
+ return Err(GitRepositoryError::ReferenceLimit);
+ }
let target = head.id().detach();
references.insert(
0,
@@ -559,6 +580,8 @@
NotBare(PathBuf),
#[error("cannot read Git references: {0}")]
References(String),
+ #[error("Git reference count or name size exceeds the limit")]
+ ReferenceLimit,
#[error("Git branch name is not valid")]
InvalidBranch,
#[error("Git reference name is not valid: {0}")]
src/git/upload_pack.rs
Mode 100644 → 100644; object fa3304db424c → d891d052c330
@@ -93,28 +93,18 @@
} else {
for (index, reference) in references.iter().enumerate() {
let suffix = if index == 0 {
- format!("\0{capabilities}")
+ Some(capabilities.as_bytes())
} else {
- String::new()
+ None
};
encode_data(
- format!(
- "{} {}{suffix}\n",
- reference.target,
- String::from_utf8_lossy(&reference.name)
- )
- .as_bytes(),
+ &advertised_ref(reference.target, &reference.name, suffix),
output,
)?;
if let Some(peeled) = reference.peeled {
- encode_data(
- format!(
- "{peeled} {}^{{}}\n",
- String::from_utf8_lossy(&reference.name)
- )
- .as_bytes(),
- output,
- )?;
+ let mut name = reference.name.clone();
+ name.extend_from_slice(b"^{}");
+ encode_data(&advertised_ref(peeled, &name, None), output)?;
}
}
}
@@ -357,18 +347,31 @@
}
fn format_ref(reference: &GitReference, symrefs: bool, peel: bool) -> Vec<u8> {
- let mut output = format!(
- "{} {}",
- reference.target,
- String::from_utf8_lossy(&reference.name)
- )
- .into_bytes();
+ let mut output = Vec::new();
+ write!(output, "{} ", reference.target).expect("a vector write cannot fail");
+ output.extend_from_slice(&reference.name);
if symrefs && let Some(target) = &reference.symbolic_target {
output.extend_from_slice(b" symref-target:");
output.extend_from_slice(target);
}
if peel && let Some(target) = reference.peeled {
output.extend_from_slice(format!(" peeled:{target}").as_bytes());
+ }
+ output.push(b'\n');
+ output
+}
+
+pub(crate) fn advertised_ref(
+ target: ObjectId,
+ name: &[u8],
+ capabilities: Option<&[u8]>,
+) -> Vec<u8> {
+ let mut output = Vec::new();
+ write!(output, "{target} ").expect("a vector write cannot fail");
+ output.extend_from_slice(name);
+ if let Some(capabilities) = capabilities {
+ output.push(0);
+ output.extend_from_slice(capabilities);
}
output.push(b'\n');
output
src/http/issues.rs
Mode 100644 → 100644; object 9552def48e4e → 3d80123a607e
@@ -62,6 +62,9 @@
let authenticated = actor.0.is_some();
let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
let page_number = query.page.unwrap_or(1);
+ if page_number == 0 {
+ return issue_bad_request(&request_id.0);
+ }
let state_for_job = state_filter.clone();
let result = issue_job(state, move || {
service.list_page(
@@ -132,6 +135,9 @@
let number = path.number;
let comments_page = query.comments_page.unwrap_or(1);
let timeline_page = query.timeline_page.unwrap_or(1);
+ if comments_page == 0 || timeline_page == 0 {
+ return issue_bad_request(&request_id.0);
+ }
let result = issue_job(state, move || {
service.get_page(
&owner,
src/http/mod.rs
Mode 100644 → 100644; object 4ebbd9107636 → 4c631c013b60
@@ -1946,10 +1946,14 @@
) -> Response {
let username_for_audit = username.to_owned();
let correlation_id = request_id.to_owned();
- let _ = login_job(state, move |login| {
+ let telemetry = state.telemetry.clone();
+ if let Err(error) = login_job(state, move |login| {
login.record_login_failure(&username_for_audit, &correlation_id)
})
- .await;
+ .await
+ {
+ telemetry.failure("login.audit", Some(request_id), &error.to_string());
+ }
login_error(request_id, username, error)
}
src/http/public.rs
Mode 100644 → 100644; object e5ee3edf115b → 2ad0c4f8cb0a
@@ -21,6 +21,7 @@
use tokio_stream::wrappers::ReceiverStream;
use crate::auth::validate_username;
+use crate::codec::encode_lower_hex;
use crate::domain::repository::validate_slug;
use crate::feed::{FeedPage, PAGE_SIZE, RepositoryFeedKind};
use crate::git::packetline::MAX_REQUEST_BYTES;
@@ -476,7 +477,7 @@
let (entries, truncated) =
service.tree_prefix(head, &[], MAX_SUMMARY_FILES, &cancellation)?;
(
- service.history(head, &cancellation)?,
+ service.history_prefix(head, MAX_SUMMARY_COMMITS, &cancellation)?,
service.readme(head, &cancellation)?,
entries,
truncated,
@@ -1066,7 +1067,7 @@
is_public: bool,
) -> Response {
let digest = Sha256::digest(body.as_bytes());
- let etag = format!("\"{}\"", encode_hex(&digest));
+ let etag = format!("\"{}\"", encode_lower_hex(&digest));
let modified = u64::try_from(timestamp)
.ok()
.and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
@@ -1113,15 +1114,6 @@
let candidate = candidate.trim();
candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
})
-}
-
-fn encode_hex(bytes: &[u8]) -> String {
- let mut output = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- output.push(char::from(b"0123456789abcdef"[usize::from(byte >> 4)]));
- output.push(char::from(b"0123456789abcdef"[usize::from(byte & 0x0f)]));
- }
- output
}
fn route_error(error: RouteError, request_id: &str) -> Response {
@@ -1535,12 +1527,7 @@
.to_owned();
page.has_head = summary.head.is_some();
page.commit_id = summary.head.map(|id| id.to_string()).unwrap_or_default();
- page.history = summary
- .history
- .into_iter()
- .take(MAX_SUMMARY_COMMITS)
- .map(CommitView::from)
- .collect();
+ page.history = summary.history.into_iter().map(CommitView::from).collect();
page.tags = summary
.tags
.into_iter()
src/http/pull_requests.rs
Mode 100644 → 100644; object dc3b69b51f7b → fb379512815a
@@ -7,6 +7,7 @@
use axum::routing::{get, post};
use serde::Deserialize;
+use crate::codec::{decode_ascii_hex, encode_lower_hex};
use crate::markdown::{self, RenderedMarkdown};
use crate::pull_request::PullRequestError;
use crate::store::StoreError;
@@ -124,6 +125,9 @@
let actor_for_list = actor_name.clone();
let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
let page_number = query.page.unwrap_or(1);
+ if page_number == 0 {
+ return bad_request(&request_id.0);
+ }
let state_for_job = state_filter.clone();
let result = job(state.clone(), move || {
service.list_page(
@@ -139,10 +143,13 @@
Ok((record, page, can_create)) => {
let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
let branches = match state.public.as_ref() {
- Some(public) => public
+ Some(public) => match public
.branch_names(actor_name, record.owner.clone(), record.slug.clone())
.await
- .unwrap_or_default(),
+ {
+ Ok(branches) => branches,
+ Err(_) => return internal(&request_id.0),
+ },
None => Vec::new(),
};
let default_owner = record.owner.clone();
@@ -150,8 +157,11 @@
let default_branch = super::repository_job(state, move |repositories| {
repositories.default_branch(&default_owner, &default_repository)
})
- .await
- .unwrap_or_else(|_| "refs/heads/main".to_owned());
+ .await;
+ let default_branch = match default_branch {
+ Ok(default_branch) => default_branch,
+ Err(_) => return internal(&request_id.0),
+ };
render(
StatusCode::OK,
&PullRequestListTemplate {
@@ -204,6 +214,11 @@
let owner = path.owner.clone();
let repository = path.repository.clone();
let signed_in = actor.0.is_some();
+ let reviews_page = query.reviews_page.unwrap_or(1);
+ let timeline_page = query.timeline_page.unwrap_or(1);
+ if reviews_page == 0 || timeline_page == 0 {
+ return bad_request(&request_id.0);
+ }
let result = job(state, move || {
service.compare_page(
&owner,
@@ -211,8 +226,8 @@
path.number,
query.revision,
actor.0.as_deref(),
- query.reviews_page.unwrap_or(1),
- query.timeline_page.unwrap_or(1),
+ reviews_page,
+ timeline_page,
)
})
.await;
@@ -632,14 +647,7 @@
if value.is_empty() || !value.len().is_multiple_of(2) {
return None;
}
- value
- .as_bytes()
- .chunks_exact(2)
- .map(|pair| {
- let pair = std::str::from_utf8(pair).ok()?;
- u8::from_str_radix(pair, 16).ok()
- })
- .collect()
+ decode_ascii_hex(value.as_bytes())
}
fn bad_request(request_id: &str) -> Response {
@@ -847,7 +855,7 @@
.iter()
.map(|file| DiffView {
path: String::from_utf8_lossy(&file.path).into_owned(),
- path_hex: encode_hex(&file.path),
+ path_hex: encode_lower_hex(&file.path),
binary: file.binary,
has_base: file.old_id.is_some(),
has_head: file.new_id.is_some(),
@@ -856,13 +864,4 @@
.collect(),
}
}
-}
-
-fn encode_hex(bytes: &[u8]) -> String {
- let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
- for byte in bytes {
- use std::fmt::Write;
- write!(encoded, "{byte:02x}").expect("a string write cannot fail");
- }
- encoded
}
src/issue.rs
Mode 100644 → 100644; object 430e2843e7d9 → b72a6637278b
@@ -1,5 +1,4 @@
use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
@@ -9,6 +8,7 @@
IssueChange, IssueDetail, IssueRecord, NewIssue, RecordPage, RepositoryRecord, Store,
StoreError,
};
+use crate::system::unix_timestamp;
pub(crate) const MAX_TITLE_BYTES: usize = 200;
pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
@@ -230,11 +230,7 @@
}
fn timestamp() -> Result<i64, IssueError> {
- let seconds = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| IssueError::Clock)?
- .as_secs();
- i64::try_from(seconds).map_err(|_| IssueError::Clock)
+ unix_timestamp().ok_or(IssueError::Clock)
}
#[derive(Debug, Error)]
src/lib.rs
Mode → 100644; object → 8016bfbcf273
@@ -1,0 +1,99 @@ +pub mod app; + +pub(crate) mod account; +pub(crate) mod admin; +#[allow( + dead_code, + reason = "the current login flow does not use the legacy challenge API" +)] +pub(crate) mod auth; +pub(crate) mod backup; +pub(crate) mod bootstrap; +pub(crate) mod cli; +pub(crate) mod codec; +pub(crate) mod config; +pub(crate) mod control; +pub(crate) mod diagnostics; +pub(crate) mod domain; +pub(crate) mod feed; +pub(crate) mod feed_token; +#[allow( + dead_code, + reason = "the test suite exercises Git transports that the server composes indirectly" +)] +pub(crate) mod git; +#[allow( + dead_code, + reason = "the test suite starts isolated Web servers through additional constructors" +)] +pub(crate) mod http; +pub(crate) mod instance; +pub(crate) mod issue; +pub(crate) mod maintenance; +pub(crate) mod markdown; +pub(crate) mod policy; +pub(crate) mod pull_request; +pub(crate) mod rate_limit; +pub(crate) mod repair; +pub(crate) mod repository; +pub(crate) mod search; +pub(crate) mod serve; +pub(crate) mod session; +#[allow( + dead_code, + reason = "the test suite starts isolated SSH servers through additional constructors" +)] +pub(crate) mod ssh; +pub(crate) mod store; +pub(crate) mod system; +pub(crate) mod telemetry; +pub(crate) mod watch; + +#[cfg(test)] +#[path = "../tests/account_lifecycle.rs"] +mod account_lifecycle_tests; +#[cfg(test)] +#[path = "../tests/auth.rs"] +mod auth_tests; +#[cfg(test)] +#[path = "../tests/git_http.rs"] +mod git_http_tests; +#[cfg(test)] +#[path = "../tests/git_push_ssh.rs"] +mod git_push_ssh_tests; +#[cfg(test)] +#[path = "../tests/git_reads.rs"] +mod git_reads_tests; +#[cfg(test)] +#[path = "../tests/git_repository.rs"] +mod git_repository_tests; +#[cfg(test)] +#[path = "../tests/git_ssh.rs"] +mod git_ssh_tests; +#[cfg(test)] +#[path = "../tests/metadata_search.rs"] +mod metadata_search_tests; +#[cfg(test)] +#[path = "../tests/public_routes.rs"] +mod public_routes_tests; +#[cfg(test)] +#[path = "../tests/pull_requests.rs"] +mod pull_requests_tests; +#[cfg(test)] +#[path = "../tests/repository_policy.rs"] +mod repository_policy_tests; +#[cfg(test)] +#[path = "../tests/sqlite.rs"] +mod sqlite_tests; +#[cfg(test)] +#[path = "../tests/sqlite_workload.rs"] +mod sqlite_workload_tests; +#[cfg(test)] +#[path = "../tests/ssh.rs"] +mod ssh_tests; +#[cfg(test)] +#[path = "../tests/web_session.rs"] +mod web_session_tests; +#[cfg(test)] +#[path = "../tests/web_shell.rs"] +mod web_shell_tests;
src/main.rs
Mode 100644 → 100644; object 6f26ca429386 → 1844ce1bc694
@@ -1,463 +1,6 @@
-mod account;
-mod admin;
-#[allow(
- dead_code,
- reason = "the bootstrap command uses only part of the authentication API"
-)]
-mod auth;
-mod backup;
-mod bootstrap;
-mod cli;
-mod config;
-mod control;
-mod diagnostics;
-mod domain;
-mod feed;
-mod feed_token;
-#[allow(dead_code, reason = "the server uses only part of the shared Git API")]
-mod git;
-#[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
-mod http;
-mod instance;
-mod issue;
-mod maintenance;
-mod markdown;
-mod policy;
-mod pull_request;
-mod rate_limit;
-mod repair;
-mod repository;
-mod search;
-mod serve;
-mod session;
-#[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
-mod ssh;
-mod store;
-mod telemetry;
-mod watch;
-
use std::process::ExitCode;
-use std::{io, io::Write};
-
-use clap::Parser;
-
-use crate::cli::{
- AccountCommand, AdminCommand, Cli, CollaboratorRole, Command, InspectCommand, RepairCommand,
- RepositoryCommand, RepositoryVisibility, SetupCommand,
-};
#[tokio::main]
async fn main() -> ExitCode {
- let cli = match Cli::try_parse() {
- Ok(cli) => cli,
- Err(error) => {
- let code = error.exit_code();
- let _ = error.print();
- return ExitCode::from(u8::try_from(code).unwrap_or(2));
- }
- };
-
- if let Some(Command::Restore { archive, target }) = &cli.command {
- return run_restore(archive, target);
- }
-
- match config::load(&cli) {
- Ok(config) => match cli.command {
- None => ExitCode::SUCCESS,
- Some(Command::Serve) => match serve::run(&config).await {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- },
- Some(Command::InviteCode) => {
- match control::request_invitation(&config.instance_dir).await {
- Ok(code) => match writeln!(io::stdout().lock(), "Signup code: {code}") {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write the signup code: {error}");
- ExitCode::FAILURE
- }
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
- }
- Some(Command::Doctor { backups }) => match diagnostics::doctor(&config, &backups) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- },
- Some(Command::Inspect { command }) => run_inspect_command(&config, command),
- Some(Command::Dump) => run_dump_command(&config),
- Some(Command::Repair { command }) => {
- let result = match command {
- RepairCommand::Intents => repair::intents(&config.instance_dir),
- RepairCommand::Quarantine => repair::quarantine(&config.instance_dir),
- };
- match result {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
- }
- Some(Command::Maintenance { retention_days }) => {
- match admin::maintain(&config.instance_dir, retention_days) {
- Ok(result) => match writeln!(
- io::stdout().lock(),
- "Pruned {} terminal records.",
- result.deleted
- ) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write maintenance information: {error}");
- ExitCode::FAILURE
- }
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
- }
- Some(Command::Backup { output }) => run_backup(&config, &output).await,
- Some(Command::Restore { .. }) => {
- unreachable!("the restore command runs before configuration is loaded")
- }
- Some(Command::Setup {
- command:
- SetupCommand::Admin {
- username,
- ssh_public_key,
- },
- }) => match bootstrap::setup_administrator(
- &config.instance_dir,
- &username,
- &ssh_public_key,
- ) {
- Ok(recovery_code) => {
- let mut output = io::stdout().lock();
- match writeln!(output, "Recovery code: {recovery_code}") {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write the recovery code: {error}");
- ExitCode::FAILURE
- }
- }
- }
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- },
- Some(Command::Admin {
- command: AdminCommand::Repository { command },
- }) => run_repository_command(&config.instance_dir, command),
- Some(Command::Admin {
- command: AdminCommand::Account { command },
- }) => run_account_command(&config.instance_dir, command),
- Some(Command::Admin {
- command: AdminCommand::Audit { limit },
- }) => run_audit_command(&config.instance_dir, limit),
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-fn run_inspect_command(config: &config::Config, command: InspectCommand) -> ExitCode {
- let result = match command {
- InspectCommand::Account { username } => {
- serialize_inspection(diagnostics::inspect_account(config, &username))
- }
- InspectCommand::Repository { owner, slug } => {
- serialize_inspection(diagnostics::inspect_repository(config, &owner, &slug))
- }
- InspectCommand::Intent { id } => {
- serialize_inspection(diagnostics::inspect_intent(config, &id))
- }
- };
- match result {
- Ok(line) => match writeln!(io::stdout().lock(), "{line}") {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write inspect information: {error}");
- ExitCode::FAILURE
- }
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-fn serialize_inspection(
- result: Result<impl serde::Serialize, diagnostics::DiagnosticError>,
-) -> Result<String, Box<dyn std::error::Error>> {
- Ok(serde_json::to_string(&result?)?)
-}
-
-fn run_dump_command(config: &config::Config) -> ExitCode {
- let result = (|| -> Result<(), Box<dyn std::error::Error>> {
- let mut output = io::stdout().lock();
- let mut output_error = None;
- diagnostics::dump(config, |row| {
- let result = serde_json::to_writer(&mut output, &row)
- .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
- .and_then(|()| {
- writeln!(output).map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
- });
- match result {
- Ok(()) => true,
- Err(error) => {
- output_error = Some(error);
- false
- }
- }
- })?;
- if let Some(error) = output_error {
- return Err(error);
- }
- Ok(())
- })();
- match result {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-async fn run_backup(config: &config::Config, output: &std::path::Path) -> ExitCode {
- let result = match backup::create_offline(&config.instance_dir, &config.config_path, output) {
- Ok(()) => Ok(()),
- Err(backup::BackupError::Instance(instance::InstanceError::Locked)) => {
- control::request_backup(&config.instance_dir, output)
- .await
- .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
- }
- Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
- };
- match result {
- Ok(()) => match writeln!(
- io::stdout().lock(),
- "Backup: {}\nWarning: This backup contains credentials.",
- output.display()
- ) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write backup information: {error}");
- ExitCode::FAILURE
- }
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-fn run_restore(archive: &std::path::Path, target: &std::path::Path) -> ExitCode {
- match backup::restore(archive, target) {
- Ok(()) => match writeln!(
- io::stdout().lock(),
- "Restore: {}\nThe restored instance is not active.",
- target.display()
- ) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write restore information: {error}");
- ExitCode::FAILURE
- }
- },
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-fn run_audit_command(instance_dir: &std::path::Path, limit: usize) -> ExitCode {
- let result = (|| -> Result<(), Box<dyn std::error::Error>> {
- let _lock = instance::InstanceLock::acquire(instance_dir)?;
- let database = instance::prepare_database(instance_dir)?;
- let events = store::Store::open(&database)?.audit_events(limit)?;
- let mut output = io::stdout().lock();
- for event in events {
- writeln!(output, "id={}", event.id)?;
- writeln!(output, "action={}", event.action)?;
- writeln!(output, "actor={}", event.actor)?;
- writeln!(output, "target={}", event.target)?;
- writeln!(output, "outcome={}", event.outcome)?;
- writeln!(output, "correlation-id={}", event.correlation_id)?;
- writeln!(output, "created-at={}", event.created_at)?;
- writeln!(output)?;
- }
- Ok(())
- })();
- match result {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: {error}");
- ExitCode::FAILURE
- }
- }
-}
-
-fn run_account_command(instance_dir: &std::path::Path, command: AccountCommand) -> ExitCode {
- let result = (|| -> Result<Option<String>, Box<dyn std::error::Error>> {
- let _lock = instance::InstanceLock::acquire(instance_dir)?;
- let database = instance::prepare_database(instance_dir)?;
- let accounts = account::AccountService::new(database);
- let correlation_id = format!("{:032x}", rand::random::<u128>());
- match command {
- AccountCommand::KeyAdd {
- username,
- label,
- ssh_public_key,
- } => {
- let fingerprint = accounts.add_key(
- &username,
- &label,
- &ssh_public_key,
- "admin-cli",
- &correlation_id,
- )?;
- Ok(Some(fingerprint))
- }
- AccountCommand::KeyRevoke {
- username,
- fingerprint,
- } => {
- accounts.revoke_key(&username, &fingerprint, "admin-cli", &correlation_id)?;
- Ok(None)
- }
- AccountCommand::Suspend { username } => {
- accounts.suspend(&username, true, "admin-cli", &correlation_id)?;
- Ok(None)
- }
- AccountCommand::Resume { username } => {
- accounts.suspend(&username, false, "admin-cli", &correlation_id)?;
- Ok(None)
- }
- }
- })();
- match result {
- Ok(Some(fingerprint)) => match writeln!(io::stdout().lock(), "fingerprint={fingerprint}") {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- eprintln!("tit: cannot write account information: {error}");
- ExitCode::FAILURE
- }
- },
- Ok(None) => ExitCode::SUCCESS,
- 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 } => {
- admin::create_repository(instance_dir, &owner, &slug, gix::hash::Kind::Sha1)
- }
- 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::Visibility {
- owner,
- slug,
- visibility,
- } => admin::set_repository_visibility(
- instance_dir,
- &owner,
- &slug,
- match visibility {
- RepositoryVisibility::Public => "public",
- RepositoryVisibility::Private => "private",
- },
- ),
- RepositoryCommand::CollaboratorSet {
- owner,
- slug,
- username,
- role,
- } => admin::set_repository_collaborator(
- instance_dir,
- &owner,
- &slug,
- &username,
- match role {
- CollaboratorRole::Maintainer => "maintainer",
- CollaboratorRole::Writer => "writer",
- CollaboratorRole::Reader => "reader",
- },
- ),
- RepositoryCommand::CollaboratorRemove {
- owner,
- slug,
- username,
- } => admin::remove_repository_collaborator(instance_dir, &owner, &slug, &username),
- 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, "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
- }
- }
+ tit_cde::app::run().await
}
src/pull_request.rs
Mode 100644 → 100644; object 6bc529256155 → 301265d5cb5a
@@ -1,10 +1,8 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
-use std::time::{SystemTime, UNIX_EPOCH};
use gix::hash::ObjectId;
-use rand::TryRng;
use thiserror::Error;
use crate::auth::{AuthError, validate_username};
@@ -20,6 +18,7 @@
PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord, PullRequestRevisionRecord,
RecordPage, RepositoryRecord, Store, StoreError,
};
+use crate::system::{random_lower_hex, unix_timestamp};
pub(crate) const MAX_TITLE_BYTES: usize = 200;
pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
@@ -815,20 +814,11 @@
}
fn random_id() -> Result<String, PullRequestError> {
- let mut bytes = [0_u8; 16];
- rand::rngs::SysRng
- .try_fill_bytes(&mut bytes)
- .map_err(|_| PullRequestError::Random)?;
- Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+ random_lower_hex::<16>().ok_or(PullRequestError::Random)
}
fn timestamp() -> Result<i64, PullRequestError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| PullRequestError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| PullRequestError::Clock)
+ unix_timestamp().ok_or(PullRequestError::Clock)
}
#[cfg(test)]
src/repository.rs
Mode 100644 → 100644; object 6295db17cd3d → 9779eb1d02c1
@@ -1,9 +1,7 @@
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};
@@ -11,9 +9,10 @@
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::maintenance::MaintenanceGate;
use crate::store::{
- HomeRepositoryRecord, NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord,
- RepositorySettings, Store, StoreError,
+ HomeRepositoryRecord, NewAuditEvent, NewDefaultBranchIntent, NewRepository, RepositoryOrigin,
+ RepositoryRecord, RepositorySettings, Store, StoreError,
};
+use crate::system::{random_lower_hex, unix_timestamp};
const HOME_REPOSITORY_LIMIT: usize = 20;
pub(crate) const MAX_DESCRIPTION_BYTES: usize = 512;
@@ -104,23 +103,49 @@
validate_slug(repository)?;
validate_username(actor)?;
let _maintenance = self.maintenance.mutation();
+ let changed_at = timestamp()?;
+ let intent_id = random_id()?;
let mut store = Store::open(&self.database)?;
let settings = store.repository_settings(owner, repository, actor)?;
let git = GitRepository::open(&self.root.join(format!("{}.git", settings.repository.id)))?;
let previous = settings.default_branch;
- git.set_default_branch(default_branch)?;
- let result = store.update_repository_default_branch(
- owner,
- repository,
- actor,
- default_branch,
- timestamp()?,
- &random_id()?,
- );
- if result.is_err() {
- let _ = git.set_default_branch(&previous);
+ git.resolve_branch(default_branch)?;
+ if git.default_branch()?.as_deref() != Some(previous.as_str()) {
+ return Err(RepositoryServiceError::DefaultBranchState);
}
- result?;
+ store.begin_repository_default_branch(&NewDefaultBranchIntent {
+ id: &intent_id,
+ owner,
+ slug: repository,
+ actor,
+ previous_branch: &previous,
+ default_branch,
+ changed_at,
+ })?;
+ if let Err(error) = git.set_default_branch(default_branch) {
+ store.abandon_repository_default_branch(&intent_id)?;
+ return Err(error.into());
+ }
+ store.complete_repository_default_branch(&intent_id)?;
+ Ok(())
+ }
+
+ pub(crate) fn recover(&self) -> Result<(), RepositoryServiceError> {
+ let _maintenance = self.maintenance.mutation();
+ let mut store = Store::open(&self.database)?;
+ for intent in store.incomplete_repository_default_branches()? {
+ let git =
+ GitRepository::open(&self.root.join(format!("{}.git", intent.repository_id)))?;
+ match git.default_branch()?.as_deref() {
+ Some(current) if current == intent.previous_branch => {
+ store.abandon_repository_default_branch(&intent.id)?;
+ }
+ Some(current) if current == intent.proposed_branch => {
+ store.complete_repository_default_branch(&intent.id)?;
+ }
+ _ => return Err(RepositoryServiceError::DefaultBranchState),
+ }
+ }
Ok(())
}
@@ -356,20 +381,11 @@
}
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())
+ random_lower_hex::<16>().ok_or(RepositoryServiceError::Random)
}
fn timestamp() -> Result<i64, RepositoryServiceError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| RepositoryServiceError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| RepositoryServiceError::Clock)
+ unix_timestamp().ok_or(RepositoryServiceError::Clock)
}
fn validate_description(description: &str) -> Result<(), RepositoryServiceError> {
@@ -434,4 +450,6 @@
Clock,
#[error("repository object format is not supported")]
UnsupportedObjectFormat,
+ #[error("repository default-branch state is not consistent")]
+ DefaultBranchState,
}
src/serve.rs
Mode 100644 → 100644; object f70be4937ee7 → 241a9c2a9b79
@@ -19,6 +19,7 @@
use crate::maintenance::MaintenanceGate;
use crate::policy::PolicyError;
use crate::pull_request::{PullRequestError, PullRequestService};
+use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::session::{SessionError, WebLoginService};
use crate::ssh::{AuthorizedSshKeys, LoginApprover, RunningSshServer, SshServerError};
use crate::store::{Store, StoreError};
@@ -33,6 +34,7 @@
let database = prepare_database(&config.instance_dir)?;
let repository_root = prepare_repository_root(&config.instance_dir)?;
let maintenance = MaintenanceGate::default();
+ RepositoryService::new_with_gate(&database, &repository_root, maintenance.clone()).recover()?;
PullRequestService::new_with_gate(&database, &repository_root, maintenance.clone())
.recover()?;
let store = Store::open(&database)?;
@@ -50,8 +52,12 @@
config.config_path.clone(),
maintenance.clone(),
);
- let control =
- RunningControlServer::start_with_backup(&config.instance_dir, accounts.clone(), backup)?;
+ let control = RunningControlServer::start_with_backup_and_telemetry(
+ &config.instance_dir,
+ accounts.clone(),
+ backup,
+ telemetry.clone(),
+ )?;
let authorized_keys = AuthorizedSshKeys::for_accounts(keys);
let readiness = ListenerReadiness::default();
@@ -289,6 +295,8 @@
Authentication(#[from] AuthError),
#[error(transparent)]
Repository(#[from] RepositoryPathError),
+ #[error(transparent)]
+ RepositoryService(#[from] RepositoryServiceError),
#[error(transparent)]
Configuration(#[from] ConfigError),
#[error(transparent)]
src/session.rs
Mode 100644 → 100644; object 96fec71f8170 → 471c521c801f
@@ -1,9 +1,10 @@
use std::path::PathBuf;
-use std::time::{SystemTime, UNIX_EPOCH};
use rand::TryRng;
use sha2::{Digest, Sha256};
use thiserror::Error;
+
+use crate::codec::encode_lower_hex;
use url::Url;
use crate::auth::{
@@ -13,6 +14,7 @@
ApproveLogin, NewApprovedWebSession, NewAuditEvent, NewLoginApproval, NewLoginNonce,
NewWebSession, Store, StoreError, WebSessionRecord,
};
+use crate::system::unix_timestamp;
const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
const SESSION_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
@@ -40,7 +42,7 @@
.checked_add(CHALLENGE_LIFETIME_SECONDS)
.ok_or(SessionError::Clock)?;
let nonce = random_bytes()?;
- let login_csrf = encode_hex(&random_bytes()?);
+ let login_csrf = encode_lower_hex(&random_bytes()?);
Store::open(&self.database)?.create_login_nonce(&NewLoginNonce {
nonce_hash: &hash(&nonce),
csrf_hash: &hash(login_csrf.as_bytes()),
@@ -67,8 +69,8 @@
i64::try_from(CHALLENGE_LIFETIME_SECONDS).map_err(|_| SessionError::Clock)?,
)
.ok_or(SessionError::Clock)?;
- let secret = encode_hex(&random_bytes()?);
- let login_csrf = encode_hex(&random_bytes()?);
+ let secret = encode_lower_hex(&random_bytes()?);
+ let login_csrf = encode_lower_hex(&random_bytes()?);
Store::open(&self.database)?.create_login_approval(&NewLoginApproval {
secret_hash: &hash(secret.as_bytes()),
csrf_hash: &hash(login_csrf.as_bytes()),
@@ -97,7 +99,7 @@
i64::try_from(CHALLENGE_LIFETIME_SECONDS).map_err(|_| SessionError::Clock)?,
)
.ok_or(SessionError::Clock)?;
- let secret = encode_hex(&random_bytes()?);
+ let secret = encode_lower_hex(&random_bytes()?);
Store::open(&self.database)?.create_login_approval(&NewLoginApproval {
secret_hash: &hash(secret.as_bytes()),
csrf_hash: &hash(csrf.as_bytes()),
@@ -140,8 +142,8 @@
validate_token(secret)?;
validate_token(login_csrf)?;
let created_at = now()?;
- let session = encode_hex(&random_bytes()?);
- let csrf = encode_hex(&random_bytes()?);
+ let session = encode_lower_hex(&random_bytes()?);
+ let csrf = encode_lower_hex(&random_bytes()?);
let expires_at = created_at
.checked_add(SESSION_LIFETIME_SECONDS)
.ok_or(SessionError::Clock)?;
@@ -178,8 +180,8 @@
username,
u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
)?;
- let session = encode_hex(&random_bytes()?);
- let csrf = encode_hex(&random_bytes()?);
+ let session = encode_lower_hex(&random_bytes()?);
+ let csrf = encode_lower_hex(&random_bytes()?);
let expires_at = created_at
.checked_add(SESSION_LIFETIME_SECONDS)
.ok_or(SessionError::Clock)?;
@@ -298,15 +300,6 @@
Sha256::digest(value).into()
}
-fn encode_hex(value: &[u8]) -> String {
- let mut result = String::with_capacity(value.len() * 2);
- for byte in value {
- use std::fmt::Write as _;
- write!(result, "{byte:02x}").expect("writing to a string cannot fail");
- }
- result
-}
-
fn validate_token(token: &str) -> Result<(), SessionError> {
if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(SessionError::InvalidToken);
@@ -315,12 +308,7 @@
}
fn now() -> Result<i64, SessionError> {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| SessionError::Clock)?
- .as_secs()
- .try_into()
- .map_err(|_| SessionError::Clock)
+ unix_timestamp().ok_or(SessionError::Clock)
}
#[derive(Debug, Error)]
src/ssh.rs
Mode 100644 → 100644; object 79f7d0100527 → 8af371e0244a
@@ -18,7 +18,7 @@
use crate::auth::SshPublicKey;
use crate::git::packetline::{MAX_REQUEST_BYTES, Packet, decode, encode_data, first_flush_end};
use crate::git::receive_pack::{ReceivePack, ReceivePackError};
-use crate::git::transport::{GitRepositories, GitSshService};
+use crate::git::transport::{GitRepositories, GitSshService, RepositoryPathError};
use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
use crate::policy::RepositoryOperation;
@@ -890,7 +890,17 @@
)?,
}
} else {
- let service = self.open_git_service(command).await;
+ let service = match self.open_git_service(command).await {
+ Ok(service) => service,
+ Err(error) => {
+ self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
+ self.telemetry
+ .failure("ssh.git", Some(&operation_id), &error.to_string());
+ session.channel_success(channel)?;
+ fail_git_channel(channel, session)?;
+ return Ok(());
+ }
+ };
if let Some(service) = service {
self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
session.channel_success(channel)?;
@@ -959,7 +969,7 @@
}
}
} else {
- self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+ self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
session.channel_success(channel)?;
if requests_json(command) {
session.data(
@@ -2470,23 +2480,44 @@
}
}
- async fn open_git_service(&mut self, command: &[u8]) -> Option<InitialGitService> {
+ async fn open_git_service(
+ &mut self,
+ command: &[u8],
+ ) -> Result<Option<InitialGitService>, GitServiceOpenError> {
let active_channels = self
.exec_channels
.values()
.filter(|channel| matches!(channel, ExecChannel::Upload(_) | ExecChannel::Receive(_)))
.count();
- let global_permit = reserve_git_channel(active_channels, &self.git_channels)?;
- let repositories = self.repositories.as_ref()?;
- let identity = self.active_identity()?;
- let service = repositories
- .resolve_ssh_service_for(Some(&identity.username), command)
- .ok()?;
+ let Some(global_permit) = reserve_git_channel(active_channels, &self.git_channels) else {
+ return Ok(None);
+ };
+ let Some(repositories) = self.repositories.as_ref() else {
+ return Ok(None);
+ };
+ let Some(identity) = self.active_identity() else {
+ return Ok(None);
+ };
+ let service = match repositories.resolve_ssh_service_for(Some(&identity.username), command)
+ {
+ Ok(service) => service,
+ Err(
+ RepositoryPathError::InvalidName
+ | RepositoryPathError::InvalidCommand
+ | RepositoryPathError::Unauthorized,
+ ) => return Ok(None),
+ Err(RepositoryPathError::Repository { source, .. })
+ if source.kind() == std::io::ErrorKind::NotFound =>
+ {
+ return Ok(None);
+ }
+ Err(error) => return Err(error.into()),
+ };
match service {
GitSshService::Upload { path, .. } => {
- let permit = repositories.blocking_permit().await.ok()?;
+ let permit = repositories.blocking_permit().await?;
let protocol = self.protocol;
- tokio::task::spawn_blocking(move || {
+ let service = tokio::task::spawn_blocking(move || {
let _permit = permit;
let service = UploadPack::open(&path)?;
let advertisement = service.advertisement(protocol, false)?;
@@ -2496,9 +2527,8 @@
global_permit,
})
})
- .await
- .ok()?
- .ok()
+ .await??;
+ Ok(Some(service))
}
GitSshService::Receive {
path,
@@ -2506,14 +2536,19 @@
repository,
} => {
if !repositories.uses_policy() && !self.authenticated_writer {
- return None;
+ return Ok(None);
}
- let database = repositories.push_database()?.to_owned();
+ let database = repositories
+ .push_database()
+ .ok_or(GitServiceOpenError::PushDatabase)?
+ .to_owned();
let actor = identity.username.clone();
- let public_key = self.authenticated_key.clone()?;
+ let Some(public_key) = self.authenticated_key.clone() else {
+ return Ok(None);
+ };
let uses_policy = repositories.uses_policy();
- let permit = repositories.blocking_permit().await.ok()?;
- tokio::task::spawn_blocking(move || {
+ let permit = repositories.blocking_permit().await?;
+ let service = tokio::task::spawn_blocking(move || {
let _permit = permit;
let service = if uses_policy {
ReceivePack::open_authorized(
@@ -2539,9 +2574,8 @@
},
)))
})
- .await
- .ok()?
- .ok()
+ .await??;
+ Ok(Some(service))
}
}
}
@@ -2552,6 +2586,22 @@
let current = self.authorized_keys.identity(public_key)?;
(current == *authenticated).then_some(current)
}
+}
+
+#[derive(Debug, Error)]
+enum GitServiceOpenError {
+ #[error(transparent)]
+ Repository(#[from] RepositoryPathError),
+ #[error(transparent)]
+ Upload(#[from] UploadPackError),
+ #[error(transparent)]
+ Receive(#[from] ReceivePackError),
+ #[error("the Git worker stopped")]
+ Join(#[from] tokio::task::JoinError),
+ #[error("the Git work queue is closed")]
+ WorkQueue(#[from] tokio::sync::AcquireError),
+ #[error("the push database is unavailable")]
+ PushDatabase,
}
enum InitialGitService {
src/store/event.rs
Mode 100644 → 100644; object ca8f480c0774 → 66e8fed3be22
@@ -1,5 +1,6 @@
use serde_json::json;
+use crate::codec::encode_lower_hex;
pub(super) const PAYLOAD_VERSION: i64 = 1;
#[derive(Clone, Copy)]
@@ -147,7 +148,7 @@
"revision": revision,
"body": body,
"commit_object_id": commit_object_id,
- "path_hex": path.map(encode_hex),
+ "path_hex": path.map(encode_lower_hex),
"side": side,
"line": line,
})
@@ -248,7 +249,7 @@
kind,
payload: json!({
"version": PAYLOAD_VERSION,
- "name_hex": encode_hex(name),
+ "name_hex": encode_lower_hex(name),
"old_target": old_target,
"new_target": new_target,
})
@@ -321,13 +322,4 @@
})
.to_string(),
}
-}
-
-fn encode_hex(bytes: &[u8]) -> String {
- let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
- for byte in bytes {
- use std::fmt::Write;
- write!(encoded, "{byte:02x}").expect("a string write cannot fail");
- }
- encoded
}
src/store/migrations/024_default_branch_intents.sql
Mode → 100644; object → db9e83d5900b
@@ -1,0 +1,21 @@ +CREATE TABLE repository_default_branch_intent ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 32 + AND id NOT GLOB '*[^0-9a-f]*' + ), + repository_id TEXT NOT NULL UNIQUE + REFERENCES repository(id) ON DELETE CASCADE, + actor TEXT NOT NULL, + previous_ref_name TEXT NOT NULL + CHECK ( + length(previous_ref_name) BETWEEN 12 AND 1024 + AND substr(previous_ref_name, 1, 11) = 'refs/heads/' + ), + proposed_ref_name TEXT NOT NULL + CHECK ( + length(proposed_ref_name) BETWEEN 12 AND 1024 + AND substr(proposed_ref_name, 1, 11) = 'refs/heads/' + ), + created_at INTEGER NOT NULL CHECK (created_at >= 0) +) STRICT;
src/store/mod.rs
Mode 100644 → 100644; object e597a1c32db3 → ef2edd91c364
@@ -10,12 +10,13 @@
use serde::Serialize;
use thiserror::Error;
+use crate::codec::encode_lower_hex;
mod event;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
const MAX_ACTIVE_FEED_TOKENS: i64 = 1;
-const SCHEMA_VERSION: i64 = 23;
+const SCHEMA_VERSION: i64 = 24;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -23,9 +24,9 @@
pub(crate) const DATABASE_FILE: &str = "tit.sqlite3";
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without migration operations"
)]
-const MIGRATIONS: [&str; 23] = [
+const MIGRATIONS: [&str; 24] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
@@ -49,6 +50,7 @@
include_str!("migrations/021_pull_request_lifecycle.sql"),
include_str!("migrations/022_account_key_management.sql"),
include_str!("migrations/023_default_branch.sql"),
+ include_str!("migrations/024_default_branch_intents.sql"),
];
#[allow(
@@ -59,9 +61,14 @@
pub(crate) enum StoreError {
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
+ #[error("cannot read migration path {path}: {source}")]
+ MigrationFilesystem {
+ path: PathBuf,
+ source: std::io::Error,
+ },
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without schema-version rejection"
)]
#[error("database schema version {0} is newer than this executable")]
NewerSchema(i64),
@@ -121,6 +128,8 @@
InvalidRepositoryVisibility,
#[error("repository default branch is not valid")]
InvalidDefaultBranch,
+ #[error("repository default-branch intent {0} is not in the required state")]
+ DefaultBranchIntentState(String),
#[error("collaborator role is not valid")]
InvalidCollaboratorRole,
#[error("repository owner cannot be a collaborator")]
@@ -214,7 +223,7 @@
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without opening a database"
)]
pub(crate) fn open(path: &Path) -> Result<Self, StoreError> {
let mut store = Self::open_unmigrated(path)?;
@@ -228,7 +237,7 @@
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without migration setup"
)]
pub(crate) fn open_unmigrated(path: &Path) -> Result<Self, StoreError> {
let connection = Connection::open(path)?;
@@ -248,7 +257,7 @@
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without direct migration"
)]
pub(crate) fn migrate(&mut self) -> Result<(), StoreError> {
self.migrate_with_hook(|_| {})
@@ -256,7 +265,7 @@
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without migration hooks"
)]
pub(crate) fn migrate_with_hook(
&mut self,
@@ -270,6 +279,23 @@
return Ok(());
}
+ let instance = if current < 23 {
+ let database = self.connection.path().ok_or_else(|| {
+ StoreError::Integrity("a database migration requires a filesystem path".to_owned())
+ })?;
+ Some(
+ Path::new(database)
+ .parent()
+ .ok_or_else(|| {
+ StoreError::Integrity(
+ "the database migration path has no parent".to_owned(),
+ )
+ })?
+ .to_owned(),
+ )
+ } else {
+ None
+ };
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Exclusive)?;
@@ -278,56 +304,11 @@
transaction.pragma_update(None, "user_version", version)?;
after_migration(version);
}
+ if let Some(instance) = instance {
+ backfill_default_branches(&transaction, &instance)?;
+ }
transaction.commit()?;
- if current < 23 {
- self.backfill_default_branches();
- }
Ok(())
- }
-
- fn backfill_default_branches(&self) {
- let Some(database) = self.connection.path() else {
- return;
- };
- let Some(instance) = Path::new(database).parent() else {
- return;
- };
- let Ok(mut statement) = self.connection.prepare("SELECT id FROM repository") else {
- return;
- };
- let Ok(ids) = statement.query_map([], |row| row.get::<_, String>(0)) else {
- return;
- };
- for id in ids.flatten() {
- let head = instance
- .join("repositories")
- .join(format!("{id}.git"))
- .join("HEAD");
- let Ok(contents) = fs::read(head) else {
- continue;
- };
- let Some(name) = contents
- .strip_suffix(b"\n")
- .unwrap_or(&contents)
- .strip_prefix(b"ref: ")
- else {
- continue;
- };
- let candidate = gix::bstr::BString::from(name);
- if !name.starts_with(b"refs/heads/")
- || gix::refs::FullName::try_from(candidate).is_err()
- {
- continue;
- }
- let Ok(name) = std::str::from_utf8(name) else {
- continue;
- };
- let _ = self.connection.execute(
- "UPDATE repository_default_branch SET ref_name = ?2
- WHERE repository_id = ?1",
- rusqlite::params![id, name],
- );
- }
}
pub(crate) fn schema_version(&self) -> Result<i64, StoreError> {
@@ -355,7 +336,10 @@
Ok(())
}
- #[allow(dead_code, reason = "M1A proves backup before the M2 server calls it")]
+ #[allow(
+ dead_code,
+ reason = "some integration test crates import storage without backup operations"
+ )]
pub(crate) fn backup(&self, path: &Path) -> Result<(), StoreError> {
let mut destination = Connection::open(path)?;
let backup = Backup::new(&self.connection, &mut destination)?;
@@ -404,7 +388,7 @@
#[allow(
dead_code,
- reason = "M1A tests storage behavior through this narrow test boundary"
+ reason = "integration storage tests use this database test boundary"
)]
pub(crate) fn connection(&self) -> &Connection {
&self.connection
@@ -412,7 +396,7 @@
#[allow(
dead_code,
- reason = "M1A tests storage behavior through this narrow test boundary"
+ reason = "integration storage tests use this mutable database test boundary"
)]
pub(crate) fn connection_mut(&mut self) -> &mut Connection {
&mut self.connection
@@ -1890,26 +1874,86 @@
.ok_or_else(|| StoreError::RepositoryNotFound(owner.to_owned(), slug.to_owned()))
}
- pub(crate) fn update_repository_default_branch(
+ pub(crate) fn begin_repository_default_branch(
&mut self,
- owner: &str,
- slug: &str,
- actor: &str,
- default_branch: &str,
- changed_at: i64,
- correlation_id: &str,
+ intent: &NewDefaultBranchIntent<'_>,
) -> Result<(), StoreError> {
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)?;
- let access = repository_issue_access(&transaction, owner, slug, Some(actor))?;
+ let access =
+ repository_issue_access(&transaction, intent.owner, intent.slug, Some(intent.actor))?;
if !access.can_maintain() {
return Err(StoreError::PullRequestDenied);
}
+ let stored: String = transaction.query_row(
+ "SELECT ref_name FROM repository_default_branch WHERE repository_id = ?1",
+ [&access.repository.id],
+ |row| row.get(0),
+ )?;
+ if stored != intent.previous_branch {
+ return Err(StoreError::InvalidDefaultBranch);
+ }
+ transaction.execute(
+ "INSERT INTO repository_default_branch_intent
+ (id, repository_id, actor, previous_ref_name, proposed_ref_name, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![
+ intent.id,
+ access.repository.id,
+ intent.actor,
+ intent.previous_branch,
+ intent.default_branch,
+ intent.changed_at
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn complete_repository_default_branch(
+ &mut self,
+ id: &str,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (repository_id, owner, slug, actor, default_branch, changed_at): (
+ String,
+ String,
+ String,
+ String,
+ String,
+ i64,
+ ) = transaction
+ .query_row(
+ "SELECT repository.id, account.username, repository.slug,
+ repository_default_branch_intent.actor,
+ repository_default_branch_intent.proposed_ref_name,
+ repository_default_branch_intent.created_at
+ FROM repository_default_branch_intent
+ JOIN repository
+ ON repository.id = repository_default_branch_intent.repository_id
+ JOIN account ON account.id = repository.owner_account_id
+ WHERE repository_default_branch_intent.id = ?1",
+ [id],
+ |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ ))
+ },
+ )
+ .optional()?
+ .ok_or_else(|| StoreError::DefaultBranchIntentState(id.to_owned()))?;
let changed = transaction.execute(
"UPDATE repository_default_branch SET ref_name = ?2
WHERE repository_id = ?1",
- rusqlite::params![access.repository.id, default_branch],
+ rusqlite::params![repository_id, default_branch],
)?;
if changed != 1 {
return Err(StoreError::InvalidDefaultBranch);
@@ -1919,15 +1963,58 @@
&transaction,
&NewAuditEvent {
action: "repository.default-branch",
- actor,
+ actor: &actor,
target: &target,
outcome: "success",
- correlation_id,
+ correlation_id: id,
created_at: changed_at,
},
)?;
+ let deleted = transaction.execute(
+ "DELETE FROM repository_default_branch_intent WHERE id = ?1",
+ [id],
+ )?;
+ if deleted != 1 {
+ return Err(StoreError::DefaultBranchIntentState(id.to_owned()));
+ }
transaction.commit()?;
Ok(())
+ }
+
+ pub(crate) fn abandon_repository_default_branch(&mut self, id: &str) -> Result<(), StoreError> {
+ let changed = self.connection.execute(
+ "DELETE FROM repository_default_branch_intent WHERE id = ?1",
+ [id],
+ )?;
+ if changed != 1 {
+ return Err(StoreError::DefaultBranchIntentState(id.to_owned()));
+ }
+ Ok(())
+ }
+
+ pub(crate) fn incomplete_repository_default_branches(
+ &self,
+ ) -> Result<Vec<DefaultBranchIntentRecord>, StoreError> {
+ let mut statement = self.connection.prepare(
+ "SELECT repository_default_branch_intent.id,
+ repository_default_branch_intent.repository_id,
+ repository_default_branch_intent.previous_ref_name,
+ repository_default_branch_intent.proposed_ref_name
+ FROM repository_default_branch_intent
+ ORDER BY repository_default_branch_intent.created_at,
+ repository_default_branch_intent.id",
+ )?;
+ statement
+ .query_map([], |row| {
+ Ok(DefaultBranchIntentRecord {
+ id: row.get(0)?,
+ repository_id: row.get(1)?,
+ previous_branch: row.get(2)?,
+ proposed_branch: row.get(3)?,
+ })
+ })?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
}
pub(crate) fn repository_description(&self, repository_id: &str) -> Result<String, StoreError> {
@@ -4894,6 +4981,23 @@
pub(crate) pack_name: Option<String>,
}
+pub(crate) struct DefaultBranchIntentRecord {
+ pub(crate) id: String,
+ pub(crate) repository_id: String,
+ pub(crate) previous_branch: String,
+ pub(crate) proposed_branch: String,
+}
+
+pub(crate) struct NewDefaultBranchIntent<'a> {
+ pub(crate) id: &'a str,
+ pub(crate) owner: &'a str,
+ pub(crate) slug: &'a str,
+ pub(crate) actor: &'a str,
+ pub(crate) previous_branch: &'a str,
+ pub(crate) default_branch: &'a str,
+ pub(crate) changed_at: i64,
+}
+
#[allow(
dead_code,
reason = "some integration tests compile storage without accounts"
@@ -5791,20 +5895,10 @@
ValueRef::Real(value) => DumpValue::Real(format!("{value:.17e}")),
ValueRef::Text(value) => match std::str::from_utf8(value) {
Ok(value) => DumpValue::TextUtf8(value.to_owned()),
- Err(_) => DumpValue::TextHex(hex(value)),
+ Err(_) => DumpValue::TextHex(encode_lower_hex(value)),
},
- ValueRef::Blob(value) => DumpValue::BlobHex(hex(value)),
+ ValueRef::Blob(value) => DumpValue::BlobHex(encode_lower_hex(value)),
}
-}
-
-fn hex(bytes: &[u8]) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut output = String::with_capacity(bytes.len() * 2);
- for byte in bytes {
- output.push(char::from(HEX[(byte >> 4) as usize]));
- output.push(char::from(HEX[(byte & 0x0f) as usize]));
- }
- output
}
fn is_unique_constraint(error: &rusqlite::Error) -> bool {
@@ -5821,6 +5915,60 @@
.and_then(|page| page.checked_mul(page_size))
.and_then(|offset| i64::try_from(offset).ok())
.ok_or(StoreError::EventLimit)
+}
+
+fn backfill_default_branches(
+ transaction: &rusqlite::Transaction<'_>,
+ instance: &Path,
+) -> Result<(), StoreError> {
+ let ids = {
+ let mut statement = transaction.prepare("SELECT id FROM repository")?;
+ statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::<Result<Vec<_>, _>>()?
+ };
+ for id in ids {
+ let head = instance
+ .join("repositories")
+ .join(format!("{id}.git"))
+ .join("HEAD");
+ let contents = match fs::read(&head) {
+ Ok(contents) => contents,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(source) => return Err(StoreError::MigrationFilesystem { path: head, source }),
+ };
+ let name = contents
+ .strip_suffix(b"\n")
+ .unwrap_or(&contents)
+ .strip_prefix(b"ref: ")
+ .ok_or_else(|| {
+ StoreError::Integrity(format!(
+ "repository {id} has a non-symbolic HEAD during migration"
+ ))
+ })?;
+ let candidate = gix::bstr::BString::from(name);
+ if !name.starts_with(b"refs/heads/") || gix::refs::FullName::try_from(candidate).is_err() {
+ return Err(StoreError::Integrity(format!(
+ "repository {id} has an invalid HEAD during migration"
+ )));
+ }
+ let name = std::str::from_utf8(name).map_err(|_| {
+ StoreError::Integrity(format!(
+ "repository {id} has a non-UTF-8 HEAD during migration"
+ ))
+ })?;
+ let changed = transaction.execute(
+ "UPDATE repository_default_branch SET ref_name = ?2
+ WHERE repository_id = ?1",
+ rusqlite::params![id, name],
+ )?;
+ if changed != 1 {
+ return Err(StoreError::Integrity(format!(
+ "repository {id} has no default-branch row during migration"
+ )));
+ }
+ }
+ Ok(())
}
#[allow(
@@ -5842,7 +5990,7 @@
#[allow(
dead_code,
- reason = "M1A proves migrations before the M2 server calls them"
+ reason = "some integration test crates import storage without migration backup paths"
)]
fn migration_backup_path(path: &Path, version: i64) -> PathBuf {
let mut backup = OsString::from(path.as_os_str());
src/system.rs
Mode → 100644; object → e199cfe70f74
@@ -1,0 +1,25 @@
+#![allow(
+ dead_code,
+ reason = "integration test crates use only the system operations required by their imported modules"
+)]
+
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+
+use crate::codec::encode_lower_hex;
+
+pub(crate) fn random_lower_hex<const N: usize>() -> Option<String> {
+ let mut bytes = [0_u8; N];
+ rand::rngs::SysRng.try_fill_bytes(&mut bytes).ok()?;
+ Some(encode_lower_hex(&bytes))
+}
+
+pub(crate) fn unix_timestamp() -> Option<i64> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()?
+ .as_secs()
+ .try_into()
+ .ok()
+}
src/telemetry.rs
Mode 100644 → 100644; object 4385dc68ebe6 → 8eecb5d9eaf3
@@ -71,6 +71,7 @@
status: Some(status),
duration_ms: Some(duration.as_millis().min(u128::from(u64::MAX)) as u64),
outcome: None,
+ error: None,
});
}
@@ -114,6 +115,22 @@
status: None,
duration_ms: None,
outcome: Some(outcome),
+ error: None,
+ });
+ }
+
+ pub(crate) fn failure(&self, event: &'static str, operation_id: Option<&str>, error: &str) {
+ self.write_event(&Event {
+ timestamp_ms: timestamp_ms(),
+ level: "error",
+ event,
+ request_id: None,
+ operation_id,
+ method: None,
+ status: None,
+ duration_ms: None,
+ outcome: Some("failure"),
+ error: Some(error),
});
}
@@ -147,6 +164,7 @@
status: None,
duration_ms: None,
outcome: Some(outcome),
+ error: None,
});
}
@@ -187,6 +205,8 @@
duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
outcome: Option<&'static str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option<&'a str>,
}
#[cfg(test)]
src/watch.rs
Mode 100644 → 100644; object 0788253bde40 → 6d83643be499
@@ -1,11 +1,11 @@
use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use crate::auth::{AuthError, validate_username};
use crate::domain::repository::{RepositoryNameError, validate_slug};
use crate::store::{RepositoryRecord, Store, StoreError, WatchRecord};
+use crate::system::unix_timestamp;
#[derive(Clone)]
pub(crate) struct WatchService {
@@ -57,11 +57,7 @@
}
fn timestamp() -> Result<i64, WatchError> {
- let seconds = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| WatchError::Clock)?
- .as_secs();
- i64::try_from(seconds).map_err(|_| WatchError::Clock)
+ unix_timestamp().ok_or(WatchError::Clock)
}
#[derive(Debug, Error)]
tests/account_lifecycle.rs
Mode 100644 → 100644; object 4e21ed4a8718 → 240f4738ec4b
@@ -1,17 +1,4 @@
-#[path = "../src/account.rs"]
-mod account;
-#[allow(
- dead_code,
- reason = "the account test uses only SSH public-key parsing"
-)]
-#[path = "../src/auth.rs"]
-mod auth;
-#[allow(
- dead_code,
- reason = "the account test does not use every store operation"
-)]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::{account, auth, store};
use rand::rng;
use rusqlite::OptionalExtension;
tests/auth.rs
Mode 100644 → 100644; object cc338bd31d0b → 3e372612f2e6
@@ -1,5 +1,4 @@
-#[path = "../src/auth.rs"]
-mod auth;
+use crate::auth;
use std::fs;
use std::path::{Path, PathBuf};
tests/cli.rs
Mode 100644 → 100644; object 612eb342f7dd → e5eb4010bd82
@@ -32,7 +32,8 @@
include_str!("../src/store/migrations/021_pull_request_lifecycle.sql"),
include_str!("../src/store/migrations/022_account_key_management.sql"),
include_str!("../src/store/migrations/023_default_branch.sql"),
- "PRAGMA user_version = 23;\n",
+ include_str!("../src/store/migrations/024_default_branch_intents.sql"),
+ "PRAGMA user_version = 24;\n",
);
#[test]
tests/git_http.rs
Mode 100644 → 100644; object 6c66c08e5a66 → 4952d1d924cc
@@ -1,35 +1,4 @@
-#[path = "../src/git/http.rs"]
-mod http;
-#[allow(dead_code, reason = "the HTTP test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[allow(
- dead_code,
- reason = "the HTTP test does not use each shared protocol API"
-)]
-#[path = "../src/git/packetline.rs"]
-mod packetline;
-#[allow(dead_code, reason = "the HTTP test does not use repository policy")]
-#[path = "../src/policy.rs"]
-mod policy;
-#[allow(
- dead_code,
- reason = "the HTTP test does not inspect repository internals"
-)]
-#[path = "../src/git/repository.rs"]
-mod repository;
-#[allow(dead_code, reason = "the HTTP test does not use the intent store")]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[allow(
- dead_code,
- reason = "the HTTP test uses transport resolution through HTTP"
-)]
-#[path = "../src/git/transport.rs"]
-mod transport;
-#[allow(dead_code, reason = "the HTTP test uses upload-pack through HTTP")]
-#[path = "../src/git/upload_pack.rs"]
-mod upload_pack;
+use crate::git::{http, packetline, transport, upload_pack};
use std::fs;
use std::io::{Read, Write};
@@ -314,7 +283,7 @@
let output = Command::new(std::env::current_exe().expect("find the test executable"))
.args([
"--exact",
- "server_process_does_not_invoke_git",
+ "git_http_tests::server_process_does_not_invoke_git",
"--nocapture",
])
.env(CHILD_VARIABLE, "1")
tests/git_push_ssh.rs
Mode 100644 → 100644; object af2de5b2427a → 293f4bc0f399
@@ -1,46 +1,4 @@
-#[allow(
- dead_code,
- reason = "the SSH push test does not use each authentication API"
-)]
-#[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"
-)]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(dead_code, reason = "the SSH push test does not use issue commands")]
-#[path = "../src/issue.rs"]
-mod issue;
-#[allow(dead_code, reason = "the SSH push test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[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 use pull requests")]
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[path = "../src/rate_limit.rs"]
-mod rate_limit;
-#[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"
-)]
-#[path = "../src/ssh.rs"]
-mod ssh;
-#[allow(dead_code, reason = "the SSH push test does not use each store API")]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[path = "../src/telemetry.rs"]
-mod telemetry;
+use crate::{auth, git, ssh, store};
use std::env;
use std::fs;
@@ -402,7 +360,7 @@
Command::new(env::current_exe().expect("find the integration test executable"))
.args([
"--exact",
- "git_push_crash_child",
+ "git_push_ssh_tests::git_push_crash_child",
"--nocapture",
"--test-threads=1",
])
tests/git_reads.rs
Mode 100644 → 100644; object 3919f6aca81f → b08b72e0ca2b
@@ -1,11 +1,4 @@
-#[path = "../src/git/patch.rs"]
-mod patch;
-#[allow(
- dead_code,
- reason = "the test uses each public read contract selectively"
-)]
-#[path = "../src/git/read.rs"]
-mod read;
+use crate::git::{patch, read};
use std::collections::BTreeSet;
use std::fs;
@@ -62,6 +55,16 @@
assert_eq!(
history.iter().map(|commit| commit.id).collect::<Vec<_>>(),
[fixture.second, fixture.first]
+ );
+ let history_prefix = service
+ .history_prefix(fixture.second, 1, &cancellation)
+ .expect("read a history prefix");
+ assert_eq!(
+ history_prefix
+ .iter()
+ .map(|commit| commit.id)
+ .collect::<Vec<_>>(),
+ [fixture.second]
);
let root = service
tests/git_repository.rs
Mode 100644 → 100644; object 578d3175bb9c → 064f1da88ab2
@@ -1,6 +1,4 @@
-#[allow(dead_code, reason = "the repository test exercises selected Git APIs")]
-#[path = "../src/git/repository.rs"]
-mod repository;
+use crate::git::{repository, upload_pack};
use std::fs;
use std::io::Write;
@@ -11,6 +9,7 @@
use gix::hash::ObjectId;
use repository::{GitRepository, GitRepositoryError};
use tempfile::TempDir;
+use upload_pack::advertised_ref;
#[test]
fn opens_empty_sha1_and_sha256_bare_repositories() {
@@ -92,6 +91,10 @@
assert!(references.iter().any(|reference| {
reference.name == b"refs/tags/v1" && reference.peeled == Some(first)
}));
+ assert!(matches!(
+ source.references_with_limit(1),
+ Err(GitRepositoryError::ReferenceLimit)
+ ));
let pack = source
.make_pack(&[first], &[])
@@ -110,6 +113,24 @@
index_pack(&destination, &incremental);
assert_object(&destination, second, "commit");
}
+}
+
+#[test]
+fn formats_reference_names_as_git_bytes() {
+ let directory = TempDir::new().expect("create a repository directory");
+ let repository_path = directory.path().join("source");
+ let commit = make_fixture(&repository_path, "sha1");
+ let name = b"topic-\xff".to_vec();
+ let advertisement = advertised_ref(commit, &name, Some(b"agent=tit"));
+ let commit_text = commit.to_string();
+ let expected = [
+ commit_text.as_bytes(),
+ b" ",
+ name.as_slice(),
+ b"\0agent=tit\n",
+ ]
+ .concat();
+ assert_eq!(advertisement, expected);
}
#[test]
tests/git_ssh.rs
Mode 100644 → 100644; object 2bbdc070f0a2 → 4b206b369ccd
@@ -1,46 +1,4 @@
-#[allow(
- dead_code,
- reason = "the SSH Git test does not use each authentication API"
-)]
-#[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"
-)]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(dead_code, reason = "the SSH Git test does not use issue commands")]
-#[path = "../src/issue.rs"]
-mod issue;
-#[allow(dead_code, reason = "the SSH Git test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[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 use pull requests")]
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[path = "../src/rate_limit.rs"]
-mod rate_limit;
-#[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"
-)]
-#[path = "../src/ssh.rs"]
-mod ssh;
-#[allow(dead_code, reason = "the SSH Git test does not use the intent store")]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[path = "../src/telemetry.rs"]
-mod telemetry;
+use crate::{auth, git, ssh};
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
tests/metadata_search.rs
Mode 100644 → 100644; object c05fefc46594 → f9e983c6ad13
@@ -1,13 +1,4 @@
-#[allow(dead_code, reason = "the search test uses only username validation")]
-#[path = "../src/auth.rs"]
-mod auth;
-#[path = "../src/domain/mod.rs"]
-mod domain;
-#[path = "../src/search.rs"]
-mod search;
-#[allow(dead_code, reason = "the search test uses only metadata storage")]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::{search, store};
use std::time::{Duration, Instant};
tests/public_routes.rs
Mode 100644 → 100644; object 53d10b0cc8d9 → 2bc6f282dfac
@@ -1,80 +1,4 @@
-#[allow(
- dead_code,
- reason = "the public-route test does not use account mutations"
-)]
-#[path = "../src/account.rs"]
-mod account;
-#[allow(
- dead_code,
- reason = "the public-route test uses only username validation"
-)]
-#[path = "../src/auth.rs"]
-mod auth;
-#[path = "../src/domain/mod.rs"]
-mod domain;
-#[path = "../src/feed.rs"]
-mod feed;
-#[path = "../src/feed_token.rs"]
-mod feed_token;
-#[allow(
- dead_code,
- reason = "the public-route test does not use each shared Git API"
-)]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(
- dead_code,
- reason = "the public-route test uses the public Web server only"
-)]
-#[path = "../src/http/mod.rs"]
-mod http;
-#[allow(
- dead_code,
- reason = "the public-route test creates instance files directly"
-)]
-#[path = "../src/instance.rs"]
-mod instance;
-#[allow(dead_code, reason = "the public-route test does not mutate issues")]
-#[path = "../src/issue.rs"]
-mod issue;
-#[allow(dead_code, reason = "the public-route test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[path = "../src/markdown.rs"]
-mod markdown;
-#[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 mutate pull requests"
-)]
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[path = "../src/rate_limit.rs"]
-mod rate_limit;
-#[allow(
- dead_code,
- reason = "the public route test does not create repositories through forms"
-)]
-#[path = "../src/repository.rs"]
-mod repository;
-#[path = "../src/search.rs"]
-mod search;
-#[allow(dead_code, reason = "the public-route test does not complete a login")]
-#[path = "../src/session.rs"]
-mod session;
-#[allow(
- dead_code,
- reason = "the public-route test does not use each store API"
-)]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[path = "../src/telemetry.rs"]
-mod telemetry;
-#[allow(dead_code, reason = "the public-route test does not change watches")]
-#[path = "../src/watch.rs"]
-mod watch;
+use crate::{git, http, store};
use std::collections::BTreeMap;
use std::fs;
@@ -810,6 +734,17 @@
assert!(anonymous.text().contains("<form class=\"filter-form\""));
assert!(anonymous.text().contains("class=\"collection-action\""));
assert!(!anonymous.text().contains("Create an issue</h2>"));
+ assert_eq!(
+ request(
+ server.address(),
+ "GET",
+ "/alice/example/issues?page=0",
+ &[],
+ &[],
+ )
+ .status,
+ 400
+ );
let cookie = format!("tit-session={token}; tit-csrf={csrf}");
let headers = [
@@ -854,6 +789,17 @@
assert!(!detail.text().contains("<script>"));
assert!(detail.text().contains("Add a comment"));
assert!(!detail.text().contains("Organize this issue"));
+ assert_eq!(
+ request(
+ server.address(),
+ "GET",
+ "/alice/example/issues/1?comments_page=0",
+ &[],
+ &[],
+ )
+ .status,
+ 400
+ );
let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
assert_eq!(
@@ -923,6 +869,17 @@
let anonymous_pull_requests =
request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
assert_eq!(anonymous_pull_requests.status, 200);
+ assert_eq!(
+ request(
+ server.address(),
+ "GET",
+ "/alice/example/pulls?page=0",
+ &[],
+ &[],
+ )
+ .status,
+ 400
+ );
assert_repository_navigation(&anonymous_pull_requests, "alice", "example");
assert!(
anonymous_pull_requests
tests/pull_requests.rs
Mode 100644 → 100644; object c02ebdd1b2b8 → 8818d416de28
@@ -1,31 +1,4 @@
-#[allow(
- dead_code,
- reason = "the pull-request test uses only identity validation"
-)]
-#[path = "../src/auth.rs"]
-mod auth;
-#[path = "../src/domain/mod.rs"]
-mod domain;
-#[allow(
- dead_code,
- reason = "the pull-request test uses part of the shared Git API"
-)]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(dead_code, reason = "the pull-request test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[allow(
- dead_code,
- reason = "the pull-request test uses repository policy through Git"
-)]
-#[path = "../src/policy.rs"]
-mod policy;
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[allow(dead_code, reason = "the pull-request test uses part of the store API")]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::{git, pull_request, store};
use std::fs;
use std::os::unix::ffi::OsStringExt;
tests/repository_policy.rs
Mode 100644 → 100644; object 99c8bd8bf9c6 → 5627b1e60d9e
@@ -1,11 +1,14 @@
-#[path = "../src/policy.rs"]
-mod policy;
-#[allow(dead_code, reason = "the policy test uses only repository storage")]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::git::repository::GitRepository;
+use crate::repository::RepositoryService;
+use crate::{policy, store};
+use std::fs;
+
+use gix::hash::Kind;
use policy::{PolicyError, RefChange, RepositoryOperation, RepositoryPolicy};
-use store::{AuditContext, NewRepository, RepositoryOrigin, Store, StoreError};
+use store::{
+ AuditContext, NewDefaultBranchIntent, NewRepository, RepositoryOrigin, Store, StoreError,
+};
use tempfile::TempDir;
#[test]
@@ -252,15 +255,19 @@
)
.expect("allow a writer topic branch");
store
- .update_repository_default_branch(
- "owner",
- "project",
- "maintainer",
- "refs/heads/trunk",
- 4,
- "default-branch",
- )
- .expect("change the protected default branch");
+ .begin_repository_default_branch(&NewDefaultBranchIntent {
+ id: "00000000000000000000000000000004",
+ owner: "owner",
+ slug: "project",
+ actor: "maintainer",
+ previous_branch: "refs/heads/main",
+ default_branch: "refs/heads/trunk",
+ changed_at: 4,
+ })
+ .expect("begin the protected default-branch change");
+ store
+ .complete_repository_default_branch("00000000000000000000000000000004")
+ .expect("complete the protected default-branch change");
policy
.authorize_ref_change(
"writer",
@@ -303,6 +310,72 @@
policy.authorize_merge("writer", "owner", "project"),
Err(PolicyError::Denied)
));
+}
+
+#[test]
+fn recovers_a_default_branch_change_after_git_moves_first() {
+ let directory = TempDir::new().expect("create a recovery fixture directory");
+ let database = directory.path().join("tit.sqlite3");
+ let repositories = directory.path().join("repositories");
+ let repository_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ let bare = repositories.join(format!("{repository_id}.git"));
+ fs::create_dir(&repositories).expect("create the repository directory");
+ let mut store = Store::open(&database).expect("create the recovery database");
+ store
+ .connection()
+ .execute(
+ "INSERT INTO account (id, username, is_administrator, state, created_at)
+ VALUES (1, 'owner', 0, 'active', 1)",
+ [],
+ )
+ .expect("create the repository owner");
+ store
+ .create_repository(&NewRepository {
+ id: repository_id,
+ owner: "owner",
+ slug: "project",
+ object_format: "sha1",
+ default_branch: "refs/heads/main",
+ created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ actor: "admin-cli",
+ correlation_id: "test",
+ })
+ .expect("create the repository record");
+ GitRepository::create_bare(&bare, Kind::Sha1).expect("create the bare repository");
+ store
+ .begin_repository_default_branch(&NewDefaultBranchIntent {
+ id: "00000000000000000000000000000005",
+ owner: "owner",
+ slug: "project",
+ actor: "owner",
+ previous_branch: "refs/heads/main",
+ default_branch: "refs/heads/trunk",
+ changed_at: 3,
+ })
+ .expect("begin the default-branch change");
+ fs::write(bare.join("HEAD"), b"ref: refs/heads/trunk\n")
+ .expect("simulate the completed Git change");
+ drop(store);
+
+ RepositoryService::new(&database, &repositories)
+ .recover()
+ .expect("recover the default-branch change");
+
+ let store = Store::open(&database).expect("reopen the recovery database");
+ assert_eq!(
+ store
+ .repository_default_branch("owner", "project")
+ .expect("read the recovered default branch"),
+ "refs/heads/trunk"
+ );
+ assert!(
+ store
+ .incomplete_repository_default_branches()
+ .expect("read default-branch intents")
+ .is_empty()
+ );
}
fn operations() -> [RepositoryOperation; 4] {
tests/sqlite.rs
Mode 100644 → 100644; object 23b75b03203d → d98bbbef6772
@@ -1,6 +1,4 @@
-#[allow(dead_code, reason = "the storage test exercises selected store APIs")]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::store;
use std::process::{Child, Command};
use std::sync::mpsc;
@@ -142,7 +140,12 @@
ready_path: &std::path::Path,
) -> Child {
Command::new(env::current_exe().expect("find the integration test executable"))
- .args(["--exact", "crash_child", "--nocapture", "--test-threads=1"])
+ .args([
+ "--exact",
+ "sqlite_tests::crash_child",
+ "--nocapture",
+ "--test-threads=1",
+ ])
.env("TIT_M1A_CHILD_MODE", mode)
.env("TIT_M1A_DATABASE", database_path)
.env("TIT_M1A_READY", ready_path)
@@ -235,7 +238,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"), 23);
+ assert_eq!(store.schema_version().expect("read the schema version"), 24);
assert_eq!(
store
.connection()
@@ -1631,7 +1634,7 @@
create_fixture(&path, fixture);
let store = Store::open(&path).expect("migrate the fixture");
- assert_eq!(store.schema_version().expect("read the schema version"), 23);
+ assert_eq!(store.schema_version().expect("read the schema version"), 24);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -1692,6 +1695,10 @@
.expect("remove the new table from the historical fixture");
store
.connection()
+ .execute("DROP TABLE repository_default_branch_intent", [])
+ .expect("remove the intent table from the historical fixture");
+ store
+ .connection()
.pragma_update(None, "user_version", 22)
.expect("set the historical schema version");
drop(store);
@@ -1709,6 +1716,71 @@
.repository_default_branch("alice", "project")
.expect("read the migrated default branch"),
"refs/heads/trunk"
+ );
+}
+
+#[test]
+fn migration_does_not_commit_when_a_repository_head_cannot_be_read() {
+ let directory = TempDir::new().expect("create a migration directory");
+ let path = database(&directory, "tit.sqlite3");
+ let mut store = Store::open(&path).expect("create the current database");
+ store
+ .connection()
+ .execute(
+ "INSERT INTO account
+ (id, username, is_administrator, state, created_at)
+ VALUES (1, 'alice', 1, 'active', 1)",
+ [],
+ )
+ .expect("create the migration account");
+ store
+ .create_repository(&NewRepository {
+ id: "00112233445566778899aabbccddeeff",
+ owner: "alice",
+ slug: "project",
+ object_format: "sha1",
+ default_branch: "refs/heads/main",
+ created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ actor: "alice",
+ correlation_id: "migration-default-failure",
+ })
+ .expect("create the migration repository");
+ store
+ .connection()
+ .execute("DROP TABLE repository_default_branch", [])
+ .expect("remove the new table from the historical fixture");
+ store
+ .connection()
+ .execute("DROP TABLE repository_default_branch_intent", [])
+ .expect("remove the intent table from the historical fixture");
+ store
+ .connection()
+ .pragma_update(None, "user_version", 22)
+ .expect("set the historical schema version");
+ drop(store);
+ let head = directory
+ .path()
+ .join("repositories")
+ .join("00112233445566778899aabbccddeeff.git")
+ .join("HEAD");
+ fs::create_dir_all(&head).expect("create an unreadable historical HEAD");
+
+ let error = match Store::open(&path) {
+ Ok(_) => panic!("migration unexpectedly succeeded"),
+ Err(error) => error,
+ };
+ assert!(
+ matches!(&error, StoreError::MigrationFilesystem { .. }),
+ "{error}"
+ );
+ let unchanged = Store::open_unmigrated(&path).expect("open the rolled-back database");
+ assert_eq!(
+ unchanged
+ .schema_version()
+ .expect("read the rolled-back schema version"),
+ 22
);
}
@@ -1758,7 +1830,7 @@
#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 23)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 24)] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "fixture.sqlite");
create_fixture(&path, V1_FIXTURE);
tests/sqlite_workload.rs
Mode 100644 → 100644; object de4ed06f5847 → 37e646932592
@@ -1,9 +1,4 @@
-#[allow(
- dead_code,
- reason = "the M1A workload does not use Git operation intents"
-)]
-#[path = "../src/store/mod.rs"]
-mod store;
+use crate::store;
use std::fs;
use std::time::{Duration, Instant};
tests/ssh.rs
Mode 100644 → 100644; object 552bf89103f4 → 05ea59378194
@@ -1,55 +1,4 @@
-#[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"
-)]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(
- dead_code,
- reason = "the SSH identity test does not use issue commands"
-)]
-#[path = "../src/issue.rs"]
-mod issue;
-#[allow(dead_code, reason = "the SSH identity test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[allow(
- dead_code,
- reason = "the SSH identity test does not use repository policy"
-)]
-#[path = "../src/policy.rs"]
-mod policy;
-#[allow(dead_code, reason = "the SSH identity test does not use pull requests")]
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[path = "../src/rate_limit.rs"]
-mod rate_limit;
-#[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"
-)]
-#[path = "../src/ssh.rs"]
-mod ssh;
-#[allow(
- dead_code,
- reason = "the SSH identity test does not use the intent store"
-)]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[path = "../src/telemetry.rs"]
-mod telemetry;
+use crate::{auth, ssh};
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
tests/support/mod.rs
Mode 100644 → 100644; object 5094ec042eea → d249db685df2
@@ -36,7 +36,11 @@
}
pub(crate) fn run(&self, arguments: &[&str]) -> Output {
- Command::new(env!("CARGO_BIN_EXE_tit"))
+ let executable = option_env!("CARGO_BIN_EXE_tit")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("CARGO_BIN_EXE_tit").map(PathBuf::from))
+ .expect("find the tit test executable");
+ Command::new(executable)
.args(arguments)
.output()
.expect("run tit")
tests/web_session.rs
Mode 100644 → 100644; object 17eec5451fa6 → 3827ad13a9e3
@@ -1,23 +1,7 @@
+use crate::{account, auth, session, store};
+
#[allow(dead_code, reason = "the Web session test uses one shared test helper")]
mod support;
-
-#[allow(
- dead_code,
- reason = "the Web session test uses part of account management"
-)]
-#[path = "../src/account.rs"]
-mod account;
-#[allow(dead_code, reason = "the Web session test uses part of authentication")]
-#[path = "../src/auth.rs"]
-mod auth;
-#[path = "../src/session.rs"]
-mod session;
-#[allow(
- dead_code,
- reason = "the Web session test does not use each store operation"
-)]
-#[path = "../src/store/mod.rs"]
-mod store;
use std::fs;
use std::path::Path;
tests/web_shell.rs
Mode 100644 → 100644; object 47eb9e815447 → cbc1fb38ada9
@@ -1,69 +1,4 @@
-#[allow(
- dead_code,
- reason = "the Web shell test does not use account mutations"
-)]
-#[path = "../src/account.rs"]
-mod account;
-#[allow(dead_code, reason = "the Web shell test uses only username validation")]
-#[path = "../src/auth.rs"]
-mod auth;
-#[allow(
- dead_code,
- reason = "the Web shell test uses only repository slug validation"
-)]
-#[path = "../src/domain/mod.rs"]
-mod domain;
-#[path = "../src/feed.rs"]
-mod feed;
-#[path = "../src/feed_token.rs"]
-mod feed_token;
-#[allow(dead_code, reason = "the shell test does not use each shared Git API")]
-#[path = "../src/git/mod.rs"]
-mod git;
-#[allow(
- dead_code,
- reason = "the shell test does not use public repository routes"
-)]
-#[path = "../src/http/mod.rs"]
-mod http;
-#[allow(
- dead_code,
- reason = "the shell test does not use instance administration"
-)]
-#[path = "../src/instance.rs"]
-mod instance;
-#[allow(dead_code, reason = "the Web shell test does not use issue workflows")]
-#[path = "../src/issue.rs"]
-mod issue;
-#[allow(dead_code, reason = "the Web shell test does not run maintenance")]
-#[path = "../src/maintenance.rs"]
-mod maintenance;
-#[path = "../src/markdown.rs"]
-mod markdown;
-#[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 use pull requests")]
-#[path = "../src/pull_request.rs"]
-mod pull_request;
-#[path = "../src/rate_limit.rs"]
-mod rate_limit;
-#[allow(dead_code, reason = "the Web shell test does not create repositories")]
-#[path = "../src/repository.rs"]
-mod repository;
-#[path = "../src/search.rs"]
-mod search;
-#[allow(dead_code, reason = "the Web shell test does not complete a login")]
-#[path = "../src/session.rs"]
-mod session;
-#[allow(dead_code, reason = "the shell test does not use repository storage")]
-#[path = "../src/store/mod.rs"]
-mod store;
-#[path = "../src/telemetry.rs"]
-mod telemetry;
-#[allow(dead_code, reason = "the Web shell test does not change watches")]
-#[path = "../src/watch.rs"]
-mod watch;
+use crate::http;
use std::collections::BTreeMap;
use std::io::{Read, Write};