michal/tit
Diff
ccaf021500c8 → 1e0b97e01e97
.github/workflows/ci.yml
Mode 100644 → 100644; object 5137f77b92a1 → a4be0dad87ef
@@ -88,8 +88,7 @@
sudo apt-get install --no-install-recommends \
gcc-arm-linux-gnueabihf \
libc6-dev-armhf-cross \
- qemu-user-binfmt
- sudo update-binfmts --enable qemu-arm
+ qemu-user
rustup target add "$TARGET"
- run: cargo --version
- run: cargo test --locked --all-targets --all-features --target "$TARGET"
CONTRIBUTING.md
Mode 100644 → 100644; object ff9633249768 → 5ed957cd3de0
@@ -102,7 +102,8 @@ HTML, and statically linked code. - dependency audit, external test driver, fixture repository, loopback address, quality gate, Rust toolchain, standard error, and standard output. -- advertised hostname, hostname, onion hostname, and proxy command. +- advertised hostname, hostname, onion hostname, proxy, proxy command, and + trusted proxy. - database constraint, foreign key, online backup, prepared statement, and schema migration. - durability gate, local filesystem, migration backup, percentile, and
Cargo.lock
Mode 100644 → 100644; object 823e9b65ba07 → a3edfad70e00
@@ -40,15 +40,6 @@ ] [[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -374,7 +365,6 @@ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", "windows-link", ] @@ -863,23 +853,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "feed-rs" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" -dependencies = [ - "chrono", - "mediatype", - "quick-xml", - "regex", - "serde", - "serde_json", - "siphasher", - "url", - "uuid", -] [[package]] name = "ff" @@ -2392,15 +2365,6 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] -name = "mediatype" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" -dependencies = [ - "serde", -] - -[[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2854,16 +2818,6 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "encoding_rs", - "memchr", -] - -[[package]] name = "quote" version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2905,33 +2859,10 @@ ] [[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] name = "regex-automata" version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rfc6979" @@ -3631,7 +3562,6 @@ "askama", "axum", "clap", - "feed-rs", "gix", "gix-pack", "httpdate", @@ -3843,17 +3773,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] [[package]] name = "vcpkg"
Cargo.toml
Mode 100644 → 100644; object acbe4089df6a → 43cf72364038
@@ -46,7 +46,6 @@
url = { version = "2.5", features = ["serde"] }
[dev-dependencies]
-feed-rs = { version = "2.4.0", default-features = false }
tempfile = "3.27"
[lints.rust]
README.md
Mode 100644 → 100644; object 6668d38d7c9a → e9c9248eb7d4
@@ -39,7 +39,9 @@ Set `public_url`, the HTTP listener, and the SSH listener and advertised hostname in `/srv/tit/config.toml`. Use HTTPS for a public instance. The included files in `release/examples/` contain service definitions and a Caddy -example for Linux, macOS, FreeBSD, OpenBSD, and NetBSD. +example for Linux, macOS, FreeBSD, OpenBSD, and NetBSD. Set +`http.trusted_proxy` to the Caddy connection address. Do not set this field when +a client can connect directly to the HTTP listener. Create the first administrator:
config.example.toml
Mode 100644 → 100644; object 2134e947e98d → b04e7d9ad20a
@@ -3,6 +3,7 @@ [http] listen = "127.0.0.1:3000" +trusted_proxy = "127.0.0.1" [ssh] listen = "0.0.0.0:2222"
src/account.rs
Mode 100644 → 100644; object 322c6ba645f4 → a99ae96d1e81
@@ -16,10 +16,6 @@
const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
const SECRET_BYTES: usize = 32;
const INVITATION_LIFETIME_SECONDS: i64 = 24 * 60 * 60;
-#[allow(
- dead_code,
- reason = "the account integration test imports this module without the Web profile route"
-)]
const PROFILE_REPOSITORY_PAGE_SIZE: usize = 20;
#[derive(Clone)]
@@ -194,10 +190,6 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "the account integration test imports this module without the Web profile route"
- )]
pub(crate) fn profile_page(
&self,
username: &str,
@@ -229,19 +221,11 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "the account integration test imports this module without Web account routes"
- )]
pub(crate) fn keys(&self, username: &str) -> Result<Vec<KeyInspection>, AccountError> {
validate_username(username)?;
Ok(Store::open(&self.database)?.inspect_account(username)?.keys)
}
- #[allow(
- dead_code,
- reason = "the account integration test imports this module without Web account routes"
- )]
pub(crate) fn complete_key_add(
&self,
request: &AccountKeyRequest<'_>,
@@ -281,10 +265,6 @@
result
}
- #[allow(
- dead_code,
- reason = "the account integration test imports this module without Web account routes"
- )]
pub(crate) fn complete_key_revoke(
&self,
request: &AccountKeyRequest<'_>,
@@ -341,10 +321,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "integration tests compile accounts without the server"
- )]
pub(crate) fn database(&self) -> &Path {
&self.database
}
@@ -405,10 +381,6 @@
Ok(())
}
-#[allow(
- dead_code,
- reason = "the account integration test imports this module without Web account routes"
-)]
fn validate_token(token: &str) -> Result<(), AccountError> {
if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(AccountError::InvalidSecret);
src/auth.rs
Mode 100644 → 100644; object b7adc33b23b9 → e401fd7945ff
@@ -1,7 +1,3 @@
-use std::collections::HashMap;
-use std::sync::Mutex;
-
-use rand::TryRng;
use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PublicKey, SshSig};
use thiserror::Error;
@@ -9,7 +5,6 @@
use crate::codec::{decode_lower_hex, encode_lower_hex};
use url::Url;
-const CHALLENGE_HEADER: &str = "tit-auth-v1";
const KEYLESS_CHALLENGE_HEADER: &str = "tit-auth-v2";
const CHALLENGE_PURPOSE: &str = "web-login";
const SIGNATURE_NAMESPACE: &str = "tit-auth";
@@ -17,7 +12,6 @@
const MAX_CHALLENGE_BYTES: usize = 4 * 1024;
const MAX_SIGNATURE_BYTES: usize = 16 * 1024;
const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
-const MAX_OUTSTANDING_CHALLENGES: usize = 1_024;
const NONCE_BYTES: usize = 32;
const MINIMUM_RSA_BITS: u32 = 3_072;
@@ -115,98 +109,7 @@
username: fields.username.to_owned(),
fingerprint: key.fingerprint().to_owned(),
nonce_hash: hash_nonce(&fields.nonce),
- expires_at: fields.expires_at,
})
-}
-
-#[derive(Debug)]
-pub(crate) struct LoginChallenges {
- origin: String,
- nonces: Mutex<HashMap<[u8; 32], u64>>,
-}
-
-impl LoginChallenges {
- pub(crate) fn new(public_url: &Url) -> Result<Self, AuthError> {
- Ok(Self {
- origin: login_origin(public_url)?,
- nonces: Mutex::new(HashMap::new()),
- })
- }
-
- pub(crate) fn issue(
- &self,
- username: &str,
- key: &SshPublicKey,
- issued_at: u64,
- lifetime_seconds: u64,
- ) -> Result<String, AuthError> {
- validate_username(username)?;
- if lifetime_seconds == 0 || lifetime_seconds > MAX_CHALLENGE_LIFETIME_SECONDS {
- return Err(AuthError::InvalidLifetime);
- }
- let expires_at = issued_at
- .checked_add(lifetime_seconds)
- .ok_or(AuthError::InvalidLifetime)?;
-
- let nonce = self.new_nonce(issued_at, expires_at)?;
- Ok(format_login_challenge(
- &self.origin,
- username,
- key,
- &nonce,
- issued_at,
- expires_at,
- ))
- }
-
- pub(crate) fn verify(
- &self,
- challenge: &str,
- signature: &str,
- expected_username: &str,
- expected_key: &SshPublicKey,
- now: u64,
- ) -> Result<VerifiedLogin, AuthError> {
- let verified = verify_login_challenge(
- &self.origin,
- challenge,
- signature,
- expected_username,
- expected_key,
- now,
- )?;
- let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
- match nonces.get(&verified.nonce_hash) {
- Some(stored_expiry) if *stored_expiry == verified.expires_at => {
- nonces.remove(&verified.nonce_hash);
- }
- _ => return Err(AuthError::ConsumedChallenge),
- }
-
- Ok(VerifiedLogin {
- username: verified.username,
- fingerprint: verified.fingerprint,
- })
- }
-
- fn new_nonce(&self, issued_at: u64, expires_at: u64) -> Result<[u8; NONCE_BYTES], AuthError> {
- loop {
- let mut nonce = [0_u8; NONCE_BYTES];
- rand::rngs::SysRng
- .try_fill_bytes(&mut nonce)
- .map_err(|_| AuthError::Random)?;
- let hash = hash_nonce(&nonce);
- let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
- nonces.retain(|_, stored_expiry| *stored_expiry >= issued_at);
- if nonces.len() >= MAX_OUTSTANDING_CHALLENGES {
- return Err(AuthError::NonceLimit);
- }
- if let std::collections::hash_map::Entry::Vacant(entry) = nonces.entry(hash) {
- entry.insert(expires_at);
- return Ok(nonce);
- }
- }
- }
}
pub(crate) fn login_origin(public_url: &Url) -> Result<String, AuthError> {
@@ -223,79 +126,10 @@
Ok(public_url.origin().ascii_serialization())
}
-pub(crate) fn format_login_challenge(
- origin: &str,
- username: &str,
- key: &SshPublicKey,
- nonce: &[u8; NONCE_BYTES],
- issued_at: u64,
- expires_at: u64,
-) -> String {
- format!(
- "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
- key.fingerprint(),
- encode_lower_hex(nonce)
- )
-}
-
-pub(crate) fn verify_login_challenge(
- origin: &str,
- challenge: &str,
- signature: &str,
- expected_username: &str,
- expected_key: &SshPublicKey,
- now: u64,
-) -> Result<PersistentVerifiedLogin, AuthError> {
- if challenge.len() > MAX_CHALLENGE_BYTES {
- return Err(AuthError::InputTooLarge("login challenge"));
- }
- if signature.len() > MAX_SIGNATURE_BYTES {
- return Err(AuthError::InputTooLarge("SSHSIG envelope"));
- }
- validate_username(expected_username)?;
- let fields = ChallengeFields::parse(challenge)?;
- if fields.origin != origin {
- return Err(AuthError::WrongOrigin);
- }
- if fields.username != expected_username {
- return Err(AuthError::WrongUsername);
- }
- if fields.fingerprint != expected_key.fingerprint() {
- return Err(AuthError::WrongKey);
- }
- if fields.expires_at <= fields.issued_at
- || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
- {
- return Err(AuthError::InvalidLifetime);
- }
- if now < fields.issued_at || now > fields.expires_at {
- return Err(AuthError::ExpiredChallenge);
- }
- let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
- validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
- expected_key
- .public_key()
- .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
- .map_err(AuthError::SignatureVerification)?;
- Ok(PersistentVerifiedLogin {
- username: fields.username.to_owned(),
- fingerprint: fields.fingerprint.to_owned(),
- nonce_hash: hash_nonce(&fields.nonce),
- expires_at: fields.expires_at,
- })
-}
-
pub(crate) struct PersistentVerifiedLogin {
pub(crate) username: String,
pub(crate) fingerprint: String,
pub(crate) nonce_hash: [u8; 32],
- pub(crate) expires_at: u64,
-}
-
-#[derive(Debug, Eq, PartialEq)]
-pub(crate) struct VerifiedLogin {
- pub(crate) username: String,
- pub(crate) fingerprint: String,
}
#[derive(Debug, Error)]
@@ -314,16 +148,12 @@
InvalidOrigin,
#[error("challenge lifetime is not valid")]
InvalidLifetime,
- #[error("cannot get random bytes")]
- Random,
#[error("login challenge is not valid")]
MalformedChallenge,
#[error("login challenge has the wrong origin")]
WrongOrigin,
#[error("login challenge has the wrong username")]
WrongUsername,
- #[error("login challenge has the wrong SSH key")]
- WrongKey,
#[error("login challenge has expired or is not active")]
ExpiredChallenge,
#[error("SSHSIG envelope is not valid: {0}")]
@@ -332,21 +162,6 @@
UnsupportedSignatureAlgorithm,
#[error("SSHSIG verification failed: {0}")]
SignatureVerification(ssh_key::Error),
- #[error("login challenge was consumed or was not issued")]
- ConsumedChallenge,
- #[error("login nonce store is not available")]
- NonceStore,
- #[error("too many login challenges are active")]
- NonceLimit,
-}
-
-struct ChallengeFields<'a> {
- origin: &'a str,
- username: &'a str,
- fingerprint: &'a str,
- nonce: [u8; NONCE_BYTES],
- issued_at: u64,
- expires_at: u64,
}
struct KeylessChallengeFields<'a> {
@@ -380,41 +195,6 @@
Ok(Self {
origin,
username,
- nonce,
- issued_at,
- expires_at,
- })
- }
-}
-
-impl<'a> ChallengeFields<'a> {
- fn parse(challenge: &'a str) -> Result<Self, AuthError> {
- let body = challenge
- .strip_suffix('\n')
- .ok_or(AuthError::MalformedChallenge)?;
- let mut lines = body.split('\n');
- if lines.next() != Some(CHALLENGE_HEADER) || lines.next() != Some("purpose=web-login") {
- return Err(AuthError::MalformedChallenge);
- }
- let origin = field(lines.next(), "origin=")?;
- let username = field(lines.next(), "username=")?;
- validate_username(username)?;
- let fingerprint = field(lines.next(), "fingerprint=")?;
- let nonce = decode_hex(field(lines.next(), "nonce=")?)?;
- let issued_at = parse_time(field(lines.next(), "issued-at=")?)?;
- let expires_at = parse_time(field(lines.next(), "expires-at=")?)?;
- if lines.next().is_some()
- || origin.is_empty()
- || fingerprint.is_empty()
- || origin.contains(['\r', '\n'])
- || fingerprint.contains(['\r', '\n'])
- {
- return Err(AuthError::MalformedChallenge);
- }
- Ok(Self {
- origin,
- username,
- fingerprint,
nonce,
issued_at,
expires_at,
src/backup.rs
Mode 100644 → 100644; object 660d5149e67c → ca44c39c132a
@@ -870,7 +870,7 @@
.unarchive_repository_for_owner("alice", "after", "alice", 5, "backup-unarchive")
.expect("unarchive the backup repository");
drop(store);
- crate::git::repository::GitRepository::create_bare(
+ GitRepository::create_bare(
&instance
.path()
.join(REPOSITORY_DIRECTORY)
src/codec.rs
Mode 100644 → 100644; object 1a9295a4a918 → ade18bc1ebbf
@@ -1,8 +1,3 @@
-#![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));
src/config.rs
Mode 100644 → 100644; object 6f01dd9655cd → 4e9e23f76221
@@ -20,6 +20,7 @@
pub(crate) public_url: Url,
pub(crate) instance_dir: PathBuf,
pub(crate) http_listen: SocketAddr,
+ pub(crate) http_trusted_proxy: Option<IpAddr>,
pub(crate) ssh_listen: SocketAddr,
pub(crate) ssh_public_host: Host<String>,
pub(crate) ssh_public_port: u16,
@@ -37,6 +38,12 @@
validate_public_url(&self.public_url)?;
if self.http_listen.port() == 0 {
return Err(ConfigError::ZeroListenerPort("http.listen"));
+ }
+ if self
+ .http_trusted_proxy
+ .is_some_and(|address| address.is_unspecified() || address.is_multicast())
+ {
+ return Err(ConfigError::InvalidTrustedProxy);
}
if self.ssh_listen.port() == 0 {
return Err(ConfigError::ZeroListenerPort("ssh.listen"));
@@ -122,6 +129,7 @@
#[serde(default, deny_unknown_fields)]
struct HttpConfig {
listen: SocketAddr,
+ trusted_proxy: Option<IpAddr>,
}
impl Default for HttpConfig {
@@ -130,6 +138,7 @@
listen: "127.0.0.1:3000"
.parse()
.expect("the default HTTP address is valid"),
+ trusted_proxy: None,
}
}
}
@@ -229,6 +238,8 @@
InvalidSshPublicAddress(IpAddr),
#[error("listener port {0} must not be zero")]
ZeroListenerPort(&'static str),
+ #[error("trusted proxy must not be an unspecified or multicast address")]
+ InvalidTrustedProxy,
#[error("configuration limit {0} must not be zero")]
ZeroLimit(&'static str),
#[error("configuration limit {0} is too large")]
@@ -283,6 +294,7 @@
public_url,
instance_dir,
http_listen,
+ http_trusted_proxy: file.http.trusted_proxy,
ssh_listen,
ssh_public_host,
ssh_public_port,
@@ -405,6 +417,7 @@
assert_eq!(config.public_url.as_str(), "https://tit.example/");
assert_eq!(config.instance_dir, path.parent().expect("a parent"));
assert_eq!(config.http_listen.to_string(), "127.0.0.1:3000");
+ assert_eq!(config.http_trusted_proxy, None);
assert_eq!(config.ssh_listen.to_string(), "0.0.0.0:2222");
assert_eq!(
config.ssh_public_host,
@@ -525,6 +538,26 @@
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::InvalidSshPublicAddress(address)) if address.is_unspecified()
+ ));
+ }
+
+ #[test]
+ fn accepts_one_explicit_trusted_proxy_and_rejects_unsafe_addresses() {
+ let (_directory, path) = write_config(
+ "version = 1\npublic_url = \"https://tit.example/\"\n[http]\ntrusted_proxy = \"127.0.0.1\"\n",
+ );
+ let config = load(&cli(&path)).expect("load the trusted proxy");
+ assert_eq!(
+ config.http_trusted_proxy,
+ Some("127.0.0.1".parse().expect("parse the expected address"))
+ );
+
+ let (_directory, path) = write_config(
+ "version = 1\npublic_url = \"https://tit.example/\"\n[http]\ntrusted_proxy = \"0.0.0.0\"\n",
+ );
+ assert!(matches!(
+ load(&cli(&path)),
+ Err(ConfigError::InvalidTrustedProxy)
));
}
src/control.rs
Mode 100644 → 100644; object 1091d56f52d1 → ba8f33f1e941
@@ -30,10 +30,7 @@
}
impl RunningControlServer {
- #[allow(
- dead_code,
- reason = "the production server uses the backup-enabled constructor"
- )]
+ #[cfg(test)]
pub(crate) fn start(
instance_dir: &Path,
accounts: AccountService,
src/git/mod.rs
Mode 100644 → 100644; object ccea7ef8c31f → 211ffaaa137e
@@ -1,10 +1,7 @@ +#[cfg(test)] pub(crate) mod http; pub(crate) mod packetline; pub(crate) mod patch; -#[allow( - dead_code, - reason = "some protocol tests import Git without the public HTTP routes" -)] pub(crate) mod read; pub(crate) mod receive_pack; pub(crate) mod repository;
src/git/packetline.rs
Mode 100644 → 100644; object 280639a75589 → e11d0729cb68
@@ -28,6 +28,7 @@
output.extend_from_slice(b"0000");
}
+#[cfg(test)]
pub(crate) fn encode_delimiter(output: &mut Vec<u8>) {
output.extend_from_slice(b"0001");
}
src/git/read.rs
Mode 100644 → 100644; object 619287c199cb → 85bd77d6a38b
@@ -63,6 +63,7 @@
}
impl ReadCancellation {
+ #[cfg(test)]
pub(crate) fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
src/git/receive_pack.rs
Mode 100644 → 100644; object 0d565cc91cc3 → fa51f27a89c2
@@ -1048,8 +1048,6 @@
StaleRef,
#[error("receive-pack input is missing a pack")]
MissingPack,
- #[error("receive-pack input has an unexpected pack")]
- UnexpectedPack,
#[error("receive-pack exceeds the pack limit")]
PackLimit,
#[error("receive-pack exceeds the object limit")]
@@ -1093,7 +1091,6 @@
matches!(
self,
Self::MissingPack
- | Self::UnexpectedPack
| Self::PackLimit
| Self::ObjectLimit
| Self::Pack(_)
src/git/repository.rs
Mode 100644 → 100644; object dc6134ed2a40 → db6a73e3db9d
@@ -281,6 +281,7 @@
write_merge_commit(&self.repository, base, head, actor, created_at, message)
}
+ #[cfg(test)]
pub(crate) fn make_pack(
&self,
wants: &[ObjectId],
@@ -615,8 +616,6 @@
},
#[error("Git object count or decoded size exceeds the limit")]
ObjectLimit,
- #[error("generated Git pack exceeds the limit")]
- PackLimit,
#[error("Git pack generation was cancelled")]
Cancelled,
#[error("cannot generate Git pack: {0}")]
src/git/transport.rs
Mode 100644 → 100644; object d6e63d355631 → c33073614a58
@@ -39,15 +39,27 @@
})
}
+ #[cfg(test)]
pub(crate) fn new_with_pushes(
root: &Path,
database: &Path,
) -> Result<Self, RepositoryPathError> {
+ Self::new_with_pushes_and_gate(root, database, MaintenanceGate::default())
+ }
+
+ #[cfg(test)]
+ pub(crate) fn new_with_pushes_and_gate(
+ root: &Path,
+ database: &Path,
+ maintenance: MaintenanceGate,
+ ) -> Result<Self, RepositoryPathError> {
let mut repositories = Self::new(root)?;
repositories.push_database = Some(database.to_owned());
+ repositories.maintenance = maintenance;
Ok(repositories)
}
+ #[cfg(test)]
pub(crate) fn new_managed_public(
root: &Path,
repositories: impl IntoIterator<Item = (String, String, String)>,
@@ -67,13 +79,6 @@
Ok(service)
}
- pub(crate) fn new_managed_authorized(
- root: &Path,
- database: &Path,
- ) -> Result<Self, RepositoryPathError> {
- Self::new_managed_authorized_with_gate(root, database, MaintenanceGate::default())
- }
-
pub(crate) fn new_managed_authorized_with_gate(
root: &Path,
database: &Path,
@@ -86,6 +91,7 @@
Ok(service)
}
+ #[cfg(test)]
pub(crate) fn resolve(
&self,
owner: &str,
@@ -141,6 +147,7 @@
Ok(candidate)
}
+ #[cfg(test)]
pub(crate) fn resolve_ssh_command(
&self,
command: &[u8],
@@ -168,6 +175,7 @@
self.resolve(owner, repository)
}
+ #[cfg(test)]
pub(crate) fn resolve_ssh_service(
&self,
command: &[u8],
@@ -183,11 +191,7 @@
if command.starts_with(b"git-upload-pack ") {
let (owner, repository) = parse_ssh_repository(command, "git-upload-pack '")?;
let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Read)?;
- return Ok(GitSshService::Upload {
- path,
- owner,
- repository,
- });
+ return Ok(GitSshService::Upload { path });
}
let (owner, repository) = parse_ssh_repository(command, "git-receive-pack '")?;
let path = self.resolve_for(actor, &owner, &repository, RepositoryOperation::Write)?;
@@ -224,6 +228,10 @@
&self.root
}
+ pub(crate) fn maintenance_gate(&self) -> MaintenanceGate {
+ self.maintenance.clone()
+ }
+
pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.push_jobs.clone().acquire_owned().await
}
@@ -237,6 +245,7 @@
}
}
+#[cfg(test)]
fn valid_managed_id(value: &str) -> bool {
value.len() == 32
&& value
@@ -247,8 +256,6 @@
pub(crate) enum GitSshService {
Upload {
path: PathBuf,
- owner: String,
- repository: String,
},
Receive {
path: PathBuf,
@@ -314,6 +321,7 @@
},
#[error("repository resolves outside the repository root")]
OutsideRoot,
+ #[cfg(test)]
#[error("managed repository catalog is not valid")]
InvalidCatalog,
#[error("repository access is not authorized")]
src/http/metadata_search.rs
Mode 100644 → 100644; object 044fd88dda41 → e8f920e440b8
@@ -138,6 +138,10 @@
query: &'a str,
searched: bool,
rows_scanned: usize,
+ #[allow(
+ dead_code,
+ reason = "the Askama template reads this field through generated code"
+ )]
bytes_scanned: usize,
truncated: bool,
results: Vec<MetadataSearchResultView<'a>>,
src/http/mod.rs
Mode 100644 → 100644; object 4c631c013b60 → 17f41572479c
@@ -127,6 +127,7 @@
watches: Option<WatchService>,
readiness: Option<ListenerReadiness>,
secure_cookies: bool,
+ trusted_proxy: Option<IpAddr>,
}
#[derive(Clone)]
@@ -159,6 +160,7 @@
pub(crate) instance_dir: PathBuf,
pub(crate) http_clone_base: String,
pub(crate) ssh_clone_base: String,
+ pub(crate) trusted_proxy: Option<IpAddr>,
pub(crate) max_request_bytes: usize,
pub(crate) max_connections: usize,
}
@@ -183,12 +185,14 @@
}
pub(crate) struct RunningWebServer {
+ #[cfg(test)]
address: SocketAddr,
shutdown: oneshot::Sender<()>,
task: JoinHandle<std::io::Result<()>>,
}
impl RunningWebServer {
+ #[cfg(test)]
pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
Self::start_with_state(
address,
@@ -212,11 +216,13 @@
watches: None,
readiness: None,
secure_cookies: false,
+ trusted_proxy: None,
},
)
.await
}
+ #[cfg(test)]
pub(crate) async fn start_public(
address: SocketAddr,
config: PublicWebConfig,
@@ -257,6 +263,7 @@
let ssh_login_target = parse_ssh_login_target(&config.ssh_clone_base)?;
let database = config.instance_dir.join(crate::store::DATABASE_FILE);
let accounts = AccountService::new(database.clone());
+ let trusted_proxy = config.trusted_proxy;
let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
.map_err(WebError::CanonicalUrl)?;
let secure_cookies = public_url.scheme() == "https";
@@ -300,6 +307,7 @@
watches: Some(watches),
readiness,
secure_cookies,
+ trusted_proxy,
},
)
.await
@@ -307,6 +315,7 @@
async fn start_with_state(address: SocketAddr, state: WebState) -> Result<Self, WebError> {
let listener = TcpListener::bind(address).await?;
+ #[cfg(test)]
let address = listener.local_addr()?;
let (shutdown, receiver) = oneshot::channel();
let task = tokio::spawn(async move {
@@ -320,12 +329,14 @@
.await
});
Ok(Self {
+ #[cfg(test)]
address,
shutdown,
task,
})
}
+ #[cfg(test)]
pub(crate) fn address(&self) -> SocketAddr {
self.address
}
@@ -350,30 +361,6 @@
}
}
}
-}
-
-pub(crate) fn router() -> Router {
- router_with_state(WebState {
- public: None,
- accounts: None,
- jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
- requests: Arc::new(Semaphore::new(1024)),
- login_attempts: login_attempt_limiter(),
- account_attempts: login_attempt_limiter(),
- max_request_bytes: 1024 * 1024,
- telemetry: Telemetry::default(),
- key_reloader: None,
- login: None,
- ssh_login_target: None,
- repositories: None,
- issues: None,
- pull_requests: None,
- feeds: None,
- search: None,
- watches: None,
- readiness: None,
- secure_cookies: false,
- })
}
fn router_with_state(state: WebState) -> Router {
@@ -520,11 +507,12 @@
mut request: Request,
next: Next,
) -> Response {
- let address = request
+ let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|peer| peer.0.ip())
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
+ let address = client_address(peer, request.headers(), state.trusted_proxy);
request.extensions_mut().insert(ClientAddress(address));
let permit = match tokio::time::timeout(
CONCURRENCY_WAIT,
@@ -544,6 +532,20 @@
"Request time limit exceeded.\n",
),
}
+}
+
+fn client_address(peer: IpAddr, headers: &HeaderMap, trusted_proxy: Option<IpAddr>) -> IpAddr {
+ if trusted_proxy != Some(peer) {
+ return peer;
+ }
+ headers
+ .get_all("x-forwarded-for")
+ .iter()
+ .next_back()
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.rsplit(',').next())
+ .and_then(|value| value.trim().parse().ok())
+ .unwrap_or(peer)
}
fn limit_response(status: StatusCode, message: &'static str) -> Response {
@@ -2495,4 +2497,30 @@
Session(#[from] SessionError),
#[error("HTTP server task failed")]
Join,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_forwarded_addresses_only_from_the_configured_proxy() {
+ let proxy = IpAddr::V4(Ipv4Addr::LOCALHOST);
+ let client: IpAddr = "192.0.2.10".parse().expect("parse a client address");
+ let direct: IpAddr = "203.0.113.5".parse().expect("parse a direct address");
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ "x-forwarded-for",
+ HeaderValue::from_static("198.51.100.9, 192.0.2.10"),
+ );
+
+ assert_eq!(client_address(proxy, &headers, Some(proxy)), client);
+ assert_eq!(client_address(direct, &headers, Some(proxy)), direct);
+
+ headers.insert(
+ "x-forwarded-for",
+ HeaderValue::from_static("not-an-address"),
+ );
+ assert_eq!(client_address(proxy, &headers, Some(proxy)), proxy);
+ }
}
src/http/public.rs
Mode 100644 → 100644; object 2ad0c4f8cb0a → aca97529f24b
@@ -27,7 +27,7 @@
use crate::git::packetline::MAX_REQUEST_BYTES;
use crate::git::patch::write_patch;
use crate::git::read::{
- BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
+ BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits, RefInfo,
RepositoryReadService, SearchOutcome, TreeEntryInfo,
};
use crate::git::upload_pack::{ProtocolVersion, UploadPack};
@@ -1564,7 +1564,7 @@
page
}
- fn refs(record: RepositoryRecord, references: Vec<crate::git::read::RefInfo>) -> Self {
+ fn refs(record: RepositoryRecord, references: Vec<RefInfo>) -> Self {
let mut page = Self::base(record, "refs", "Refs".to_owned());
page.refs = references
.into_iter()
@@ -1697,7 +1697,7 @@
fn search(
record: RepositoryRecord,
- references: Vec<crate::git::read::RefInfo>,
+ references: Vec<RefInfo>,
selected: Option<(Vec<u8>, ObjectId)>,
query: String,
outcome: Option<SearchOutcome>,
@@ -1860,6 +1860,10 @@
struct BlameView {
start_line: u32,
source_start_line: u32,
+ #[allow(
+ dead_code,
+ reason = "the Askama template reads this field through generated code"
+ )]
line_count: u32,
current_end_line: u32,
source_end_line: u32,
@@ -1933,7 +1937,7 @@
}
fn select_search_ref(
- references: &[crate::git::read::RefInfo],
+ references: &[RefInfo],
requested: Option<&str>,
default_branch: &str,
) -> Result<Option<(Vec<u8>, ObjectId)>, RouteError> {
src/http/pull_requests.rs
Mode 100644 → 100644; object fb379512815a → d0df38dc0b8f
@@ -8,8 +8,10 @@
use serde::Deserialize;
use crate::codec::{decode_ascii_hex, encode_lower_hex};
+use crate::git::read::{Comparison, Mergeability};
+use crate::git::repository::GitRepositoryError;
use crate::markdown::{self, RenderedMarkdown};
-use crate::pull_request::PullRequestError;
+use crate::pull_request::{ActivityPages, NewPullRequest, PullRequestError, PullRequestReview};
use crate::store::StoreError;
use super::filters;
@@ -83,8 +85,10 @@
number,
Some(revision),
actor.0.as_deref(),
- 1,
- 1,
+ ActivityPages {
+ reviews: 1,
+ timeline: 1,
+ },
)
})
.await;
@@ -226,8 +230,10 @@
path.number,
query.revision,
actor.0.as_deref(),
- reviews_page,
- timeline_page,
+ ActivityPages {
+ reviews: reviews_page,
+ timeline: timeline_page,
+ },
)
})
.await;
@@ -460,18 +466,18 @@
let repository = path.repository.clone();
let number = path.number;
let result = job(state, move || {
- service.review(
- &owner,
- &repository,
+ service.review(&PullRequestReview {
+ owner: &owner,
+ repository: &repository,
number,
revision,
- &actor,
- &fields[2],
- &fields[3],
- path_bytes.as_deref(),
- side.as_deref(),
+ actor: &actor,
+ kind: &fields[2],
+ body: &fields[3],
+ path: path_bytes.as_deref(),
+ side: side.as_deref(),
line,
- )
+ })
})
.await;
match result {
@@ -506,15 +512,15 @@
let owner = path.owner.clone();
let repository = path.repository.clone();
let result = job(state, move || {
- service.open(
- &owner,
- &repository,
- &actor,
- &fields[1],
- &fields[2],
- &fields[3],
- &fields[4],
- )
+ service.open(&NewPullRequest {
+ owner: &owner,
+ repository: &repository,
+ actor: &actor,
+ title: &fields[1],
+ body: &fields[2],
+ base_ref: &fields[3],
+ head_ref: &fields[4],
+ })
})
.await;
match result {
@@ -623,9 +629,7 @@
| PullRequestError::MergeMethod
| PullRequestError::Store(StoreError::PullRequestRevisionNotFound)
| PullRequestError::Store(StoreError::PullRequestReviewAnchor)
- | PullRequestError::Git(crate::git::repository::GitRepositoryError::MissingReference(_)) => {
- bad_request(request_id)
- }
+ | PullRequestError::Git(GitRepositoryError::MissingReference(_)) => bad_request(request_id),
PullRequestError::StaleRevision | PullRequestError::Mergeability => render_error(
StatusCode::CONFLICT,
request_id,
@@ -822,10 +826,8 @@
hunks: String,
}
-impl From<&crate::git::read::Comparison> for ComparisonView {
- fn from(comparison: &crate::git::read::Comparison) -> Self {
- use crate::git::read::Mergeability;
-
+impl From<&Comparison> for ComparisonView {
+ fn from(comparison: &Comparison) -> Self {
Self {
merge_base: comparison
.merge_base
src/issue.rs
Mode 100644 → 100644; object b72a6637278b → 1c40e84e1294
@@ -6,7 +6,7 @@
use crate::domain::repository::{RepositoryNameError, validate_slug};
use crate::store::{
IssueChange, IssueDetail, IssueRecord, NewIssue, RecordPage, RepositoryRecord, Store,
- StoreError,
+ StoreError, TimelinePagination,
};
use crate::system::unix_timestamp;
@@ -100,9 +100,11 @@
repository,
number,
actor,
- comments_page,
- timeline_page,
- PAGE_SIZE,
+ TimelinePagination {
+ primary_page: comments_page,
+ timeline_page,
+ page_size: PAGE_SIZE,
+ },
)
.map_err(IssueError::from)?;
if (comments_page > 1 && detail.comments.is_empty())
src/lib.rs
Mode 100644 → 100644; object 8016bfbcf273 → 1ca09a26fb81
@@ -2,10 +2,6 @@ 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; @@ -17,15 +13,7 @@ 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; @@ -39,10 +27,6 @@ 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;
src/maintenance.rs
Mode 100644 → 100644; object c085ad9b1d03 → 2b0af9e21461
@@ -1,4 +1,4 @@
-use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
+use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
@@ -6,6 +6,7 @@
pub(crate) struct MaintenanceGate {
synchronous: Arc<RwLock<()>>,
asynchronous: Arc<AsyncRwLock<()>>,
+ git_operations: Arc<Mutex<()>>,
}
impl MaintenanceGate {
@@ -17,6 +18,12 @@
pub(crate) async fn mutation_async(&self) -> OwnedRwLockReadGuard<()> {
self.asynchronous.clone().read_owned().await
+ }
+
+ pub(crate) fn git_operation(&self) -> MutexGuard<'_, ()> {
+ self.git_operations
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn maintenance(&self) -> RwLockWriteGuard<'_, ()> {
src/policy.rs
Mode 100644 → 100644; object f82876ac41f3 → 4ecaabd93da7
@@ -69,28 +69,6 @@
RepositoryOperation::Maintain,
)
}
-
- #[allow(
- dead_code,
- reason = "policy tests verify anonymous catalog behavior independently"
- )]
- pub(crate) fn public_repositories(&self) -> Result<Vec<RepositoryRecord>, PolicyError> {
- Store::open(&self.database)?
- .active_repositories()?
- .into_iter()
- .filter_map(|repository| {
- let record = RepositoryAuthorizationRecord {
- repository,
- role: None,
- };
- match allows(&record, RepositoryOperation::Read) {
- Ok(true) => Some(Ok(record.repository)),
- Ok(false) => None,
- Err(error) => Some(Err(error)),
- }
- })
- .collect()
- }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -103,15 +81,10 @@
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[allow(
- dead_code,
- reason = "subsequent transports use the complete repository operation matrix"
-)]
pub(crate) enum RepositoryOperation {
Read,
Write,
Maintain,
- Own,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -140,7 +113,6 @@
role,
Some(RepositoryRole::Owner | RepositoryRole::Maintainer)
)),
- RepositoryOperation::Own => Ok(role == Some(RepositoryRole::Owner)),
}
}
src/pull_request.rs
Mode 100644 → 100644; object 301265d5cb5a → 32b52465894a
@@ -1,6 +1,5 @@
use std::fs;
use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex};
use gix::hash::ObjectId;
use thiserror::Error;
@@ -8,15 +7,16 @@
use crate::auth::{AuthError, validate_username};
use crate::domain::repository::{RepositoryNameError, validate_slug};
use crate::git::read::{
- Comparison, ReadCancellation, ReadError, ReadLimits, RepositoryReadService,
+ Comparison, Mergeability, ReadCancellation, ReadError, ReadLimits, RepositoryReadService,
};
+use crate::git::receive_pack::{ReceivePackError, recover_incomplete_pushes};
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::maintenance::MaintenanceGate;
use crate::policy::{PolicyError, RepositoryPolicy};
use crate::store::{
GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, NewPullRequestReview,
- PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord, PullRequestRevisionRecord,
- RecordPage, RepositoryRecord, Store, StoreError,
+ PullRequestDetail, PullRequestEdit, PullRequestRecord, PullRequestRefIntentRecord,
+ PullRequestRevisionRecord, RecordPage, RepositoryRecord, Store, StoreError, TimelinePagination,
};
use crate::system::{random_lower_hex, unix_timestamp};
@@ -29,7 +29,6 @@
pub(crate) struct PullRequestService {
database: PathBuf,
repositories: PathBuf,
- operations: Arc<Mutex<()>>,
maintenance: MaintenanceGate,
}
@@ -37,6 +36,35 @@
pub(crate) detail: PullRequestDetail,
pub(crate) revision: PullRequestRevisionRecord,
pub(crate) comparison: Comparison,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct ActivityPages {
+ pub(crate) reviews: usize,
+ pub(crate) timeline: usize,
+}
+
+pub(crate) struct NewPullRequest<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) repository: &'a str,
+ pub(crate) actor: &'a str,
+ pub(crate) title: &'a str,
+ pub(crate) body: &'a str,
+ pub(crate) base_ref: &'a str,
+ pub(crate) head_ref: &'a str,
+}
+
+pub(crate) struct PullRequestReview<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) repository: &'a str,
+ pub(crate) number: i64,
+ pub(crate) revision: i64,
+ pub(crate) actor: &'a str,
+ pub(crate) kind: &'a str,
+ pub(crate) body: &'a str,
+ pub(crate) path: Option<&'a [u8]>,
+ pub(crate) side: Option<&'a str>,
+ pub(crate) line: Option<i64>,
}
impl PullRequestService {
@@ -52,34 +80,27 @@
Self {
database: database.to_owned(),
repositories: repositories.to_owned(),
- operations: Arc::new(Mutex::new(())),
maintenance,
}
}
- #[allow(
- clippy::too_many_arguments,
- reason = "opening a pull request requires its repository, content, and two refs"
- )]
pub(crate) fn open(
&self,
- owner: &str,
- repository: &str,
- actor: &str,
- title: &str,
- body: &str,
- base_ref: &str,
- head_ref: &str,
+ request: &NewPullRequest<'_>,
) -> Result<PullRequestRecord, PullRequestError> {
+ let owner = request.owner;
+ let repository = request.repository;
+ let actor = request.actor;
+ let title = request.title;
+ let body = request.body;
+ let base_ref = request.base_ref;
+ let head_ref = request.head_ref;
validate_context(owner, repository, actor)?;
validate_content(title, body)?;
validate_branch(base_ref)?;
validate_branch(head_ref)?;
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let authorization = Store::open(&self.database)?.repository_authorization(
@@ -129,10 +150,7 @@
return Err(PullRequestError::Number);
}
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let current = Store::open(&self.database)?
.pull_request(owner, repository, number, Some(actor))?
@@ -181,10 +199,6 @@
.pull_request)
}
- #[allow(
- dead_code,
- reason = "the pull-request integration test imports this module without the Web list route"
- )]
pub(crate) fn list_page(
&self,
owner: &str,
@@ -202,10 +216,7 @@
return Err(PullRequestError::State);
}
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let result = Store::open(&self.database)?
.pull_request_page(owner, repository, actor, state, page, PAGE_SIZE)
@@ -216,10 +227,6 @@
Ok(result)
}
- #[allow(
- dead_code,
- reason = "the pull-request integration test imports this module without the Web edit route"
- )]
pub(crate) fn edit(
&self,
owner: &str,
@@ -235,14 +242,18 @@
}
validate_content(title, body)?;
Store::open(&self.database)?
- .edit_pull_request(owner, repository, number, actor, title, body, timestamp()?)
+ .edit_pull_request(&PullRequestEdit {
+ owner,
+ repository,
+ number,
+ actor,
+ title,
+ body,
+ changed_at: timestamp()?,
+ })
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "the pull-request integration test imports this module without the Web state route"
- )]
pub(crate) fn set_state(
&self,
owner: &str,
@@ -263,10 +274,7 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "integration tests and later non-Web callers read pull requests without comparison"
- )]
+ #[cfg(test)]
pub(crate) fn get(
&self,
owner: &str,
@@ -283,20 +291,14 @@
return Err(PullRequestError::Number);
}
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
Store::open(&self.database)?
.pull_request(owner, repository, number, actor)
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "the binary uses the paged Web comparison and integration tests use the full comparison"
- )]
+ #[cfg(test)]
pub(crate) fn compare(
&self,
owner: &str,
@@ -308,11 +310,6 @@
self.compare_with_activity(owner, repository, number, revision, actor, None)
}
- #[allow(
- clippy::too_many_arguments,
- dead_code,
- reason = "the pull-request integration test imports this module without the Web detail route"
- )]
pub(crate) fn compare_page(
&self,
owner: &str,
@@ -320,8 +317,7 @@
number: i64,
revision: Option<i64>,
actor: Option<&str>,
- reviews_page: usize,
- timeline_page: usize,
+ pages: ActivityPages,
) -> Result<PullRequestComparison, PullRequestError> {
self.compare_with_activity(
owner,
@@ -329,14 +325,10 @@
number,
revision,
actor,
- Some((reviews_page, timeline_page)),
+ Some((pages.reviews, pages.timeline)),
)
}
- #[allow(
- clippy::too_many_arguments,
- reason = "the paged comparison includes repository identity and independent activity pages"
- )]
fn compare_with_activity(
&self,
owner: &str,
@@ -360,10 +352,7 @@
return Err(PullRequestError::Number);
}
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let store = Store::open(&self.database)?;
let detail = match activity {
@@ -372,9 +361,11 @@
repository,
number,
actor,
- reviews_page,
- timeline_page,
- PAGE_SIZE,
+ TimelinePagination {
+ primary_page: reviews_page,
+ timeline_page,
+ page_size: PAGE_SIZE,
+ },
)?,
None => store.pull_request(owner, repository, number, actor)?,
};
@@ -407,33 +398,27 @@
})
}
- #[allow(
- clippy::too_many_arguments,
- reason = "a review action includes its repository, revision, content, and optional line anchor"
- )]
pub(crate) fn review(
&self,
- owner: &str,
- repository: &str,
- number: i64,
- revision_number: i64,
- actor: &str,
- kind: &str,
- body: &str,
- path: Option<&[u8]>,
- side: Option<&str>,
- line: Option<i64>,
+ review: &PullRequestReview<'_>,
) -> Result<String, PullRequestError> {
+ let owner = review.owner;
+ let repository = review.repository;
+ let number = review.number;
+ let revision_number = review.revision;
+ let actor = review.actor;
+ let kind = review.kind;
+ let body = review.body;
+ let path = review.path;
+ let side = review.side;
+ let line = review.line;
validate_context(owner, repository, actor)?;
if number < 1 || revision_number < 1 {
return Err(PullRequestError::Number);
}
validate_review(kind, body, path, side, line)?;
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let detail =
Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
@@ -514,10 +499,7 @@
return Err(PullRequestError::MergeMethod);
}
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _operation = self.maintenance.git_operation();
self.recover_inner()?;
let detail =
Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
@@ -549,8 +531,8 @@
detail.pull_request.head_ref, detail.pull_request.title
);
let new_target = match (method, comparison.mergeability) {
- ("fast-forward", crate::git::read::Mergeability::FastForward) => head,
- ("merge-commit", crate::git::read::Mergeability::Clean) => {
+ ("fast-forward", Mergeability::FastForward) => head,
+ ("merge-commit", Mergeability::Clean) => {
git.prepare_merge_commit(base, head, actor, created_at, &merge_message)?
}
_ => return Err(PullRequestError::Mergeability),
@@ -627,40 +609,10 @@
.pull_request)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile the service without the Web list route"
- )]
- pub(crate) fn list(
- &self,
- owner: &str,
- repository: &str,
- actor: Option<&str>,
- ) -> Result<(crate::store::RepositoryRecord, Vec<PullRequestRecord>, bool), PullRequestError>
- {
- validate_username(owner)?;
- validate_slug(repository)?;
- if let Some(actor) = actor {
- validate_username(actor)?;
- }
- let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- self.recover_inner()?;
- Store::open(&self.database)?
- .pull_requests(owner, repository, actor)
- .map_err(Into::into)
- }
-
pub(crate) fn recover(&self) -> Result<(), PullRequestError> {
let _maintenance = self.maintenance.mutation();
- let _operation = self
- .operations
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- crate::git::receive_pack::recover_incomplete_pushes(&self.database)?;
+ let _operation = self.maintenance.git_operation();
+ recover_incomplete_pushes(&self.database)?;
self.recover_inner()
}
@@ -855,10 +807,6 @@
#[error("pull-request number is not valid")]
Number,
#[error("pull-request list state is not valid")]
- #[allow(
- dead_code,
- reason = "the pull-request integration test imports this module without Web list state"
- )]
State,
#[error("pull-request revision does not exist")]
Revision,
@@ -891,7 +839,7 @@
#[error(transparent)]
Policy(#[from] PolicyError),
#[error(transparent)]
- ReceivePack(#[from] crate::git::receive_pack::ReceivePackError),
+ ReceivePack(#[from] ReceivePackError),
#[error("cannot create a random pull-request ID")]
Random,
#[error("the system clock is before the Unix epoch")]
src/repository.rs
Mode 100644 → 100644; object 9779eb1d02c1 → 22dc30248372
@@ -9,8 +9,9 @@
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::maintenance::MaintenanceGate;
use crate::store::{
- HomeRepositoryRecord, NewAuditEvent, NewDefaultBranchIntent, NewRepository, RepositoryOrigin,
- RepositoryRecord, RepositorySettings, Store, StoreError,
+ HomeRepositoryRecord, NewAuditEvent, NewDefaultBranchIntent, NewRepository,
+ RepositoryCollaboratorUpdate, RepositoryOrigin, RepositoryRecord, RepositorySettings,
+ RepositorySettingsUpdate, Store, StoreError,
};
use crate::system::{random_lower_hex, unix_timestamp};
@@ -103,6 +104,7 @@
validate_slug(repository)?;
validate_username(actor)?;
let _maintenance = self.maintenance.mutation();
+ let _operation = self.maintenance.git_operation();
let changed_at = timestamp()?;
let intent_id = random_id()?;
let mut store = Store::open(&self.database)?;
@@ -132,6 +134,7 @@
pub(crate) fn recover(&self) -> Result<(), RepositoryServiceError> {
let _maintenance = self.maintenance.mutation();
+ let _operation = self.maintenance.git_operation();
let mut store = Store::open(&self.database)?;
for intent in store.incomplete_repository_default_branches()? {
let git =
@@ -162,15 +165,15 @@
validate_username(actor)?;
validate_description(description)?;
let mut store = Store::open(&self.database)?;
- store.update_repository_settings(
+ store.update_repository_settings(&RepositorySettingsUpdate {
owner,
- repository,
+ slug: repository,
actor,
description,
visibility,
- timestamp()?,
- &random_id()?,
- )?;
+ changed_at: timestamp()?,
+ correlation_id: &random_id()?,
+ })?;
Ok(())
}
@@ -187,15 +190,15 @@
validate_username(actor)?;
validate_username(username)?;
let mut store = Store::open(&self.database)?;
- store.update_repository_collaborator(
+ store.update_repository_collaborator(&RepositoryCollaboratorUpdate {
owner,
- repository,
+ slug: repository,
actor,
username,
role,
- timestamp()?,
- &random_id()?,
- )?;
+ changed_at: timestamp()?,
+ correlation_id: &random_id()?,
+ })?;
Ok(())
}
@@ -286,13 +289,15 @@
let target = format!("{owner}/{slug}");
let result = self.create_inner(
&mut store,
- actor,
- owner,
- slug,
- object_format,
- object_format_name,
- correlation_id,
- created_at,
+ &RepositoryCreation {
+ actor,
+ owner,
+ slug,
+ object_format,
+ object_format_name,
+ correlation_id,
+ created_at,
+ },
);
if result.is_err() {
store.record_audit_event(&NewAuditEvent {
@@ -307,18 +312,18 @@
result
}
- #[allow(clippy::too_many_arguments)]
fn create_inner(
&self,
store: &mut Store,
- actor: &str,
- owner: &str,
- slug: &str,
- object_format: Kind,
- object_format_name: &str,
- correlation_id: &str,
- created_at: i64,
+ creation: &RepositoryCreation<'_>,
) -> Result<RepositoryRecord, RepositoryServiceError> {
+ let actor = creation.actor;
+ let owner = creation.owner;
+ let slug = creation.slug;
+ let object_format = creation.object_format;
+ let object_format_name = creation.object_format_name;
+ let correlation_id = creation.correlation_id;
+ let created_at = creation.created_at;
let id = random_id()?;
let pending_path = self.root.join(format!(".pending-{id}.git"));
let final_path = self.root.join(format!("{id}.git"));
@@ -378,6 +383,16 @@
archived_at: None,
})
}
+}
+
+struct RepositoryCreation<'a> {
+ actor: &'a str,
+ owner: &'a str,
+ slug: &'a str,
+ object_format: Kind,
+ object_format_name: &'a str,
+ correlation_id: &'a str,
+ created_at: i64,
}
fn random_id() -> Result<String, RepositoryServiceError> {
src/serve.rs
Mode 100644 → 100644; object 241a9c2a9b79 → b63c8c32c660
@@ -86,6 +86,7 @@
instance_dir: config.instance_dir.clone(),
http_clone_base,
ssh_clone_base,
+ trusted_proxy: config.http_trusted_proxy,
max_request_bytes: usize::try_from(config.max_request_bytes)
.map_err(|_| ServeError::RequestLimit)?,
max_connections: usize::try_from(config.max_connections)
src/session.rs
Mode 100644 → 100644; object 471c521c801f → 5427acfe2c43
@@ -82,10 +82,6 @@
Ok(IssuedLoginApproval { secret, login_csrf })
}
- #[allow(
- dead_code,
- reason = "the Web session integration test imports this module without account routes"
- )]
pub(crate) fn issue_account_approval(
&self,
username: &str,
@@ -233,10 +229,6 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile login without HTTP handlers"
- )]
pub(crate) fn record_login_failure(
&self,
username: &str,
@@ -323,7 +315,6 @@
Random,
#[error("system clock is before the Unix epoch")]
Clock,
- #[allow(dead_code, reason = "integration tests use the service without HTTP")]
#[error("Web login service is not available")]
Unavailable,
}
src/ssh.rs
Mode 100644 → 100644; object 8af371e0244a → 7de0ecf62401
@@ -5,6 +5,7 @@
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
+#[cfg(test)]
use rand::rng;
use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session};
use russh::{Channel, ChannelId, MethodKind, MethodSet, Preferred, Pty};
@@ -12,17 +13,17 @@
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
-use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot};
+use tokio::sync::{OwnedRwLockReadGuard, OwnedSemaphorePermit, Semaphore, oneshot};
use tokio::task::JoinHandle;
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::receive_pack::{ReceivePack, ReceivePackError, recover_incomplete_pushes};
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;
-use crate::pull_request::{PullRequestError, PullRequestService};
+use crate::pull_request::{NewPullRequest, PullRequestError, PullRequestService};
use crate::rate_limit::AttemptLimiter;
use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::store::{Store, StoreError};
@@ -101,9 +102,11 @@
}
pub(crate) struct RunningSshServer {
+ #[cfg(test)]
address: SocketAddr,
handle: russh::server::RunningServerHandle,
task: JoinHandle<std::io::Result<()>>,
+ #[cfg(test)]
audit: Arc<RequestAudit>,
}
@@ -119,6 +122,7 @@
}
impl AuthorizedSshKeys {
+ #[cfg(test)]
pub(crate) fn new(keys: &[SshPublicKey]) -> Self {
Self {
keys: Arc::new(RwLock::new(key_map(keys))),
@@ -146,6 +150,7 @@
}
}
+#[cfg(test)]
fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, SshIdentity> {
keys.iter()
.map(|key| {
@@ -176,6 +181,7 @@
}
impl RunningSshServer {
+ #[cfg(test)]
pub(crate) async fn start(
address: SocketAddr,
authorized_keys: &[SshPublicKey],
@@ -184,21 +190,13 @@
Self::start_inner(address, authorized_keys, &[], None, host_key).await
}
+ #[cfg(test)]
pub(crate) async fn start_with_git(
address: SocketAddr,
authorized_keys: &[SshPublicKey],
repositories: GitRepositories,
) -> Result<Self, SshServerError> {
let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
- Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
- }
-
- pub(crate) async fn start_with_git_and_host_key(
- address: SocketAddr,
- authorized_keys: &[SshPublicKey],
- repositories: GitRepositories,
- host_key: PrivateKey,
- ) -> Result<Self, SshServerError> {
Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
}
@@ -227,6 +225,7 @@
.await
}
+ #[cfg(test)]
pub(crate) async fn start_with_git_writes(
address: SocketAddr,
authorized_keys: &[SshPublicKey],
@@ -245,6 +244,7 @@
.await
}
+ #[cfg(test)]
async fn start_inner(
address: SocketAddr,
authorized_keys: &[SshPublicKey],
@@ -276,6 +276,7 @@
runtime: SshRuntime,
) -> Result<Self, SshServerError> {
let listener = TcpListener::bind(address).await?;
+ #[cfg(test)]
let address = listener.local_addr()?;
let mut methods = MethodSet::empty();
methods.push(MethodKind::PublicKey);
@@ -329,21 +330,26 @@
});
let handle = handle_receiver.await.map_err(|_| SshServerError::Startup)?;
Ok(Self {
+ #[cfg(test)]
address,
handle,
task,
+ #[cfg(test)]
audit,
})
}
+ #[cfg(test)]
pub(crate) fn address(&self) -> SocketAddr {
self.address
}
+ #[cfg(test)]
pub(crate) fn audit(&self) -> RequestAuditSnapshot {
self.audit.snapshot()
}
+ #[cfg(test)]
pub(crate) async fn shutdown(self) -> Result<(), SshServerError> {
self.handle.shutdown("tit test shutdown".to_owned());
self.task.await.map_err(|_| SshServerError::Join)??;
@@ -374,12 +380,10 @@
.push_database()
.ok_or_else(|| SshServerError::Recovery("push storage is not configured".to_owned()))?
.to_owned();
- tokio::task::spawn_blocking(move || {
- crate::git::receive_pack::recover_incomplete_pushes(&database)
- })
- .await
- .map_err(|_| SshServerError::Join)?
- .map_err(|error| SshServerError::Recovery(error.to_string()))
+ tokio::task::spawn_blocking(move || recover_incomplete_pushes(&database))
+ .await
+ .map_err(|_| SshServerError::Join)?
+ .map_err(|error| SshServerError::Recovery(error.to_string()))
}
#[derive(Debug, Error)]
@@ -499,7 +503,7 @@
}
struct ReceiveChannel {
- service: ReceivePack,
+ lease: ReceiveLease,
owner: String,
repository: String,
identity: SshIdentity,
@@ -513,6 +517,11 @@
last_activity: Instant,
transferred: u64,
_global_permit: OwnedSemaphorePermit,
+}
+
+struct ReceiveLease {
+ service: ReceivePack,
+ _maintenance: OwnedRwLockReadGuard<()>,
}
impl UploadChannel {
@@ -936,18 +945,23 @@
identity,
public_key,
global_permit,
+ maintenance,
} = *receive;
let started_at = Instant::now();
let advertisement_bytes =
u64::try_from(advertisement.len()).unwrap_or(u64::MAX);
session.data(channel, advertisement)?;
- let pack = tokio::fs::File::create(service.incoming_pack()).await;
+ let lease = ReceiveLease {
+ service: *service,
+ _maintenance: maintenance,
+ };
+ let pack = tokio::fs::File::create(lease.service.incoming_pack()).await;
match pack {
Ok(pack) => {
self.exec_channels.insert(
channel,
ExecChannel::Receive(Box::new(ReceiveChannel {
- service: *service,
+ lease,
owner,
repository,
identity,
@@ -1048,7 +1062,7 @@
if receive_data(&mut git, data).await.is_err() {
fail_git_channel(channel, session)?;
} else if git.commands_complete
- && matches!(git.service.expects_pack(&git.commands), Ok(false))
+ && matches!(git.lease.service.expects_pack(&git.commands), Ok(false))
{
send_receive_result(
channel,
@@ -1323,6 +1337,7 @@
.ok_or(RepositoryCommandError::Unavailable)?
.to_owned();
let root = repositories.repository_root().to_owned();
+ let maintenance = repositories.maintenance_gate();
let permit = repositories
.blocking_permit()
.await
@@ -1331,7 +1346,7 @@
let correlation_id = format!("{:032x}", rand::random::<u128>());
tokio::task::spawn_blocking(move || {
let _permit = permit;
- RepositoryService::new(&database, &root)
+ RepositoryService::new_with_gate(&database, &root, maintenance)
.create_for_account(
&actor,
&command.slug,
@@ -2146,13 +2161,14 @@
.ok_or(PullRequestCommandError::Unavailable)?
.to_owned();
let root = repositories.repository_root().to_owned();
+ let maintenance = repositories.maintenance_gate();
let permit = repositories
.blocking_permit()
.await
.map_err(|_| PullRequestCommandError::Unavailable)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
- PullRequestService::new(&database, &root)
+ PullRequestService::new_with_gate(&database, &root, maintenance)
.list_page(
&command.owner,
&command.repository,
@@ -2184,22 +2200,23 @@
.ok_or(PullRequestCommandError::Unavailable)?
.to_owned();
let root = repositories.repository_root().to_owned();
+ let maintenance = repositories.maintenance_gate();
let permit = repositories
.blocking_permit()
.await
.map_err(|_| PullRequestCommandError::Unavailable)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
- PullRequestService::new(&database, &root)
- .open(
- &command.owner,
- &command.repository,
- &command.actor,
- &title,
- &body,
- &command.base,
- &command.head,
- )
+ PullRequestService::new_with_gate(&database, &root, maintenance)
+ .open(&NewPullRequest {
+ owner: &command.owner,
+ repository: &command.repository,
+ actor: &command.actor,
+ title: &title,
+ body: &body,
+ base_ref: &command.base,
+ head_ref: &command.head,
+ })
.map(|pull_request| PullRequestMutationResult {
owner: command.owner,
repository: command.repository,
@@ -2225,13 +2242,14 @@
.ok_or(PullRequestCommandError::Unavailable)?
.to_owned();
let root = repositories.repository_root().to_owned();
+ let maintenance = repositories.maintenance_gate();
let permit = repositories
.blocking_permit()
.await
.map_err(|_| PullRequestCommandError::Unavailable)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
- PullRequestService::new(&database, &root)
+ PullRequestService::new_with_gate(&database, &root, maintenance)
.set_state(
&command.owner,
&command.repository,
@@ -2547,6 +2565,7 @@
return Ok(None);
};
let uses_policy = repositories.uses_policy();
+ let maintenance = repositories.mutation_permit().await;
let permit = repositories.blocking_permit().await?;
let service = tokio::task::spawn_blocking(move || {
let _permit = permit;
@@ -2571,6 +2590,7 @@
identity,
public_key,
global_permit,
+ maintenance,
},
)))
})
@@ -2621,6 +2641,7 @@
identity: SshIdentity,
public_key: PublicKey,
global_permit: OwnedSemaphorePermit,
+ maintenance: OwnedRwLockReadGuard<()>,
}
async fn receive_data(git: &mut ReceiveChannel, data: &[u8]) -> Result<(), ()> {
@@ -2654,7 +2675,7 @@
git: Box<ReceiveChannel>,
) -> ReceiveResult {
let ReceiveChannel {
- mut service,
+ mut lease,
owner,
repository,
identity,
@@ -2674,11 +2695,9 @@
let repositories = repositories?;
let push_permit = repositories.push_permit().await.ok()?;
let permit = repositories.blocking_permit().await.ok()?;
- let maintenance = repositories.mutation_permit().await;
tokio::task::spawn_blocking(move || {
let _permit = permit;
let _push_permit = push_permit;
- let _maintenance = maintenance;
if authorized_keys.identity(&public_key).as_ref() != Some(&identity)
|| !repositories.authorize(
&identity.username,
@@ -2689,14 +2708,14 @@
{
return None;
}
- Some(match service.finish(&commands) {
+ Some(match lease.service.finish(&commands) {
Ok(response) => Ok(response),
Err(error) => {
- let response = service.rejection_response(&commands, &error);
- match service.record_rejection() {
+ let response = lease.service.rejection_response(&commands, &error);
+ match lease.service.record_rejection() {
Ok(()) => Err((error, response)),
Err(audit_error) => {
- let response = service.rejection_response(&commands, &audit_error);
+ let response = lease.service.rejection_response(&commands, &audit_error);
Err((audit_error, response))
}
}
@@ -2818,12 +2837,12 @@
}
impl RequestAudit {
+ #[cfg(test)]
fn snapshot(&self) -> RequestAuditSnapshot {
RequestAuditSnapshot {
accepted_env: self.accepted_env.load(Ordering::Relaxed),
rejected_env: self.rejected_env.load(Ordering::Relaxed),
accepted_exec: self.accepted_exec.load(Ordering::Relaxed),
- rejected_exec: self.rejected_exec.load(Ordering::Relaxed),
rejected_shell: self.rejected_shell.load(Ordering::Relaxed),
rejected_pty: self.rejected_pty.load(Ordering::Relaxed),
rejected_agent: self.rejected_agent.load(Ordering::Relaxed),
@@ -2832,12 +2851,12 @@
}
}
+#[cfg(test)]
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct RequestAuditSnapshot {
pub(crate) accepted_env: usize,
pub(crate) rejected_env: usize,
pub(crate) accepted_exec: usize,
- pub(crate) rejected_exec: usize,
pub(crate) rejected_shell: usize,
pub(crate) rejected_pty: usize,
pub(crate) rejected_agent: usize,
src/store/error.rs
Mode → 100644; object → 3428ef2f98ad
@@ -1,0 +1,112 @@
+use std::path::PathBuf;
+
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+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,
+ },
+ #[error("database schema version {0} is newer than this executable")]
+ NewerSchema(i64),
+ #[error("database schema version is {actual}, expected {expected}")]
+ SchemaVersion { expected: i64, actual: i64 },
+ #[error("database integrity check failed: {0}")]
+ Integrity(String),
+ #[error("SQLite setting {name} is {actual}, expected {expected}")]
+ Setting {
+ name: &'static str,
+ expected: &'static str,
+ actual: String,
+ },
+ #[error("Git operation intent {0} is not in the required state")]
+ IntentState(String),
+ #[error("the instance already has an administrator")]
+ AlreadyInitialized,
+ #[error("account does not exist or is not active: {0}")]
+ AccountNotFound(String),
+ #[error("username is not available: {0}")]
+ UsernameUnavailable(String),
+ #[error("signup invitation is invalid, expired, or already used")]
+ InvalidInvitation,
+ #[error("recovery credential is invalid")]
+ InvalidRecovery,
+ #[error("SSH public key already exists")]
+ KeyExists,
+ #[error("active SSH public key does not exist")]
+ KeyNotFound,
+ #[error("an account must have at least one active SSH public key")]
+ LastKey,
+ #[error("login identity does not exist or is not active")]
+ LoginIdentity,
+ #[error("too many login challenges are active")]
+ LoginNonceLimit,
+ #[error("login challenge is invalid, expired, or already used")]
+ InvalidLoginChallenge,
+ #[error("SSH login approval is invalid, expired, or already used")]
+ InvalidLoginApproval,
+ #[error("SSH login approval is waiting for SSH authentication")]
+ LoginApprovalPending,
+ #[error("Web session is invalid or expired")]
+ InvalidSession,
+ #[error("repository does not exist: {0}/{1}")]
+ RepositoryNotFound(String, String),
+ #[error("repository already exists: {0}/{1}")]
+ RepositoryExists(String, String),
+ #[error("repository ID already exists")]
+ RepositoryIdentifierCollision,
+ #[error("repository is already archived: {0}/{1}")]
+ RepositoryArchived(String, String),
+ #[error("repository visibility is not valid")]
+ 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")]
+ OwnerCollaborator,
+ #[error("collaborator account does not exist or is not active: {0}")]
+ CollaboratorNotFound(String),
+ #[error("repository event page limit is too large")]
+ EventLimit,
+ #[error("stored Git reference event is malformed")]
+ EventPayload,
+ #[error("audit event page limit is too large")]
+ AuditLimit,
+ #[error("issue does not exist: {0}/{1}#{2}")]
+ IssueNotFound(String, String, i64),
+ #[error("issue access is not authorized")]
+ IssueDenied,
+ #[error("issue is hidden by repository access policy")]
+ IssueHidden,
+ #[error("issue state is already {0}")]
+ IssueState(String),
+ #[error("pull request does not exist: {0}/{1}#{2}")]
+ PullRequestNotFound(String, String, i64),
+ #[error("pull-request access is not authorized")]
+ PullRequestDenied,
+ #[error("pull request is hidden by repository access policy")]
+ PullRequestHidden,
+ #[error("pull request is not open")]
+ PullRequestState,
+ #[error("pull-request revision does not exist")]
+ PullRequestRevisionNotFound,
+ #[error("pull-request review anchor does not match its revision")]
+ PullRequestReviewAnchor,
+ #[error("pull-request ref intent {0} is not in the required state")]
+ PullRequestIntentState(String),
+ #[error("repository watch access is not authorized")]
+ WatchDenied,
+ #[error("feed token is invalid or revoked")]
+ FeedTokenNotFound,
+ #[error("an account cannot have more than one active feed token")]
+ FeedTokenLimit,
+ #[error("feed token scope is not valid")]
+ InvalidFeedScope,
+}
src/store/event.rs
Mode 100644 → 100644; object 66e8fed3be22 → 029ce2866935
@@ -115,21 +115,21 @@
}
}
-#[allow(
- clippy::too_many_arguments,
- reason = "the event payload stores the complete immutable review anchor"
-)]
+pub(super) struct PullRequestReview<'a> {
+ pub(super) pull_request_id: &'a str,
+ pub(super) number: i64,
+ pub(super) review_id: &'a str,
+ pub(super) revision: i64,
+ pub(super) body: &'a str,
+ pub(super) commit_object_id: Option<&'a str>,
+ pub(super) path: Option<&'a [u8]>,
+ pub(super) side: Option<&'a str>,
+ pub(super) line: Option<i64>,
+}
+
pub(super) fn pull_request_review(
kind: EventKind,
- pull_request_id: &str,
- number: i64,
- review_id: &str,
- revision: i64,
- body: &str,
- commit_object_id: Option<&str>,
- path: Option<&[u8]>,
- side: Option<&str>,
- line: Option<i64>,
+ review: &PullRequestReview<'_>,
) -> VersionedEvent {
debug_assert!(matches!(
kind,
@@ -142,35 +142,32 @@
kind,
payload: json!({
"version": PAYLOAD_VERSION,
- "pull_request_id": pull_request_id,
- "number": number,
- "review_id": review_id,
- "revision": revision,
- "body": body,
- "commit_object_id": commit_object_id,
- "path_hex": path.map(encode_lower_hex),
- "side": side,
- "line": line,
+ "pull_request_id": review.pull_request_id,
+ "number": review.number,
+ "review_id": review.review_id,
+ "revision": review.revision,
+ "body": review.body,
+ "commit_object_id": review.commit_object_id,
+ "path_hex": review.path.map(encode_lower_hex),
+ "side": review.side,
+ "line": review.line,
})
.to_string(),
}
}
-#[allow(
- clippy::too_many_arguments,
- reason = "the event payload stores the complete immutable pull-request revision"
-)]
-pub(super) fn pull_request(
- kind: EventKind,
- pull_request_id: &str,
- number: i64,
- revision: i64,
- title: &str,
- base_ref: &str,
- head_ref: &str,
- base_object_id: &str,
- head_object_id: &str,
-) -> VersionedEvent {
+pub(super) struct PullRequestRevision<'a> {
+ pub(super) pull_request_id: &'a str,
+ pub(super) number: i64,
+ pub(super) revision: i64,
+ pub(super) title: &'a str,
+ pub(super) base_ref: &'a str,
+ pub(super) head_ref: &'a str,
+ pub(super) base_object_id: &'a str,
+ pub(super) head_object_id: &'a str,
+}
+
+pub(super) fn pull_request(kind: EventKind, revision: &PullRequestRevision<'_>) -> VersionedEvent {
debug_assert!(matches!(
kind,
EventKind::PullRequestCreated | EventKind::PullRequestRevised
@@ -179,14 +176,14 @@
kind,
payload: json!({
"version": PAYLOAD_VERSION,
- "pull_request_id": pull_request_id,
- "number": number,
- "revision": revision,
- "title": title,
- "base_ref": base_ref,
- "head_ref": head_ref,
- "base_object_id": base_object_id,
- "head_object_id": head_object_id,
+ "pull_request_id": revision.pull_request_id,
+ "number": revision.number,
+ "revision": revision.revision,
+ "title": revision.title,
+ "base_ref": revision.base_ref,
+ "head_ref": revision.head_ref,
+ "base_object_id": revision.base_object_id,
+ "head_object_id": revision.head_object_id,
})
.to_string(),
}
src/store/mod.rs
Mode 100644 → 100644; object ef2edd91c364 → 40324df2ab4d
@@ -8,194 +8,31 @@
use rusqlite::types::ValueRef;
use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
use serde::Serialize;
-use thiserror::Error;
use crate::codec::encode_lower_hex;
+
+mod error;
mod event;
+mod schema;
+
+pub(crate) use error::StoreError;
+use schema::{MIGRATIONS, VERSION as SCHEMA_VERSION};
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 = 24;
-#[allow(
- dead_code,
- reason = "the integration test imports this module without the CLI operation"
-)]
pub(crate) const DATABASE_FILE: &str = "tit.sqlite3";
-#[allow(
- dead_code,
- reason = "some integration test crates import storage without migration operations"
-)]
-const MIGRATIONS: [&str; 24] = [
- include_str!("migrations/001_initial.sql"),
- include_str!("migrations/002_state.sql"),
- include_str!("migrations/003_git_intents.sql"),
- include_str!("migrations/004_identity.sql"),
- include_str!("migrations/005_repository.sql"),
- include_str!("migrations/006_repository_events.sql"),
- include_str!("migrations/007_account_lifecycle.sql"),
- include_str!("migrations/008_web_sessions.sql"),
- include_str!("migrations/009_repository_authorization.sql"),
- include_str!("migrations/010_audit_history.sql"),
- include_str!("migrations/011_domain_events.sql"),
- include_str!("migrations/012_issues.sql"),
- include_str!("migrations/013_watches.sql"),
- include_str!("migrations/014_feed_tokens.sql"),
- include_str!("migrations/015_pull_requests.sql"),
- include_str!("migrations/016_pull_request_reviews.sql"),
- include_str!("migrations/017_pull_request_merges.sql"),
- include_str!("migrations/018_streamlined_login.sql"),
- include_str!("migrations/019_product_reduction.sql"),
- include_str!("migrations/020_repository_profiles.sql"),
- 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(
- dead_code,
- reason = "integration tests compile storage without every account operation"
-)]
-#[derive(Debug, Error)]
-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 = "some integration test crates import storage without schema-version rejection"
- )]
- #[error("database schema version {0} is newer than this executable")]
- NewerSchema(i64),
- #[allow(
- dead_code,
- reason = "the integration test imports this module without the CLI operation"
- )]
- #[error("database schema version is {actual}, expected {expected}")]
- SchemaVersion { expected: i64, actual: i64 },
- #[error("database integrity check failed: {0}")]
- Integrity(String),
- #[error("SQLite setting {name} is {actual}, expected {expected}")]
- Setting {
- name: &'static str,
- expected: &'static str,
- actual: String,
- },
- #[error("Git operation intent {0} is not in the required state")]
- IntentState(String),
- #[error("the instance already has an administrator")]
- AlreadyInitialized,
- #[error("account does not exist or is not active: {0}")]
- AccountNotFound(String),
- #[error("username is not available: {0}")]
- UsernameUnavailable(String),
- #[error("signup invitation is invalid, expired, or already used")]
- InvalidInvitation,
- #[error("recovery credential is invalid")]
- InvalidRecovery,
- #[error("SSH public key already exists")]
- KeyExists,
- #[error("active SSH public key does not exist")]
- KeyNotFound,
- #[error("an account must have at least one active SSH public key")]
- LastKey,
- #[error("login identity does not exist or is not active")]
- LoginIdentity,
- #[error("too many login challenges are active")]
- LoginNonceLimit,
- #[error("login challenge is invalid, expired, or already used")]
- InvalidLoginChallenge,
- #[error("SSH login approval is invalid, expired, or already used")]
- InvalidLoginApproval,
- #[error("SSH login approval is waiting for SSH authentication")]
- LoginApprovalPending,
- #[error("Web session is invalid or expired")]
- InvalidSession,
- #[error("repository does not exist: {0}/{1}")]
- RepositoryNotFound(String, String),
- #[error("repository already exists: {0}/{1}")]
- RepositoryExists(String, String),
- #[error("repository ID already exists")]
- RepositoryIdentifierCollision,
- #[error("repository is already archived: {0}/{1}")]
- RepositoryArchived(String, String),
- #[error("repository visibility is not valid")]
- 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")]
- OwnerCollaborator,
- #[error("collaborator account does not exist or is not active: {0}")]
- CollaboratorNotFound(String),
- #[allow(
- dead_code,
- reason = "some integration tests import storage without public event pages"
- )]
- #[error("repository event page limit is too large")]
- EventLimit,
- #[error("stored Git reference event is malformed")]
- EventPayload,
- #[error("audit event page limit is too large")]
- AuditLimit,
- #[error("issue does not exist: {0}/{1}#{2}")]
- IssueNotFound(String, String, i64),
- #[error("issue access is not authorized")]
- IssueDenied,
- #[error("issue is hidden by repository access policy")]
- IssueHidden,
- #[error("issue state is already {0}")]
- IssueState(String),
- #[error("pull request does not exist: {0}/{1}#{2}")]
- PullRequestNotFound(String, String, i64),
- #[error("pull-request access is not authorized")]
- PullRequestDenied,
- #[error("pull request is hidden by repository access policy")]
- PullRequestHidden,
- #[error("pull request is not open")]
- PullRequestState,
- #[error("pull-request revision does not exist")]
- PullRequestRevisionNotFound,
- #[error("pull-request review anchor does not match its revision")]
- PullRequestReviewAnchor,
- #[error("pull-request ref intent {0} is not in the required state")]
- PullRequestIntentState(String),
- #[error("repository watch access is not authorized")]
- WatchDenied,
- #[error("feed token is invalid or revoked")]
- FeedTokenNotFound,
- #[error("an account cannot have more than one active feed token")]
- FeedTokenLimit,
- #[error("feed token scope is not valid")]
- InvalidFeedScope,
-}
pub(crate) struct Store {
connection: Connection,
}
impl Store {
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without audited services"
- )]
pub(crate) fn record_audit_event(&self, event: &NewAuditEvent<'_>) -> Result<(), StoreError> {
insert_audit_event(&self.connection, event)?;
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without the audit CLI"
- )]
pub(crate) fn audit_events(&self, limit: usize) -> Result<Vec<AuditEventRecord>, StoreError> {
if limit == 0 || limit > 1_000 {
return Err(StoreError::AuditLimit);
@@ -221,10 +58,6 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- 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)?;
let current = store.schema_version()?;
@@ -235,10 +68,6 @@
Ok(store)
}
- #[allow(
- dead_code,
- 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)?;
configure(&connection)?;
@@ -255,18 +84,10 @@
Ok(Self { connection })
}
- #[allow(
- dead_code,
- reason = "some integration test crates import storage without direct migration"
- )]
pub(crate) fn migrate(&mut self) -> Result<(), StoreError> {
self.migrate_with_hook(|_| {})
}
- #[allow(
- dead_code,
- reason = "some integration test crates import storage without migration hooks"
- )]
pub(crate) fn migrate_with_hook(
&mut self,
mut after_migration: impl FnMut(i64),
@@ -336,10 +157,6 @@
Ok(())
}
- #[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)?;
@@ -372,7 +189,6 @@
}
#[cfg(test)]
- #[allow(dead_code, reason = "integration storage tests use this test boundary")]
pub(crate) fn checkpoint(&self) -> Result<(), StoreError> {
self.connection
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
@@ -380,24 +196,17 @@
}
#[cfg(test)]
- #[allow(dead_code, reason = "integration storage tests use this test boundary")]
pub(crate) fn vacuum(&self) -> Result<(), StoreError> {
self.connection.execute_batch("VACUUM")?;
Ok(())
}
- #[allow(
- dead_code,
- reason = "integration storage tests use this database test boundary"
- )]
+ #[cfg(test)]
pub(crate) fn connection(&self) -> &Connection {
&self.connection
}
- #[allow(
- dead_code,
- reason = "integration storage tests use this mutable database test boundary"
- )]
+ #[cfg(test)]
pub(crate) fn connection_mut(&mut self) -> &mut Connection {
&mut self.connection
}
@@ -630,10 +439,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without receive-pack"
- )]
pub(crate) fn git_intent_completed(&self, id: &str) -> Result<bool, StoreError> {
Ok(self
.connection
@@ -714,10 +519,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn create_signup_invitation(
&self,
code_hash: &[u8; 32],
@@ -732,10 +533,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn create_account_with_invitation(
&mut self,
account: &InvitedAccount<'_>,
@@ -785,10 +582,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn recover_account(
&mut self,
recovery: &AccountRecovery<'_>,
@@ -858,10 +651,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn add_account_key(
&mut self,
username: &str,
@@ -892,10 +681,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn revoke_account_key(
&mut self,
username: &str,
@@ -1033,10 +818,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
- )]
pub(crate) fn suspend_account(
&mut self,
username: &str,
@@ -1084,10 +865,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
- )]
pub(crate) fn create_login_nonce(
&mut self,
nonce: &NewLoginNonce<'_>,
@@ -1130,10 +907,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
- )]
pub(crate) fn consume_login_nonce(
&mut self,
login: &NewWebSession<'_>,
@@ -1355,10 +1128,6 @@
Ok(username)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
- )]
pub(crate) fn web_session(
&self,
session_hash: &[u8; 32],
@@ -1367,8 +1136,7 @@
) -> Result<WebSessionRecord, StoreError> {
self.connection
.query_row(
- "SELECT account.username, account.is_administrator, web_session.expires_at,
- web_session.ssh_public_key_id
+ "SELECT account.username, account.is_administrator
FROM web_session
JOIN account ON account.id = web_session.account_id
WHERE web_session.session_hash = ?1 AND web_session.ended_at IS NULL
@@ -1379,8 +1147,6 @@
Ok(WebSessionRecord {
username: row.get(0)?,
is_administrator: row.get(1)?,
- expires_at: row.get(2)?,
- ssh_public_key_id: row.get(3)?,
})
},
)
@@ -1388,10 +1154,6 @@
.ok_or(StoreError::InvalidSession)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
- )]
pub(crate) fn end_account_sessions(
&mut self,
username: &str,
@@ -1614,10 +1376,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without admin commands"
- )]
pub(crate) fn set_repository_visibility(
&mut self,
owner: &str,
@@ -1658,10 +1416,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without admin commands"
- )]
pub(crate) fn set_repository_collaborator(
&mut self,
owner: &str,
@@ -1722,10 +1476,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without admin commands"
- )]
pub(crate) fn remove_repository_collaborator(
&mut self,
owner: &str,
@@ -2032,20 +1782,17 @@
.ok_or_else(|| StoreError::Integrity(format!("repository {repository_id} disappeared")))
}
- #[allow(
- clippy::too_many_arguments,
- reason = "an audited repository settings change includes repository identity and audit context"
- )]
pub(crate) fn update_repository_settings(
&mut self,
- owner: &str,
- slug: &str,
- actor: &str,
- description: &str,
- visibility: &str,
- changed_at: i64,
- correlation_id: &str,
+ update: &RepositorySettingsUpdate<'_>,
) -> Result<(), StoreError> {
+ let owner = update.owner;
+ let slug = update.slug;
+ let actor = update.actor;
+ let description = update.description;
+ let visibility = update.visibility;
+ let changed_at = update.changed_at;
+ let correlation_id = update.correlation_id;
if !matches!(visibility, "public" | "private") {
return Err(StoreError::InvalidRepositoryVisibility);
}
@@ -2081,20 +1828,17 @@
Ok(())
}
- #[allow(
- clippy::too_many_arguments,
- reason = "an audited collaborator change includes repository identity and audit context"
- )]
pub(crate) fn update_repository_collaborator(
&mut self,
- owner: &str,
- slug: &str,
- actor: &str,
- username: &str,
- role: Option<&str>,
- changed_at: i64,
- correlation_id: &str,
+ update: &RepositoryCollaboratorUpdate<'_>,
) -> Result<(), StoreError> {
+ let owner = update.owner;
+ let slug = update.slug;
+ let actor = update.actor;
+ let username = update.username;
+ let role = update.role;
+ let changed_at = update.changed_at;
+ let correlation_id = update.correlation_id;
if role.is_some_and(|role| !matches!(role, "maintainer" | "writer" | "reader")) {
return Err(StoreError::InvalidCollaboratorRole);
}
@@ -2281,10 +2025,6 @@
Ok(())
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without authorization"
- )]
pub(crate) fn repository_authorization(
&self,
owner: &str,
@@ -2444,12 +2184,14 @@
insert_pull_request_intent(
&transaction,
intent,
- &access.repository.id,
- actor_id,
- number,
- 1,
- None,
- "open",
+ &PullRequestIntentInsertion {
+ repository_id: &access.repository.id,
+ author_account_id: actor_id,
+ pull_request_number: number,
+ revision_number: 1,
+ old_head_object_id: None,
+ operation: "open",
+ },
)?;
transaction.commit()?;
Ok(PullRequestRefIntentRecord::from_new(
@@ -2531,12 +2273,14 @@
insert_pull_request_intent(
&transaction,
intent,
- &access.repository.id,
- actor_id,
- number,
- revision,
- Some(&old_head),
- "revise",
+ &PullRequestIntentInsertion {
+ repository_id: &access.repository.id,
+ author_account_id: actor_id,
+ pull_request_number: number,
+ revision_number: revision,
+ old_head_object_id: Some(&old_head),
+ operation: "revise",
+ },
)?;
transaction.commit()?;
Ok(PullRequestRefIntentRecord::from_new(
@@ -2619,14 +2363,16 @@
};
let event = event::pull_request(
kind,
- &intent.pull_request_id,
- intent.pull_request_number,
- intent.revision_number,
- &intent.title,
- &intent.base_ref,
- &intent.head_ref,
- &intent.base_object_id,
- &intent.head_object_id,
+ &event::PullRequestRevision {
+ pull_request_id: &intent.pull_request_id,
+ number: intent.pull_request_number,
+ revision: intent.revision_number,
+ title: &intent.title,
+ base_ref: &intent.base_ref,
+ head_ref: &intent.head_ref,
+ base_object_id: &intent.base_object_id,
+ head_object_id: &intent.head_object_id,
+ },
);
insert_domain_event(
&transaction,
@@ -2693,26 +2439,24 @@
self.pull_request_with_activity(owner, repository, number, actor, None)
}
- #[allow(
- clippy::too_many_arguments,
- reason = "the pull-request detail has independent review and timeline pages"
- )]
pub(crate) fn pull_request_detail_page(
&self,
owner: &str,
repository: &str,
number: i64,
actor: Option<&str>,
- reviews_page: usize,
- timeline_page: usize,
- page_size: usize,
+ pagination: TimelinePagination,
) -> Result<PullRequestDetail, StoreError> {
self.pull_request_with_activity(
owner,
repository,
number,
actor,
- Some((reviews_page, timeline_page, page_size)),
+ Some((
+ pagination.primary_page,
+ pagination.timeline_page,
+ pagination.page_size,
+ )),
)
}
@@ -2961,15 +2705,17 @@
};
let event = event::pull_request_review(
kind,
- &pull_request.0,
- review.number,
- &id,
- review.revision,
- review.body,
- review.commit_object_id,
- review.path,
- review.side,
- review.line,
+ &event::PullRequestReview {
+ pull_request_id: &pull_request.0,
+ number: review.number,
+ review_id: &id,
+ revision: review.revision,
+ body: review.body,
+ commit_object_id: review.commit_object_id,
+ path: review.path,
+ side: review.side,
+ line: review.line,
+ },
);
insert_pull_request_event(
&transaction,
@@ -2981,34 +2727,6 @@
)?;
transaction.commit()?;
Ok(id)
- }
-
- pub(crate) fn pull_requests(
- &self,
- owner: &str,
- repository: &str,
- actor: Option<&str>,
- ) -> Result<(RepositoryRecord, Vec<PullRequestRecord>, bool), StoreError> {
- let access = repository_issue_access(&self.connection, owner, repository, actor)?;
- if !access.can_read() {
- return Err(StoreError::PullRequestHidden);
- }
- let mut statement = self.connection.prepare(
- "SELECT pull_request.id, pull_request.number, pull_request.title,
- pull_request.body, pull_request.state, author.username,
- pull_request.base_ref, pull_request.head_ref,
- pull_request.base_object_id, pull_request.head_object_id,
- pull_request.created_at, pull_request.updated_at
- FROM pull_request
- JOIN account AS author ON author.id = pull_request.author_account_id
- WHERE pull_request.repository_id = ?1
- ORDER BY pull_request.number DESC",
- )?;
- let pull_requests = statement
- .query_map([&access.repository.id], pull_request_from_row)?
- .collect::<Result<Vec<_>, _>>()?;
- let can_create = access.can_write_repository();
- Ok((access.repository, pull_requests, can_create))
}
pub(crate) fn pull_request_page(
@@ -3064,20 +2782,17 @@
))
}
- #[allow(
- clippy::too_many_arguments,
- reason = "a pull-request edit includes repository identity, content, actor, and time"
- )]
pub(crate) fn edit_pull_request(
&mut self,
- owner: &str,
- repository: &str,
- number: i64,
- actor: &str,
- title: &str,
- body: &str,
- changed_at: i64,
+ edit: &PullRequestEdit<'_>,
) -> Result<(), StoreError> {
+ let owner = edit.owner;
+ let repository = edit.repository;
+ let number = edit.number;
+ let actor = edit.actor;
+ let title = edit.title;
+ let body = edit.body;
+ let changed_at = edit.changed_at;
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)?;
@@ -3392,20 +3107,19 @@
))
}
- #[allow(
- clippy::too_many_arguments,
- reason = "the issue detail has independent comment and timeline pages"
- )]
pub(crate) fn issue_detail(
&self,
owner: &str,
repository: &str,
number: i64,
actor: Option<&str>,
- comments_page: usize,
- timeline_page: usize,
- page_size: usize,
+ pagination: TimelinePagination,
) -> Result<IssueDetail, StoreError> {
+ let TimelinePagination {
+ primary_page: comments_page,
+ timeline_page,
+ page_size,
+ } = pagination;
let access = repository_issue_access(&self.connection, owner, repository, actor)?;
if !access.can_read() {
return Err(StoreError::IssueHidden);
@@ -3794,26 +3508,6 @@
Ok(false)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without authorization"
- )]
- pub(crate) fn active_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
- let mut statement = self.connection.prepare(
- "SELECT repository.id, account.username, repository.slug,
- repository.visibility, repository.state, repository.object_format,
- repository.created_at, repository.archived_at
- FROM repository
- JOIN account ON account.id = repository.owner_account_id
- WHERE repository.state = 'active'
- ORDER BY account.username, repository.slug",
- )?;
- statement
- .query_map([], repository_from_row)?
- .collect::<Result<Vec<_>, _>>()
- .map_err(Into::into)
- }
-
pub(crate) fn all_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
let mut statement = self.connection.prepare(
"SELECT repository.id, account.username, repository.slug,
@@ -3981,10 +3675,7 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "some integration tests import the store without public HTTP routes"
- )]
+ #[cfg(test)]
pub(crate) fn public_repository(
&self,
owner: &str,
@@ -4011,10 +3702,7 @@
}
}
- #[allow(
- dead_code,
- reason = "some integration tests import storage without the server"
- )]
+ #[cfg(test)]
pub(crate) fn active_ssh_public_keys(&self) -> Result<Vec<String>, StoreError> {
let mut statement = self.connection.prepare(
"SELECT ssh_public_key.canonical_key
@@ -4029,10 +3717,6 @@
.map_err(Into::into)
}
- #[allow(
- dead_code,
- reason = "some integration tests compile storage without the production SSH server"
- )]
pub(crate) fn active_ssh_identities(&self) -> Result<Vec<ActiveSshIdentity>, StoreError> {
let mut statement = self.connection.prepare(
"SELECT account.username, ssh_public_key.canonical_key,
@@ -4050,26 +3734,6 @@
fingerprint: row.get(2)?,
})
})?
- .collect::<Result<Vec<_>, _>>()
- .map_err(Into::into)
- }
-
- #[allow(
- dead_code,
- reason = "some integration tests import storage without the server"
- )]
- pub(crate) fn active_public_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
- let mut statement = self.connection.prepare(
- "SELECT repository.id, account.username, repository.slug,
- repository.visibility, repository.state, repository.object_format,
- repository.created_at, repository.archived_at
- FROM repository
- JOIN account ON account.id = repository.owner_account_id
- WHERE repository.visibility = 'public' AND repository.state = 'active'
- ORDER BY account.username, repository.slug",
- )?;
- statement
- .query_map([], repository_from_row)?
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
}
@@ -4236,10 +3900,7 @@
}
}
- #[allow(
- dead_code,
- reason = "some integration tests import storage without public event pages"
- )]
+ #[cfg(test)]
pub(crate) fn public_repository_events(
&self,
owner: &str,
@@ -4251,10 +3912,6 @@
self.repository_events_for(repository, before, limit, false)
}
- #[allow(
- dead_code,
- reason = "some integration tests use only public event queries"
- )]
pub(crate) fn repository_events(
&self,
owner: &str,
@@ -4325,20 +3982,12 @@
pub(crate) created_at: i64,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
-)]
pub(crate) struct NewSshKey<'a> {
pub(crate) canonical_key: &'a str,
pub(crate) fingerprint: &'a str,
pub(crate) label: &'a str,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
-)]
pub(crate) struct InvitedAccount<'a> {
pub(crate) invitation_hash: &'a [u8; 32],
pub(crate) username: &'a str,
@@ -4348,10 +3997,6 @@
pub(crate) correlation_id: &'a str,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
-)]
pub(crate) struct AccountRecovery<'a> {
pub(crate) username: &'a str,
pub(crate) old_recovery_hash: &'a [u8; 32],
@@ -4361,10 +4006,6 @@
pub(crate) correlation_id: &'a str,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
-)]
pub(crate) struct NewLoginNonce<'a> {
pub(crate) nonce_hash: &'a [u8; 32],
pub(crate) csrf_hash: &'a [u8; 32],
@@ -4408,10 +4049,6 @@
pub(crate) correlation_id: &'a str,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
-)]
pub(crate) struct NewWebSession<'a> {
pub(crate) nonce_hash: &'a [u8; 32],
pub(crate) login_csrf_hash: &'a [u8; 32],
@@ -4424,15 +4061,9 @@
pub(crate) correlation_id: &'a str,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without Web login"
-)]
pub(crate) struct WebSessionRecord {
pub(crate) username: String,
pub(crate) is_administrator: bool,
- pub(crate) expires_at: i64,
- pub(crate) ssh_public_key_id: Option<i64>,
}
pub(crate) struct NewRepository<'a> {
@@ -4459,10 +4090,13 @@
}
#[derive(Clone, Copy)]
-#[allow(
- dead_code,
- reason = "some integration tests create repositories without the import operation"
-)]
+pub(crate) struct TimelinePagination {
+ pub(crate) primary_page: usize,
+ pub(crate) timeline_page: usize,
+ pub(crate) page_size: usize,
+}
+
+#[derive(Clone, Copy)]
pub(crate) enum RepositoryOrigin {
Created,
Imported,
@@ -4519,9 +4153,29 @@
pub(crate) branches: Vec<String>,
}
+pub(crate) struct RepositorySettingsUpdate<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) slug: &'a str,
+ pub(crate) actor: &'a str,
+ pub(crate) description: &'a str,
+ pub(crate) visibility: &'a str,
+ pub(crate) changed_at: i64,
+ pub(crate) correlation_id: &'a str,
+}
+
pub(crate) struct RepositoryCollaboratorRecord {
pub(crate) username: String,
pub(crate) role: String,
+}
+
+pub(crate) struct RepositoryCollaboratorUpdate<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) slug: &'a str,
+ pub(crate) actor: &'a str,
+ pub(crate) username: &'a str,
+ pub(crate) role: Option<&'a str>,
+ pub(crate) changed_at: i64,
+ pub(crate) correlation_id: &'a str,
}
#[derive(Debug, Eq, PartialEq)]
@@ -4627,29 +4281,17 @@
primary_key: i64,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without authorization"
-)]
pub(crate) struct RepositoryAuthorizationRecord {
pub(crate) repository: RepositoryRecord,
pub(crate) role: Option<String>,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without the production SSH server"
-)]
pub(crate) struct ActiveSshIdentity {
pub(crate) username: String,
pub(crate) canonical_key: String,
pub(crate) fingerprint: String,
}
-#[allow(
- dead_code,
- reason = "some integration tests import storage without public event pages"
-)]
pub(crate) struct RepositoryEventRecord {
pub(crate) event_id: String,
pub(crate) sequence: i64,
@@ -4781,6 +4423,16 @@
pub(crate) can_merge: bool,
}
+pub(crate) struct PullRequestEdit<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) repository: &'a str,
+ pub(crate) number: i64,
+ pub(crate) actor: &'a str,
+ pub(crate) title: &'a str,
+ pub(crate) body: &'a str,
+ pub(crate) changed_at: i64,
+}
+
pub(crate) struct NewPullRequestReview<'a> {
pub(crate) owner: &'a str,
pub(crate) repository: &'a str,
@@ -4833,7 +4485,6 @@
}
impl PullRequestRefIntentRecord {
- #[allow(clippy::too_many_arguments)]
fn from_new(
intent: &NewPullRequestRefIntent<'_>,
repository_id: String,
@@ -4957,10 +4608,6 @@
pub(crate) created_at: i64,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without the audit CLI"
-)]
pub(crate) struct AuditEventRecord {
pub(crate) id: i64,
pub(crate) action: String,
@@ -4998,10 +4645,6 @@
pub(crate) changed_at: i64,
}
-#[allow(
- dead_code,
- reason = "some integration tests compile storage without accounts"
-)]
fn insert_ssh_key(
transaction: &rusqlite::Transaction<'_>,
account_id: i64,
@@ -5185,16 +4828,19 @@
}
}
-#[allow(clippy::too_many_arguments)]
-fn insert_pull_request_intent(
- transaction: &rusqlite::Transaction<'_>,
- intent: &NewPullRequestRefIntent<'_>,
- repository_id: &str,
+struct PullRequestIntentInsertion<'a> {
+ repository_id: &'a str,
author_account_id: i64,
pull_request_number: i64,
revision_number: i64,
- old_head_object_id: Option<&str>,
- operation: &str,
+ old_head_object_id: Option<&'a str>,
+ operation: &'a str,
+}
+
+fn insert_pull_request_intent(
+ transaction: &rusqlite::Transaction<'_>,
+ intent: &NewPullRequestRefIntent<'_>,
+ insertion: &PullRequestIntentInsertion<'_>,
) -> Result<(), StoreError> {
transaction.execute(
"INSERT INTO pull_request_ref_intent
@@ -5205,19 +4851,19 @@
?13, ?14, ?15, 'pending', ?16)",
rusqlite::params![
intent.id,
- repository_id,
+ insertion.repository_id,
intent.pull_request_id,
- pull_request_number,
- revision_number,
- operation,
+ insertion.pull_request_number,
+ insertion.revision_number,
+ insertion.operation,
intent.title,
intent.body,
- author_account_id,
+ insertion.author_account_id,
intent.actor,
intent.base_ref,
intent.head_ref,
intent.base_object_id,
- old_head_object_id,
+ insertion.old_head_object_id,
intent.head_object_id,
intent.created_at,
],
@@ -5971,10 +5617,6 @@
Ok(())
}
-#[allow(
- dead_code,
- reason = "the integration test imports this module without the CLI operation"
-)]
pub(crate) fn doctor(instance_dir: &Path) -> Result<(), StoreError> {
let path = instance_dir.join(DATABASE_FILE);
let store = Store::open_read_only(&path)?;
@@ -5988,10 +5630,6 @@
store.integrity_check()
}
-#[allow(
- dead_code,
- 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());
backup.push(format!(".v{version}.backup"));
src/store/schema.rs
Mode → 100644; object → 7a184eeb3fa3
@@ -1,0 +1,28 @@
+pub(super) const VERSION: i64 = 24;
+
+pub(super) const MIGRATIONS: [&str; 24] = [
+ include_str!("migrations/001_initial.sql"),
+ include_str!("migrations/002_state.sql"),
+ include_str!("migrations/003_git_intents.sql"),
+ include_str!("migrations/004_identity.sql"),
+ include_str!("migrations/005_repository.sql"),
+ include_str!("migrations/006_repository_events.sql"),
+ include_str!("migrations/007_account_lifecycle.sql"),
+ include_str!("migrations/008_web_sessions.sql"),
+ include_str!("migrations/009_repository_authorization.sql"),
+ include_str!("migrations/010_audit_history.sql"),
+ include_str!("migrations/011_domain_events.sql"),
+ include_str!("migrations/012_issues.sql"),
+ include_str!("migrations/013_watches.sql"),
+ include_str!("migrations/014_feed_tokens.sql"),
+ include_str!("migrations/015_pull_requests.sql"),
+ include_str!("migrations/016_pull_request_reviews.sql"),
+ include_str!("migrations/017_pull_request_merges.sql"),
+ include_str!("migrations/018_streamlined_login.sql"),
+ include_str!("migrations/019_product_reduction.sql"),
+ include_str!("migrations/020_repository_profiles.sql"),
+ 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"),
+];
src/system.rs
Mode 100644 → 100644; object e199cfe70f74 → 36d1999fa070
@@ -1,8 +1,3 @@
-#![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;
src/telemetry.rs
Mode 100644 → 100644; object 8eecb5d9eaf3 → a9ca117582c6
@@ -32,10 +32,6 @@
}
impl Telemetry {
- #[allow(
- dead_code,
- reason = "integration tests use disabled telemetry outside the production server"
- )]
pub(crate) fn enabled() -> Self {
Self {
counters: Arc::new(Counters::default()),
@@ -100,10 +96,6 @@
self.write_ssh_event("ssh.operation", operation_id, "started");
}
- #[allow(
- dead_code,
- reason = "some integration tests compile telemetry without process lifecycle"
- )]
pub(crate) fn lifecycle(&self, event: &'static str, outcome: &'static str) {
self.write_event(&Event {
timestamp_ms: timestamp_ms(),
tests/auth.rs
Mode 100644 → 100644; object 3e372612f2e6 → b1b5aadb40cd
@@ -3,15 +3,11 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
-use std::sync::{Arc, Barrier};
-use std::thread;
use auth::{
- AuthError, LoginChallenges, SshPublicKey, format_keyless_login_challenge,
- verify_keyless_login_challenge,
+ AuthError, SshPublicKey, format_keyless_login_challenge, verify_keyless_login_challenge,
};
use tempfile::TempDir;
-use url::Url;
const ISSUED_AT: u64 = 1_750_000_000;
const LIFETIME: u64 = 120;
@@ -42,6 +38,10 @@
fn rejects_unsupported_and_undersized_keys() {
let directory = TempDir::new().expect("create a key directory");
+ assert!(matches!(
+ SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
+ Err(AuthError::InputTooLarge("SSH public key"))
+ ));
assert!(matches!(
SshPublicKey::parse(DSA_PUBLIC_KEY),
Err(AuthError::UnsupportedKeyAlgorithm)
@@ -75,33 +75,6 @@
}
#[test]
-fn verifies_stock_sshsig_envelopes_for_each_supported_key() {
- let directory = TempDir::new().expect("create a key directory");
- for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
- let private_key = directory.path().join(fixture.name());
- generate_key(&private_key, fixture);
- let key = parse_public_key(&private_key).expect("parse the public key");
- let challenges = issuer("https://tit.example/");
- let challenge = challenges
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a login challenge");
- let signature = sign(
- &directory,
- fixture.name(),
- &private_key,
- "tit-auth",
- &challenge,
- );
-
- let verified = challenges
- .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
- .expect("verify the stock SSHSIG envelope");
- assert_eq!(verified.username, "alice");
- assert_eq!(verified.fingerprint, key.fingerprint());
- }
-}
-
-#[test]
fn derives_the_key_from_a_keyless_stock_sshsig_envelope() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("keyless");
@@ -129,261 +102,92 @@
}
#[test]
-fn rejects_replay_expiry_wrong_context_and_malformed_envelopes() {
+fn rejects_invalid_keyless_login_challenges() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
- let key = parse_public_key(&private_key).expect("parse the public key");
-
- let replay_issuer = issuer("https://tit.example/");
- let replay_challenge = replay_issuer
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a replay challenge");
- let replay_signature = sign(
- &directory,
- "replay",
- &private_key,
- "tit-auth",
- &replay_challenge,
+ let challenge = format_keyless_login_challenge(
+ "https://tit.example",
+ "alice",
+ &[7_u8; 32],
+ ISSUED_AT,
+ ISSUED_AT + LIFETIME,
);
- replay_issuer
- .verify(
- &replay_challenge,
- &replay_signature,
- "alice",
- &key,
- ISSUED_AT + 1,
- )
- .expect("verify the challenge one time");
- assert!(matches!(
- replay_issuer.verify(
- &replay_challenge,
- &replay_signature,
- "alice",
- &key,
- ISSUED_AT + 1
- ),
- Err(AuthError::ConsumedChallenge)
- ));
+ let signature = sign(&directory, "valid", &private_key, "tit-auth", &challenge);
- let expired_issuer = issuer("https://tit.example/");
- let expired_challenge = expired_issuer
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue an expiring challenge");
- let expired_signature = sign(
- &directory,
- "expired",
- &private_key,
- "tit-auth",
- &expired_challenge,
- );
assert!(matches!(
- expired_issuer.verify(
- &expired_challenge,
- &expired_signature,
+ verify_keyless_login_challenge(
+ "https://other.example",
+ &challenge,
+ &signature,
"alice",
- &key,
- ISSUED_AT + LIFETIME + 1
- ),
- Err(AuthError::ExpiredChallenge)
- ));
-
- let namespace_issuer = issuer("https://tit.example/");
- let namespace_challenge = namespace_issuer
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a namespace challenge");
- let wrong_namespace = sign(
- &directory,
- "namespace",
- &private_key,
- "other",
- &namespace_challenge,
- );
- assert!(matches!(
- namespace_issuer.verify(
- &namespace_challenge,
- &wrong_namespace,
- "alice",
- &key,
- ISSUED_AT + 1
- ),
- Err(AuthError::SignatureVerification(_))
- ));
-
- let origin_issuer = issuer("https://tit.example/");
- let origin_challenge = origin_issuer
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue an origin challenge");
- let origin_signature = sign(
- &directory,
- "origin",
- &private_key,
- "tit-auth",
- &origin_challenge,
- );
- assert!(matches!(
- issuer("https://other.example/").verify(
- &origin_challenge,
- &origin_signature,
- "alice",
- &key,
ISSUED_AT + 1
),
Err(AuthError::WrongOrigin)
));
-
- let malformed_issuer = issuer("https://tit.example/");
- let malformed_challenge = malformed_issuer
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a malformed-envelope challenge");
assert!(matches!(
- malformed_issuer.verify(
- &malformed_challenge,
+ verify_keyless_login_challenge(
+ "https://tit.example",
+ &challenge,
+ &signature,
+ "bob",
+ ISSUED_AT + 1,
+ ),
+ Err(AuthError::WrongUsername)
+ ));
+ assert!(matches!(
+ verify_keyless_login_challenge(
+ "https://tit.example",
+ &challenge,
+ &signature,
+ "alice",
+ ISSUED_AT + LIFETIME + 1,
+ ),
+ Err(AuthError::ExpiredChallenge)
+ ));
+
+ let wrong_namespace = sign(&directory, "namespace", &private_key, "other", &challenge);
+ assert!(matches!(
+ verify_keyless_login_challenge(
+ "https://tit.example",
+ &challenge,
+ &wrong_namespace,
+ "alice",
+ ISSUED_AT + 1,
+ ),
+ Err(AuthError::SignatureVerification(_))
+ ));
+ assert!(matches!(
+ verify_keyless_login_challenge(
+ "https://tit.example",
+ &challenge,
"not an SSHSIG envelope",
"alice",
- &key,
- ISSUED_AT + 1
+ ISSUED_AT + 1,
),
Err(AuthError::SignatureEnvelope(_))
));
-}
-
-#[test]
-fn rejects_wrong_key_username_and_rsa_keys() {
- let directory = TempDir::new().expect("create a key directory");
- let first_private = directory.path().join("first");
- let second_private = directory.path().join("second");
- generate_key(&first_private, KeyFixture::Ed25519);
- generate_key(&second_private, KeyFixture::Ed25519);
- let first_key = parse_public_key(&first_private).expect("parse the first public key");
- let second_key = parse_public_key(&second_private).expect("parse the second public key");
- let challenges = issuer("https://tit.example/");
- let challenge = challenges
- .issue("alice", &first_key, ISSUED_AT, LIFETIME)
- .expect("issue a key challenge");
- let signature = sign(
- &directory,
- "wrong-key",
- &first_private,
- "tit-auth",
- &challenge,
- );
assert!(matches!(
- challenges.verify(&challenge, &signature, "alice", &second_key, ISSUED_AT + 1),
- Err(AuthError::WrongKey)
- ));
- assert!(matches!(
- challenges.verify(&challenge, &signature, "bob", &first_key, ISSUED_AT + 1),
- Err(AuthError::WrongUsername)
- ));
-
- let rsa_private = directory.path().join("rsa");
- generate_key(&rsa_private, KeyFixture::Rsa3072);
- assert!(matches!(
- parse_public_key(&rsa_private),
- Err(AuthError::UnsupportedKeyAlgorithm)
- ));
-}
-
-#[test]
-fn consumes_a_nonce_atomically() {
- let directory = TempDir::new().expect("create a key directory");
- let private_key = directory.path().join("ed25519");
- generate_key(&private_key, KeyFixture::Ed25519);
- let key = parse_public_key(&private_key).expect("parse the public key");
- let challenges = Arc::new(issuer("https://tit.example/"));
- let challenge = challenges
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a concurrent challenge");
- let signature = sign(
- &directory,
- "concurrent",
- &private_key,
- "tit-auth",
- &challenge,
- );
- let barrier = Arc::new(Barrier::new(8));
-
- let workers: Vec<_> = (0..8)
- .map(|_| {
- let challenges = Arc::clone(&challenges);
- let barrier = Arc::clone(&barrier);
- let challenge = challenge.clone();
- let signature = signature.clone();
- let key = key.clone();
- thread::spawn(move || {
- barrier.wait();
- challenges
- .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
- .is_ok()
- })
- })
- .collect();
-
- let successes = workers
- .into_iter()
- .map(|worker| worker.join().expect("join a verifier"))
- .filter(|success| *success)
- .count();
- assert_eq!(successes, 1);
-}
-
-#[test]
-fn enforces_input_and_active_challenge_limits() {
- let directory = TempDir::new().expect("create a key directory");
- let private_key = directory.path().join("ed25519");
- generate_key(&private_key, KeyFixture::Ed25519);
- let key = parse_public_key(&private_key).expect("parse the public key");
-
- assert!(matches!(
- SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
- Err(AuthError::InputTooLarge("SSH public key"))
- ));
-
- let challenges = issuer("https://tit.example/");
- let challenge = challenges
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("issue a size-limit challenge");
- assert!(matches!(
- challenges.verify(
+ verify_keyless_login_challenge(
+ "https://tit.example",
&"x".repeat(4 * 1024 + 1),
"signature",
"alice",
- &key,
- ISSUED_AT + 1
+ ISSUED_AT + 1,
),
Err(AuthError::InputTooLarge("login challenge"))
));
assert!(matches!(
- challenges.verify(
+ verify_keyless_login_challenge(
+ "https://tit.example",
&challenge,
&"x".repeat(16 * 1024 + 1),
"alice",
- &key,
- ISSUED_AT + 1
+ ISSUED_AT + 1,
),
Err(AuthError::InputTooLarge("SSHSIG envelope"))
));
-
- let bounded = issuer("https://tit.example/");
- for _ in 0..1_024 {
- bounded
- .issue("alice", &key, ISSUED_AT, LIFETIME)
- .expect("fill the active challenge store");
- }
- assert!(matches!(
- bounded.issue("alice", &key, ISSUED_AT, LIFETIME),
- Err(AuthError::NonceLimit)
- ));
- bounded
- .issue("alice", &key, ISSUED_AT + LIFETIME + 1, LIFETIME)
- .expect("prune expired challenges before issuing another one");
-}
-
-fn issuer(origin: &str) -> LoginChallenges {
- LoginChallenges::new(&Url::parse(origin).expect("parse the test origin"))
- .expect("create a login challenge issuer")
}
fn parse_public_key(private_key: &Path) -> Result<SshPublicKey, AuthError> {
tests/cli.rs
Mode 100644 → 100644; object d3e31bbce67e → 06e20cb7e1ee
@@ -1,3 +1,4 @@
+mod ssh_fixture;
mod support;
use std::fs;
@@ -6,10 +7,8 @@
use std::process::{Command, Stdio};
use sha2::{Digest, Sha256};
-use support::{
- TestInstance, create_bare_git_fixture, create_ssh_key_fixture, free_address,
- read_stock_ssh_configuration,
-};
+use ssh_fixture::create_ssh_key as create_ssh_key_fixture;
+use support::{TestInstance, create_bare_git_fixture, free_address, read_stock_ssh_configuration};
use tempfile::TempDir;
const CURRENT_DATABASE: &str = concat!(
tests/git_push_ssh.rs
Mode 100644 → 100644; object 293f4bc0f399 → a7a20a6cfd08
@@ -1,10 +1,10 @@
-use crate::{auth, git, ssh, store};
+use crate::{auth, backup::OnlineBackupService, git, maintenance::MaintenanceGate, ssh, store};
use std::env;
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
-use std::process::{Child, Command, Output};
+use std::process::{Child, Command, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
@@ -12,6 +12,103 @@
use git::transport::GitRepositories;
use ssh::RunningSshServer;
use tempfile::TempDir;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn online_backup_waits_for_an_active_receive_pack() {
+ let directory = TempDir::new().expect("create a backup fixture directory");
+ let repositories_root = directory.path().join("repositories");
+ let bare = repositories_root.join("alice/example.git");
+ fs::create_dir_all(bare.parent().expect("a bare repository parent"))
+ .expect("create a repository owner directory");
+ run(Command::new("git")
+ .args(["init", "-q", "--bare", "--object-format", "sha1"])
+ .arg(&bare));
+
+ let private_key = directory.path().join("id_ed25519");
+ create_key(&private_key);
+ let key = parse_key(&private_key);
+ let database = directory.path().join(store::DATABASE_FILE);
+ store::Store::open(&database).expect("create the backup database");
+ let config = directory.path().join("config.toml");
+ fs::write(
+ &config,
+ b"version = 1\npublic_url = \"http://localhost:3000/\"\n",
+ )
+ .expect("write the backup configuration");
+ let gate = MaintenanceGate::default();
+ let repositories =
+ GitRepositories::new_with_pushes_and_gate(&repositories_root, &database, gate.clone())
+ .expect("open the repository root");
+ let server = RunningSshServer::start_with_git_writes(
+ SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+ std::slice::from_ref(&key),
+ std::slice::from_ref(&key),
+ repositories,
+ )
+ .await
+ .expect("start the SSH Git server");
+
+ let mut receive = Command::new("ssh")
+ .args([
+ "-F",
+ "/dev/null",
+ "-p",
+ &server.address().port().to_string(),
+ "-i",
+ ])
+ .arg(&private_key)
+ .args([
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "IdentitiesOnly=yes",
+ "-o",
+ "StrictHostKeyChecking=no",
+ "-o",
+ "UserKnownHostsFile=/dev/null",
+ "-o",
+ "LogLevel=ERROR",
+ "ignored@127.0.0.1",
+ "git-receive-pack '/alice/example'",
+ ])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("start receive-pack");
+ let quarantine = bare.join("objects/tit-quarantine");
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while fs::read_dir(&quarantine)
+ .map(|entries| entries.count() == 0)
+ .unwrap_or(true)
+ {
+ assert!(
+ receive.try_wait().expect("inspect receive-pack").is_none(),
+ "receive-pack stopped before it created quarantine"
+ );
+ assert!(Instant::now() < deadline, "receive-pack did not start");
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+
+ let backup_directory = TempDir::new().expect("create a backup output directory");
+ let output = backup_directory.path().join("instance.tar");
+ let backup = OnlineBackupService::new(directory.path().to_owned(), config, gate);
+ let backup_task = tokio::spawn({
+ let output = output.clone();
+ async move { backup.create(output).await }
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ assert!(!output.exists(), "backup did not wait for receive-pack");
+
+ receive.kill().expect("stop receive-pack");
+ receive.wait().expect("wait for receive-pack");
+ backup_task
+ .await
+ .expect("join the backup task")
+ .expect("create the online backup");
+ assert!(output.exists());
+ server.shutdown().await.expect("stop the SSH Git server");
+}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stock_git_creates_and_updates_branches_for_both_hash_formats_over_ssh() {
tests/public_routes.rs
Mode 100644 → 100644; object 2bc6f282dfac → c30f949702d1
@@ -1,759 +1,88 @@
-use crate::{git, http, store};
+use crate::{http, store};
use std::collections::BTreeMap;
use std::fs;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
-use std::path::{Path, PathBuf};
+use std::path::Path;
use std::process::Command;
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
use http::{PublicWebConfig, RunningWebServer};
use sha2::{Digest, Sha256};
use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
use tempfile::TempDir;
-#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn browses_and_clones_public_repositories_for_both_hash_formats() {
- for format in ["sha1", "sha256"] {
- let fixture = Fixture::new(format);
- let server = RunningWebServer::start_public(
- SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
- PublicWebConfig {
- instance_dir: fixture.instance.path().to_owned(),
- http_clone_base: "https://tit.example".to_owned(),
- ssh_clone_base: "ssh://tit.example:2222".to_owned(),
- max_request_bytes: 1024 * 1024,
- max_connections: 1024,
- },
- )
- .await
- .expect("start the public Web server");
+const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
- let home = request(server.address(), "GET", "/", &[], &[]);
- assert_eq!(home.status, 200);
- assert_html_policy(&home);
- let home_text = home.text();
- assert!(home_text.contains("<div class=\"two-column\">"));
- assert!(home_text.contains("<h1>A small Git CDE</h1>"));
- assert!(home_text.contains("aria-label=\"Get started\""));
- assert!(home_text.contains("<ul class=\"home-repository-list\">"));
- assert!(home_text.contains("href=\"/alice/example\""));
- assert!(home_text.contains(
- "<section class=\"home-workflow\" aria-labelledby=\"home-workflow-heading\">"
- ));
- assert!(home_text.contains("<code>repo create</code>"));
-
- let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
- assert_eq!(summary.status, 200);
- assert_html_policy(&summary);
- assert_repository_navigation(&summary, "alice", "example");
- let summary_text = summary.text();
- assert!(summary_text.contains(
- "<h1><a href=\"/alice\">alice</a>/<a href=\"/alice/example\">example</a></h1>"
- ));
- assert!(summary_text.contains(
- "<section class=\"repository-panel repository-file-panel\" aria-label=\"Files\">"
- ));
- assert!(!summary_text.contains("<h2 id=\"files-heading\">Files</h2>"));
- assert!(summary_text.contains("https://tit.example/alice/example"));
- assert!(summary_text.contains("ssh://tit.example:2222/alice/example"));
- assert!(summary_text.contains(&fixture.head));
- assert!(summary_text.contains("<div class=\"repository-summary-layout\">"));
- assert!(summary_text.contains(
- "</aside>\n </div>\n\n <section class=\"repository-panel repository-readme\""
- ));
- assert!(summary_text.contains("<strong>main</strong>"));
- assert!(summary_text.contains(&format!(
- "href=\"/alice/example/blob/{}/README.md\"",
- fixture.head
- )));
- assert!(summary_text.contains(&format!(
- "href=\"/alice/example/tree/{}/nested\"",
- fixture.head
- )));
- assert!(summary_text.contains("README.md"));
- assert!(summary_text.contains(">View all files</a>"));
- assert!(!summary_text.contains("summary-overflow-50.txt"));
- assert!(summary_text.contains("<h1>tit fixture</h1>"));
- assert!(summary_text.contains("<strong>safe</strong>"));
- assert!(summary_text.contains("<code><safe></code>"));
- assert!(summary_text.contains(&format!(
- "href=\"/alice/example/blob/{}/docs/guide.md\"",
- fixture.head
- )));
- assert!(summary_text.contains(&format!(
- "href=\"/alice/example/blob/{}/LICENSE\"",
- fixture.head
- )));
- assert!(!summary_text.contains("<script"));
- assert!(!summary_text.contains("javascript:"));
- assert!(!summary_text.contains("<img"));
- assert!(!summary_text.contains("tracker.example"));
- assert!(summary_text.contains("<script>alert(3)</script>"));
- assert!(!summary_text.contains("/alice/example/atom.xml"));
- assert!(summary_text.contains("/alice/example/rss.xml"));
- assert!(summary_text.contains("/alice/example/search"));
- assert!(summary_text.contains("/alice/example/commits\">View all commits</a>"));
- assert!(summary_text.contains(">v0.4</a>"));
- assert!(summary_text.contains(">v0.3</a>"));
- assert!(summary_text.contains(">v0.2</a>"));
- assert!(!summary_text.contains(">v0.1</a>"));
- assert_eq!(summary_text.matches("aria-label=\"Commit ").count(), 3);
- assert!(!summary_text.contains("Object format"));
-
- let commits = request(server.address(), "GET", "/alice/example/commits", &[], &[]);
- assert_eq!(commits.status, 200);
- assert_html_policy(&commits);
- assert_repository_navigation(&commits, "alice", "example");
- assert!(commits.text().contains("<h2>All commits</h2>"));
- assert_eq!(
- commits
- .text()
- .matches("<li><a href=\"/alice/example/commit/")
- .count(),
- 100
- );
- assert!(
- commits
- .text()
- .contains("/alice/example/commits?page=2\">Older commits</a>")
- );
- let older_commits = request(
- server.address(),
- "GET",
- "/alice/example/commits?page=2",
- &[],
- &[],
- );
- assert_eq!(older_commits.status, 200);
- assert_eq!(
- older_commits
- .text()
- .matches("<li><a href=\"/alice/example/commit/")
- .count(),
- 2
- );
- assert!(
- older_commits
- .text()
- .contains("/alice/example/commits?page=1\">Newer commits</a>")
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/commits?page=3",
- &[],
- &[],
- )
- .status,
- 400
- );
- {
- let path = "/alice/example/rss.xml";
- let content_type = "application/rss+xml; charset=utf-8";
- let feed = request(server.address(), "GET", path, &[], &[]);
- assert_eq!(feed.status, 200);
- assert_eq!(feed.header("content-type"), content_type);
- assert_eq!(feed.header("cache-control"), "public, max-age=60");
- assert!(!feed.header("etag").is_empty());
- assert!(!feed.header("last-modified").is_empty());
- let parsed = feed_rs::parser::parse(feed.body.as_slice()).expect("parse the feed");
- assert_eq!(parsed.entries.len(), 1);
- assert!(parsed.entries[0].id.starts_with("urn:tit:event:"));
- assert!(feed.text().contains("Repository imported"));
-
- let etag = feed.header("etag");
- let conditional = request(
- server.address(),
- "GET",
- path,
- &[("If-None-Match", etag)],
- &[],
- );
- assert_eq!(conditional.status, 304);
- assert!(conditional.body.is_empty());
-
- let modified = request(
- server.address(),
- "GET",
- path,
- &[("If-Modified-Since", feed.header("last-modified"))],
- &[],
- );
- assert_eq!(modified.status, 304);
- assert!(modified.body.is_empty());
-
- let head = request(server.address(), "HEAD", path, &[], &[]);
- assert_eq!(head.status, 200);
- assert!(head.body.is_empty());
- assert_eq!(head.header("etag"), feed.header("etag"));
- }
- assert_eq!(
- request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]).status,
- 404
- );
-
- let invalid_page = request(
- server.address(),
- "GET",
- "/alice/example/rss.xml?before=0",
- &[],
- &[],
- );
- assert_eq!(invalid_page.status, 400);
-
- let database = fixture.instance.path().join("tit.sqlite3");
- let feed_store = Store::open(&database).expect("open the feed database");
- for timestamp in 10..35 {
- let actor = if timestamp == 34 {
- "x</title><script>alert(5)</script>"
- } else {
- "alice"
- };
- feed_store
- .connection()
- .execute(
- "INSERT INTO repository_event
- (event_id, repository_id, sequence, kind, actor, payload_version,
- payload, created_at)
- VALUES (
- lower(hex(randomblob(16))),
- ?1,
- (SELECT COALESCE(MAX(sequence), 0) + 1
- FROM repository_event WHERE repository_id = ?1),
- 'push', ?2, 1, '{\"version\":1}', ?3
- )",
- rusqlite::params![fixture.repository_id, actor, timestamp],
- )
- .expect("insert a page fixture event");
- }
- drop(feed_store);
- let first_page = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
- let first_feed =
- feed_rs::parser::parse(first_page.body.as_slice()).expect("parse the first page");
- assert_eq!(first_feed.entries.len(), 20);
- assert!(!first_page.text().contains("<script>"));
- assert!(first_page.text().contains("x</title><script>"));
- let next = first_page
- .text()
- .split("rel=\"next\" href=\"")
- .nth(1)
- .and_then(|value| value.split('\"').next())
- .expect("find the next-page URL")
- .replace("https://tit.example", "");
- let second_page = request(server.address(), "GET", &next, &[], &[]);
- let second_feed =
- feed_rs::parser::parse(second_page.body.as_slice()).expect("parse the second page");
- assert_eq!(second_feed.entries.len(), 6);
- assert!(!second_page.text().contains("rel=\"next\""));
-
- let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
- assert_eq!(empty.status, 200);
- assert!(empty.text().contains("This repository has no commits."));
- let empty_search = request(server.address(), "GET", "/alice/empty/search", &[], &[]);
- assert_eq!(empty_search.status, 200);
- assert!(
- empty_search
- .text()
- .contains("This repository has no commits to search.")
- );
-
- let alias = request(server.address(), "GET", "/alice/example.git", &[], &[]);
- assert_eq!(alias.status, 308);
- assert_eq!(alias.header("location"), "/alice/example");
-
- let routes = [
- "/alice/example/refs".to_owned(),
- "/alice/example/commits".to_owned(),
- "/alice/example/search".to_owned(),
- format!("/alice/example/commit/{}", fixture.head),
- format!("/alice/example/tree/{}", fixture.head),
- format!("/alice/example/tree/{}/nested", fixture.head),
- format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
- format!("/alice/example/diff/{}/{}", fixture.parent, fixture.head),
- format!("/alice/example/blame/{}/nested/file.txt", fixture.head),
- ];
- for route in &routes {
- let response = request(server.address(), "GET", route, &[], &[]);
- assert_eq!(response.status, 200, "route failed: {route}");
- assert_html_policy(&response);
- assert!(!response.text().to_ascii_lowercase().contains("<script"));
-
- let head = request(server.address(), "HEAD", route, &[], &[]);
- assert_eq!(head.status, 200, "HEAD failed: {route}");
- assert!(head.body.is_empty());
- assert_eq!(
- head.header("content-length"),
- response.body.len().to_string()
- );
- }
- let patch = request(
- server.address(),
- "GET",
- &format!("/alice/example/commit/{}.patch", fixture.head),
- &[],
- &[],
- );
- assert_eq!(patch.status, 200);
- assert_eq!(patch.header("content-type"), "text/x-diff; charset=utf-8");
- assert!(patch.header("content-disposition").contains(".patch"));
- let patch_path = fixture.instance.path().join("commit.patch");
- fs::write(&patch_path, &patch.body).expect("write the downloaded patch");
- run(Command::new("git")
- .arg("-C")
- .arg(&fixture.worktree)
- .args(["checkout", "-q", "--detach", "HEAD^"]));
- run(Command::new("git")
- .arg("-C")
- .arg(&fixture.worktree)
- .args(["apply", "--check"])
- .arg(&patch_path));
-
- let search = request(
- server.address(),
- "GET",
- "/alice/example/search?q=second%20line&ref=HEAD",
- &[],
- &[],
- );
- assert_eq!(search.status, 200);
- assert_html_policy(&search);
- let search_text = search.text();
- assert!(search_text.contains("Found 1 matching lines."));
- assert!(search_text.contains("nested/file.txt:2"));
- assert!(search_text.contains(&format!(
- "/alice/example/blob/{}/nested/file.txt",
- fixture.head
- )));
- assert!(search_text.contains("<option value=\"HEAD\" selected>HEAD</option>"));
-
- let malformed = request(
- server.address(),
- "GET",
- "/alice/example/search?q=needle&ref=refs%2Fheads%2Fmain",
- &[],
- &[],
- );
- assert_eq!(malformed.status, 200);
- assert!(malformed.text().contains("malformed.txt:1"));
- assert!(malformed.text().contains("start � needle"));
-
- let escaped = request(
- server.address(),
- "GET",
- "/alice/example/search?q=%3Cscript%3E&ref=HEAD",
- &[],
- &[],
- );
- assert_eq!(escaped.status, 200);
- assert!(!escaped.text().contains("value=\"<script>\""));
- assert!(escaped.text().contains("value=\"<script>\""));
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/search?q=&ref=HEAD",
- &[],
- &[],
- )
- .status,
- 400
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/search?q=text&ref=refs%2Fheads%2Fmissing",
- &[],
- &[],
- )
- .status,
- 404
- );
-
- let tree = request(
- server.address(),
- "GET",
- &format!("/alice/example/tree/{}", fixture.head),
- &[],
- &[],
- );
- let tree_text = tree.text();
- assert!(tree_text.contains("README.md"));
- assert!(tree_text.contains("binary.dat"));
- assert!(tree_text.contains("non-å.txt"));
- assert!(tree_text.contains("non-%C3%A5.txt"));
- assert!(tree_text.contains("<img src=x onerror=alert(4)>.txt"));
- assert!(!tree_text.contains("<img"));
-
- let blob = request(
- server.address(),
- "GET",
- &format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
- &[],
- &[],
- );
- assert!(blob.text().contains("second line"));
- assert!(blob.text().contains(&format!(
- "/alice/example/raw/{}/nested/file.txt",
- fixture.head
- )));
-
- let binary = request(
- server.address(),
- "GET",
- &format!("/alice/example/blob/{}/binary.dat", fixture.head),
- &[],
- &[],
- );
- assert_eq!(binary.status, 200);
- assert!(binary.text().contains("Binary content cannot be shown."));
-
- let large = request(
- server.address(),
- "GET",
- &format!("/alice/example/blob/{}/large.txt", fixture.head),
- &[],
- &[],
- );
- assert_eq!(large.status, 200);
- assert!(large.body.len() > 2 * 1024 * 1024);
- assert!(large.text().contains("large content starts"));
- assert!(large.text().contains("large content ends"));
-
- let raw = request(
- server.address(),
- "GET",
- &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
- &[],
- &[],
- );
- assert_eq!(raw.status, 200);
- assert_eq!(raw.header("content-type"), "application/octet-stream");
- assert_eq!(raw.body, b"first line\nsecond line\n");
- assert_eq!(
- raw.header("cache-control"),
- "public, max-age=31536000, immutable"
- );
- let raw_head = request(
- server.address(),
- "HEAD",
- &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
- &[],
- &[],
- );
- assert_eq!(raw_head.status, 200);
- assert!(raw_head.body.is_empty());
- assert_eq!(
- raw_head.header("content-length"),
- raw.body.len().to_string()
- );
-
- let non_utf8 = request(
- server.address(),
- "GET",
- &format!("/alice/example/raw/{}/non-%C3%A5.txt", fixture.head),
- &[],
- &[],
- );
- assert_eq!(non_utf8.status, 200);
- assert_eq!(non_utf8.body, b"non-UTF-8 path\n");
- let missing_non_utf8 = request(
- server.address(),
- "GET",
- &format!("/alice/example/raw/{}/missing-%FF.txt", fixture.head),
- &[],
- &[],
- );
- assert_eq!(missing_non_utf8.status, 404);
- assert_html_policy(&missing_non_utf8);
-
- let archive = request(
- server.address(),
- "GET",
- &format!("/alice/example/archive/{}.tar", fixture.head),
- &[],
- &[],
- );
- assert_eq!(archive.status, 200);
- assert_eq!(archive.header("content-type"), "application/x-tar");
- assert!(archive.body.ends_with(&[0_u8; 1024]));
- let archive_head = request(
- server.address(),
- "HEAD",
- &format!("/alice/example/archive/{}.tar", fixture.head),
- &[],
- &[],
- );
- assert_eq!(archive_head.status, 200);
- assert!(archive_head.body.is_empty());
- let archive_path = fixture.instance.path().join(format!("{format}.tar"));
- fs::write(&archive_path, &archive.body).expect("write the public archive");
- let listed = Command::new("tar")
- .arg("-tf")
- .arg(&archive_path)
- .output()
- .expect("run the system tar reader");
- assert!(listed.status.success());
- let names = String::from_utf8_lossy(&listed.stdout);
- assert!(names.contains("README.md"));
- assert!(names.contains("nested/file.txt"));
-
- let discovery = request(
- server.address(),
- "GET",
- "/alice/example.git/info/refs?service=git-upload-pack",
- &[("Git-Protocol", "version=2")],
- &[],
- );
- assert_eq!(discovery.status, 200);
- assert_eq!(
- discovery.header("content-type"),
- "application/x-git-upload-pack-advertisement"
- );
- assert!(
- discovery
- .body
- .windows(b"version 2".len())
- .any(|window| window == b"version 2")
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/info/refs?service=git-receive-pack",
- &[],
- &[],
- )
- .status,
- 400
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/info/refs?service=git-upload-pack",
- &[("Git-Protocol", "version=3")],
- &[],
- )
- .status,
- 400
- );
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/git-upload-pack",
- &[("Content-Type", "text/plain")],
- b"0000",
- )
- .status,
- 415
- );
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/git-upload-pack",
- &[
- ("Content-Type", "application/x-git-upload-pack-request"),
- ("Git-Protocol", "version=2"),
- ],
- b"zzzz",
- )
- .status,
- 400
- );
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/git-upload-pack",
- &[("Content-Type", "application/x-git-upload-pack-request")],
- &vec![b'0'; git::packetline::MAX_REQUEST_BYTES + 1],
- )
- .status,
- 413
- );
-
- let clone = fixture.instance.path().join(format!("clone-{format}"));
- run(Command::new("git")
- .args(["-c", "protocol.version=2", "clone", "-q"])
- .arg(format!("http://{}/alice/example.git", server.address()))
- .arg(&clone));
- assert_eq!(rev_parse(&clone, "HEAD"), fixture.head);
- assert_eq!(
- fs::read(clone.join("nested/file.txt")).expect("read the cloned file"),
- b"first line\nsecond line\n"
- );
-
- let tag_directory = fixture
- .instance
- .path()
- .join("repositories")
- .join(format!("{}.git", fixture.repository_id))
- .join("refs/tags");
- fs::create_dir_all(&tag_directory).expect("create the tag directory");
- for index in 0..=128 {
- fs::write(
- tag_directory.join(format!("summary-limit-{index:03}")),
- format!("{}\n", fixture.head),
- )
- .expect("write a summary-limit tag");
- }
- let tag_limited_summary = request(server.address(), "GET", "/alice/example", &[], &[]);
- assert_eq!(tag_limited_summary.status, 200);
- assert!(
- tag_limited_summary
- .text()
- .contains("This repository has too many tags for the summary.")
- );
- assert!(!tag_limited_summary.text().contains(">v0.4</a>"));
-
- for route in [
- "/alice/missing",
- "/Alice/example",
- "/alice/example/commit/not-an-object",
- "/alice/example/tree/0000000000000000000000000000000000000000",
- "/alice/example/raw/0000000000000000000000000000000000000000/file",
- ] {
- let response = request(server.address(), "GET", route, &[], &[]);
- assert_eq!(response.status, 404, "route leaked or accepted: {route}");
- assert_html_policy(&response);
- }
-
- let database = fixture.instance.path().join(store::DATABASE_FILE);
- let hidden = Store::open(&database).expect("open the repository database");
- hidden
- .connection()
- .execute(
- "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
- [],
- )
- .expect("make the repository private");
- drop(hidden);
- assert_hidden(server.address(), &fixture.head);
-
- let archived = Store::open(&database).expect("reopen the repository database");
- archived
- .connection()
- .execute(
- "UPDATE repository
- SET visibility = 'public', state = 'archived', archived_at = 3
- WHERE slug = 'example'",
- [],
- )
- .expect("archive the repository");
- drop(archived);
- assert_hidden(server.address(), &fixture.head);
-
- server.shutdown().await.expect("stop the public Web server");
- }
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn runs_the_complete_issue_workflow_without_javascript() {
- let fixture = Fixture::new("sha1");
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn renders_and_mutates_a_public_issue_without_javascript() {
+ let fixture = Fixture::new();
let database = fixture.instance.path().join(store::DATABASE_FILE);
- let token = "11".repeat(32);
- let csrf = "22".repeat(32);
- let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
- let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
- let reader_token = "44".repeat(32);
- let reader_csrf = "55".repeat(32);
- let reader_session_hash: [u8; 32] = Sha256::digest(reader_token.as_bytes()).into();
- let reader_csrf_hash: [u8; 32] = Sha256::digest(reader_csrf.as_bytes()).into();
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("read current time")
- .as_secs() as i64;
- let store = Store::open(&database).expect("open the issue fixture database");
- store
- .connection()
- .execute(
- "INSERT INTO web_session
- (session_hash, csrf_hash, account_id, created_at, expires_at)
- SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
- rusqlite::params![session_hash, csrf_hash, now, now + 3600,],
- )
- .expect("create a Web session");
- store
- .connection()
- .execute(
- "INSERT INTO account (username, is_administrator, state, created_at)
- VALUES ('bob', 0, 'active', ?1)",
- [now],
- )
- .expect("create a reader account");
- store
- .connection()
- .execute(
- "INSERT INTO web_session
- (session_hash, csrf_hash, account_id, created_at, expires_at)
- SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'bob'",
- rusqlite::params![reader_session_hash, reader_csrf_hash, now, now + 3600],
- )
- .expect("create a reader Web session");
- store
- .connection()
- .execute(
- "INSERT INTO repository_collaborator
- (repository_id, account_id, role, created_at)
- SELECT repository.id, account.id, 'reader', ?1
- FROM repository, account
- WHERE repository.slug = 'example' AND account.username = 'bob'",
- [now],
- )
- .expect("create a reader collaborator");
- drop(store);
-
let server = RunningWebServer::start_public(
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
PublicWebConfig {
instance_dir: fixture.instance.path().to_owned(),
- http_clone_base: "http://127.0.0.1".to_owned(),
+ http_clone_base: "https://tit.example".to_owned(),
ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+ trusted_proxy: None,
max_request_bytes: 1024 * 1024,
- max_connections: 1024,
+ max_connections: 32,
},
)
.await
- .expect("start the issue Web server");
+ .expect("start the public Web server");
- let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
- assert_eq!(anonymous.status, 200);
- assert_repository_navigation(&anonymous, "alice", "example");
- assert!(anonymous.text().contains("This repository has no issues."));
+ let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
+ assert_eq!(summary.status, 200);
+ assert_html_policy(&summary);
assert!(
- anonymous
+ summary
.text()
- .contains("<section class=\"collection-panel\" aria-label=\"Issues\">")
+ .contains("alice</a>/<a href=\"/alice/example\"")
);
- 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
- );
+ assert!(summary.text().contains("<h1>tit fixture</h1>"));
+ assert!(!summary.text().to_ascii_lowercase().contains("<script"));
+ assert!(summary.text().contains("/alice/example/issues"));
+ let anonymous_issues = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
+ assert_eq!(anonymous_issues.status, 200);
+ assert!(
+ anonymous_issues
+ .text()
+ .contains("This repository has no issues.")
+ );
+ assert!(!anonymous_issues.text().contains("Create an issue</h2>"));
+
+ let token = "11".repeat(32);
+ let csrf = "22".repeat(32);
+ create_session(&database, &token, &csrf);
let cookie = format!("tit-session={token}; tit-csrf={csrf}");
let headers = [
("Content-Type", "application/x-www-form-urlencoded"),
("Cookie", cookie.as_str()),
];
- let create = form(&[
+
+ let rejected = form(&[
+ ("csrf", &"33".repeat(32)),
+ ("title", "Rejected issue"),
+ ("body", "This must not be stored."),
+ ]);
+ assert_eq!(
+ request(
+ server.address(),
+ "POST",
+ "/alice/example/issues",
+ &headers,
+ rejected.as_bytes(),
+ )
+ .status,
+ 403
+ );
+
+ let issue = form(&[
("csrf", &csrf),
- ("title", "Unsafe rendering check"),
+ ("title", "No JavaScript workflow"),
("body", "**safe**\n\n<script>alert(1)</script>"),
]);
let created = request(
@@ -761,21 +90,10 @@
"POST",
"/alice/example/issues",
&headers,
- create.as_bytes(),
+ issue.as_bytes(),
);
assert_eq!(created.status, 303);
assert_eq!(created.header("location"), "/alice/example/issues/1");
-
- let issue_index = request(
- server.address(),
- "GET",
- "/alice/example/issues",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- let issue_index_text = issue_index.text();
- assert!(issue_index_text.contains("<ol class=\"issue-list\">"));
- assert!(issue_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
let detail = request(
server.address(),
@@ -785,721 +103,18 @@
&[],
);
assert_eq!(detail.status, 200);
+ assert!(detail.text().contains("#1 No JavaScript workflow"));
assert!(detail.text().contains("<strong>safe</strong>"));
- 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
- );
+ assert!(!detail.text().to_ascii_lowercase().contains("<script"));
- let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/issues/1/state",
- &headers,
- bad_csrf.as_bytes(),
- )
- .status,
- 403
- );
- for (path, fields) in [
- (
- "/alice/example/issues/1/comments",
- vec![
- ("csrf", csrf.as_str()),
- ("body", "A **comment** for @alice."),
- ],
- ),
- (
- "/alice/example/issues/1/edit",
- vec![
- ("csrf", csrf.as_str()),
- ("title", "Edited issue"),
- ("body", "Preserved _Markdown_."),
- ],
- ),
- (
- "/alice/example/issues/1/state",
- vec![("csrf", csrf.as_str()), ("state", "closed")],
- ),
- (
- "/alice/example/issues/1/state",
- vec![("csrf", csrf.as_str()), ("state", "open")],
- ),
- ] {
- let response = request(
- server.address(),
- "POST",
- path,
- &headers,
- form(&fields).as_bytes(),
- );
- assert_eq!(response.status, 303, "issue mutation failed at {path}");
- }
-
- let final_page = request(
- server.address(),
- "GET",
- "/alice/example/issues/1",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- let final_text = final_page.text();
- assert_eq!(final_page.status, 200);
- assert!(final_text.contains("#1 Edited issue"));
- assert!(final_text.contains("<em>Markdown</em>"));
- assert!(final_text.contains("<strong>comment</strong>"));
- assert!(final_text.contains("<article class=\"comment-card\""));
- assert!(!final_text.contains("Labels:"));
- assert!(!final_text.contains("Assignees:"));
- assert!(final_text.contains("created the issue"));
- assert!(final_text.contains("reopened the issue"));
-
- 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
- .text()
- .contains("This repository has no pull requests.")
- );
- assert!(
- !anonymous_pull_requests
- .text()
- .contains("Open a pull request</h2>")
- );
- let open_pull_request = form(&[
- ("csrf", csrf.as_str()),
- ("title", "Review the feature"),
- ("body", "Keep **each revision**."),
- ("base-ref", "refs/heads/main"),
- ("head-ref", "refs/heads/feature"),
- ]);
- let opened_pull_request = request(
- server.address(),
- "POST",
- "/alice/example/pulls",
- &headers,
- open_pull_request.as_bytes(),
- );
- assert_eq!(opened_pull_request.status, 303);
- assert_eq!(
- opened_pull_request.header("location"),
- "/alice/example/pulls/1"
- );
- let pull_request_index = request(
- server.address(),
- "GET",
- "/alice/example/pulls",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- let pull_request_index_text = pull_request_index.text();
- assert!(pull_request_index_text.contains("<ol class=\"issue-list\">"));
- assert!(pull_request_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
- let pull_request_page = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
- assert_eq!(pull_request_page.status, 200);
- assert!(pull_request_page.text().contains("#1 Review the feature"));
- assert!(
- pull_request_page
- .text()
- .contains("<strong>each revision</strong>")
- );
- assert!(
- pull_request_page
- .text()
- .contains("git fetch origin refs/pull/1/head")
- );
- assert!(
- pull_request_page
- .text()
- .contains("Comparison for revision 1")
- );
- assert!(
- pull_request_page
- .text()
- .contains("Mergeability: already merged")
- );
- let worktree = fixture.instance.path().join("worktree");
- run(Command::new("git")
- .arg("-C")
- .arg(&worktree)
- .args(["switch", "-q", "feature"]));
- fs::write(worktree.join("pull-request.txt"), b"new revision\n")
- .expect("write a pull-request revision");
- commit_all(&worktree, "pull-request revision");
- let bare = fixture
- .instance
- .path()
- .join("repositories")
- .join(format!("{}.git", fixture.repository_id));
- run(Command::new("git")
- .arg("-C")
- .arg(&worktree)
- .args(["push", "-q"])
- .arg(&bare)
- .arg("feature"));
- let revision = form(&[("csrf", csrf.as_str())]);
- let revised_pull_request = request(
- server.address(),
- "POST",
- "/alice/example/pulls/1/revisions",
- &headers,
- revision.as_bytes(),
- );
- assert_eq!(revised_pull_request.status, 303);
- let revised_pull_request_page =
- request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
- assert_eq!(
- revised_pull_request_page
- .text()
- .matches("recorded <code title=")
- .count(),
- 2
- );
- assert!(
- revised_pull_request_page
- .text()
- .contains("Comparison for revision 2")
- );
- assert!(
- revised_pull_request_page
- .text()
- .contains("pull-request.txt")
- );
- let first_revision = request(
- server.address(),
- "GET",
- "/alice/example/pulls/1?revision=1",
- &[],
- &[],
- );
- assert_eq!(first_revision.status, 200);
- assert!(first_revision.text().contains("Comparison for revision 1"));
- assert!(!first_revision.text().contains("pull-request.txt"));
- let first_revision_patch = request(
- server.address(),
- "GET",
- "/alice/example/pulls/1/revisions/1.patch",
- &[],
- &[],
- );
- assert_eq!(first_revision_patch.status, 200);
- assert_eq!(
- first_revision_patch.header("content-type"),
- "text/x-diff; charset=utf-8"
- );
- let immutable_revision_patch = first_revision_patch.body.clone();
- for fields in [
- vec![
- ("csrf", csrf.as_str()),
- ("revision", "2"),
- ("kind", "comment"),
- ("body", "A **general review**."),
- ("path-hex", ""),
- ("side", ""),
- ("line", ""),
- ],
- vec![
- ("csrf", csrf.as_str()),
- ("revision", "2"),
- ("kind", "approved"),
- ("body", ""),
- ("path-hex", ""),
- ("side", ""),
- ("line", ""),
- ],
- vec![
- ("csrf", csrf.as_str()),
- ("revision", "2"),
- ("kind", "line-comment"),
- ("body", "Review this line."),
- ("path-hex", "70756c6c2d726571756573742e747874"),
- ("side", "head"),
- ("line", "1"),
- ],
- ] {
- let response = request(
- server.address(),
- "POST",
- "/alice/example/pulls/1/reviews",
- &headers,
- form(&fields).as_bytes(),
- );
- assert_eq!(response.status, 303);
- }
- let reviewed = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
- assert!(reviewed.text().contains("<strong>general review</strong>"));
- assert!(reviewed.text().contains("<article class=\"comment-card\""));
- assert!(reviewed.text().contains("approved the pull request"));
- assert!(reviewed.text().contains("head line 1"));
- assert!(!reviewed.text().contains("<strong>Outdated</strong>"));
-
- fs::write(worktree.join("pull-request.txt"), b"later revision\n")
- .expect("write a later pull-request revision");
- commit_all(&worktree, "later pull-request revision");
- run(Command::new("git")
- .arg("-C")
- .arg(&worktree)
- .args(["push", "-q"])
- .arg(&bare)
- .arg("feature"));
- let revised_again = request(
- server.address(),
- "POST",
- "/alice/example/pulls/1/revisions",
- &headers,
- revision.as_bytes(),
- );
- assert_eq!(revised_again.status, 303);
- let outdated = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
- let first_revision_patch_after_revisions = request(
- server.address(),
- "GET",
- "/alice/example/pulls/1/revisions/1.patch",
- &[],
- &[],
- );
- assert_eq!(
- first_revision_patch_after_revisions.body,
- immutable_revision_patch
- );
- assert!(outdated.text().contains("<strong>Outdated</strong>"));
- let merge_page = request(
- server.address(),
- "GET",
- "/alice/example/pulls/1",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert!(merge_page.text().contains("Fast-forward refs/heads/main"));
- let cancelled_merge = request(
- server.address(),
- "POST",
- "/alice/example/pulls/1/merge",
- &headers,
- form(&[
- ("csrf", csrf.as_str()),
- ("method", "fast-forward"),
- ("confirm", "no"),
- ])
- .as_bytes(),
- );
- assert_eq!(cancelled_merge.status, 303);
- assert_ne!(
- rev_parse(&bare, "refs/heads/main"),
- rev_parse(&bare, "refs/heads/feature")
- );
- let merge = request(
- server.address(),
- "POST",
- "/alice/example/pulls/1/merge",
- &headers,
- form(&[
- ("csrf", csrf.as_str()),
- ("method", "fast-forward"),
- ("confirm", "yes"),
- ])
- .as_bytes(),
- );
- assert_eq!(merge.status, 303);
- let merged = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
- assert!(merged.text().contains("merged · opened by alice"));
- assert!(merged.text().contains("merged the pull request"));
- assert_eq!(
- rev_parse(&bare, "refs/heads/main"),
- rev_parse(&bare, "refs/heads/feature")
- );
- let search_page = request(server.address(), "GET", "/search", &[], &[]);
- assert_eq!(search_page.status, 200);
- assert!(search_page.text().contains("Search repositories"));
- let metadata_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
- assert_eq!(metadata_search.status, 200);
- assert!(metadata_search.text().contains("/alice/example"));
- assert_eq!(
- request(server.address(), "GET", "/search?q=", &[], &[]).status,
- 400
- );
- let feed = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
- assert_eq!(feed.status, 200);
- assert!(feed.text().contains("alice reopened #1"));
- {
- let path = "/alice/example/issues/rss.xml";
- let content_type = "application/rss+xml; charset=utf-8";
- let issue_feed = request(server.address(), "GET", path, &[], &[]);
- assert_eq!(issue_feed.status, 200);
- assert_eq!(issue_feed.header("content-type"), content_type);
- let parsed = feed_rs::parser::parse(issue_feed.body.as_slice())
- .expect("parse the public issue feed");
- assert!(!parsed.entries.is_empty());
- assert!(issue_feed.text().contains("reopened #1"));
- assert!(!issue_feed.text().contains("Repository imported"));
- }
-
- let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
- assert_eq!(anonymous_watch.status, 200);
- assert_repository_navigation(&anonymous_watch, "alice", "example");
- assert!(
- anonymous_watch
- .text()
- .contains("Log in</a> to change watch preferences.")
- );
- let anonymous_activity = request(server.address(), "GET", "/activity", &[], &[]);
- assert_eq!(anonymous_activity.status, 303);
- assert_eq!(anonymous_activity.header("location"), "/login");
- let watch_page = request(
- server.address(),
- "GET",
- "/alice/example/watch",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert_eq!(watch_page.status, 200);
- assert!(
- watch_page
- .text()
- .contains("You do not watch this repository.")
- );
- let everything = form(&[("csrf", csrf.as_str()), ("state", "watch")]);
- let watched = request(
- server.address(),
- "POST",
- "/alice/example/watch",
- &headers,
- everything.as_bytes(),
- );
- assert_eq!(watched.status, 303);
- assert_eq!(watched.header("location"), "/alice/example/watch");
- let selected = request(
- server.address(),
- "GET",
- "/alice/example/watch",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert!(
- selected
- .text()
- .contains("You watch activity in this repository.")
- );
- let activity = request(
- server.address(),
- "GET",
- "/activity",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert_eq!(activity.status, 200);
- assert!(activity.text().contains("alice/example"));
- assert!(activity.text().contains("reopened #1"));
-
- let feed_tokens = request(
- server.address(),
- "GET",
- "/feeds",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert_eq!(feed_tokens.status, 200);
- assert!(feed_tokens.text().contains("Feed URLs are credentials."));
- let issue_repository_token = form(&[("csrf", csrf.as_str())]);
- let issued = request(
- server.address(),
- "POST",
- "/feeds/tokens",
- &headers,
- issue_repository_token.as_bytes(),
- );
- assert_eq!(issued.status, 201);
- assert!(issued.text().contains("tit will not show it again."));
- let private_token = extract_feed_token(issued.text());
- let private_path = format!("/feeds/{private_token}/rss.xml");
- let private_feed = request(server.address(), "GET", &private_path, &[], &[]);
- assert_eq!(private_feed.status, 200);
- assert_eq!(private_feed.header("cache-control"), "private, no-store");
- assert_eq!(private_feed.header("referrer-policy"), "no-referrer");
- assert!(private_feed.text().contains("alice/example"));
- let private_hash: [u8; 32] = Sha256::digest(private_token.as_bytes()).into();
- let token_store = Store::open(&database).expect("open the feed token database");
- let (private_id, stored_hash): (String, Vec<u8>) = token_store
- .connection()
- .query_row(
- "SELECT id, token_hash FROM feed_token
- WHERE scope = 'watched' AND revoked_at IS NULL",
- [],
- |row| Ok((row.get(0)?, row.get(1)?)),
- )
- .expect("read the stored feed token hash");
- assert_eq!(stored_hash, private_hash);
- assert_ne!(stored_hash, private_token.as_bytes());
- let selected_events = token_store
- .watched_activity_page("alice", None, crate::feed::PAGE_SIZE)
- .expect("read watched Web activity");
- assert!(!selected_events.events.is_empty());
- for event in &selected_events.events {
- assert!(activity.text().contains(&event.event.event_id));
- assert!(
- private_feed
- .text()
- .contains(&format!("urn:tit:event:{}", event.event.event_id))
- );
- }
- drop(token_store);
- let hidden_token = request(
- server.address(),
- "GET",
- "/feeds",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert!(!hidden_token.text().contains(&private_token));
-
- let private_store = Store::open(&database).expect("open the private feed database");
- private_store
+ Store::open(&database)
+ .expect("open the repository database")
.connection()
.execute(
"UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
[],
)
- .expect("make the feed repository private");
- drop(private_store);
- let private_archive_path = format!("/alice/example/archive/{}.tar", fixture.head);
- let owner_archive = request(
- server.address(),
- "GET",
- &private_archive_path,
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert_eq!(owner_archive.status, 200);
- assert_eq!(owner_archive.header("cache-control"), "private, no-store");
- let anonymous_archive = request(server.address(), "GET", &private_archive_path, &[], &[]);
- assert_eq!(anonymous_archive.status, 404);
- let private_commit_patch_path = format!("/alice/example/commit/{}.patch", fixture.head);
- assert_eq!(
- request(
- server.address(),
- "GET",
- &private_commit_patch_path,
- &[("Cookie", cookie.as_str())],
- &[],
- )
- .status,
- 200
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- &private_commit_patch_path,
- &[],
- &[],
- )
- .status,
- 404
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/pulls/1/revisions/1.patch",
- &[],
- &[],
- )
- .status,
- 404
- );
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/example/issues/rss.xml",
- &[],
- &[],
- )
- .status,
- 404
- );
- let anonymous_private_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
- assert!(
- anonymous_private_search
- .text()
- .contains("No repository matched")
- );
- let owner_private_search = request(
- server.address(),
- "GET",
- "/search?q=example",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert!(owner_private_search.text().contains("/alice/example"));
- assert_eq!(
- request(server.address(), "GET", &private_path, &[], &[]).status,
- 200
- );
-
- let cancelled_rotate = form(&[("csrf", csrf.as_str()), ("confirm", "no")]);
- let cancelled_rotation = request(
- server.address(),
- "POST",
- &format!("/feeds/tokens/{private_id}/rotate"),
- &headers,
- cancelled_rotate.as_bytes(),
- );
- assert_eq!(cancelled_rotation.status, 303);
- assert_eq!(
- request(server.address(), "GET", &private_path, &[], &[]).status,
- 200
- );
- let rotate = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
- let rotated = request(
- server.address(),
- "POST",
- &format!("/feeds/tokens/{private_id}/rotate"),
- &headers,
- rotate.as_bytes(),
- );
- assert_eq!(rotated.status, 201);
- let rotated_token = extract_feed_token(rotated.text());
- assert_ne!(rotated_token, private_token);
- assert_eq!(
- request(server.address(), "GET", &private_path, &[], &[]).status,
- 404
- );
- let rotated_path = format!("/feeds/{rotated_token}/rss.xml");
- assert_eq!(
- request(server.address(), "GET", &rotated_path, &[], &[]).status,
- 200
- );
- let rotated_hash: [u8; 32] = Sha256::digest(rotated_token.as_bytes()).into();
- let rotated_store = Store::open(&database).expect("open the rotated feed database");
- let rotated_id: String = rotated_store
- .connection()
- .query_row(
- "SELECT id FROM feed_token WHERE token_hash = ?1 AND revoked_at IS NULL",
- [rotated_hash.as_slice()],
- |row| row.get(0),
- )
- .expect("read the rotated feed token ID");
- drop(rotated_store);
- let revoked = request(
- server.address(),
- "POST",
- &format!("/feeds/tokens/{rotated_id}/revoke"),
- &headers,
- rotate.as_bytes(),
- );
- assert_eq!(revoked.status, 303);
- assert_eq!(
- request(server.address(), "GET", &rotated_path, &[], &[]).status,
- 404
- );
-
- let body = form(&[("csrf", csrf.as_str())]);
- let issued = request(
- server.address(),
- "POST",
- "/feeds/tokens",
- &headers,
- body.as_bytes(),
- );
- assert_eq!(issued.status, 201);
- let token = extract_feed_token(issued.text());
- let response = request(
- server.address(),
- "GET",
- &format!("/feeds/{token}/rss.xml"),
- &[],
- &[],
- );
- assert_eq!(response.status, 200);
- feed_rs::parser::parse(response.body.as_slice()).expect("parse the watched feed");
- assert!(response.text().contains("commented on #1"));
-
- let none = form(&[("csrf", csrf.as_str()), ("state", "unwatch")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/watch",
- &headers,
- none.as_bytes(),
- )
- .status,
- 303
- );
- assert_eq!(
- Store::open(&database)
- .expect("reopen the watch database")
- .connection()
- .query_row("SELECT count(*) FROM watch", [], |row| row.get::<_, i64>(0))
- .expect("count cleared watches"),
- 0
- );
-
- let reader_cookie = format!("tit-session={reader_token}; tit-csrf={reader_csrf}");
- let reader_headers = [
- ("Content-Type", "application/x-www-form-urlencoded"),
- ("Cookie", reader_cookie.as_str()),
- ];
- let denied_rename = form(&[("csrf", reader_csrf.as_str()), ("new-name", "reader-name")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/settings/rename",
- &reader_headers,
- denied_rename.as_bytes(),
- )
- .status,
- 403
- );
- let conflicting_rename = form(&[("csrf", csrf.as_str()), ("new-name", "empty")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/example/settings/rename",
- &headers,
- conflicting_rename.as_bytes(),
- )
- .status,
- 400
- );
- let rename = form(&[("csrf", csrf.as_str()), ("new-name", "renamed")]);
- let renamed = request(
- server.address(),
- "POST",
- "/alice/example/settings/rename",
- &headers,
- rename.as_bytes(),
- );
- assert_eq!(renamed.status, 303);
- assert_eq!(renamed.header("location"), "/alice/renamed/settings");
+ .expect("make the repository private");
assert_eq!(
request(server.address(), "GET", "/alice/example", &[], &[]).status,
404
@@ -1508,129 +123,34 @@
request(
server.address(),
"GET",
- "/alice/renamed/issues/1",
+ "/alice/example",
&[("Cookie", cookie.as_str())],
&[],
)
.status,
200
);
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/renamed/pulls/1",
- &[("Cookie", cookie.as_str())],
- &[],
- )
- .status,
- 200
- );
- let bare = fixture
- .instance
- .path()
- .join("repositories")
- .join(format!("{}.git", fixture.repository_id));
- let ref_before_archive = rev_parse(&bare, "refs/heads/main");
- let archive_repository = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/renamed/settings/archive",
- &headers,
- archive_repository.as_bytes(),
- )
- .status,
- 303
- );
- let archived_home = request(
- server.address(),
- "GET",
- "/",
- &[("Cookie", cookie.as_str())],
- &[],
- );
- assert!(
- archived_home
- .text()
- .contains("<strong>alice/renamed</strong>")
- );
- assert!(
- archived_home
- .text()
- .contains("<span class=\"repository-meta\">archived</span>")
- );
- assert!(archived_home.text().contains("Unarchive repository"));
- let denied_unarchive = form(&[("csrf", reader_csrf.as_str()), ("confirm", "yes")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/renamed/settings/unarchive",
- &reader_headers,
- denied_unarchive.as_bytes(),
- )
- .status,
- 403
- );
- let unarchive = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
- assert_eq!(
- request(
- server.address(),
- "POST",
- "/alice/renamed/settings/unarchive",
- &headers,
- unarchive.as_bytes(),
- )
- .status,
- 303
- );
- assert_eq!(rev_parse(&bare, "refs/heads/main"), ref_before_archive);
- assert_eq!(
- request(
- server.address(),
- "GET",
- "/alice/renamed",
- &[("Cookie", cookie.as_str())],
- &[],
- )
- .status,
- 200
- );
- let lifecycle_store = Store::open(&database).expect("open the lifecycle database");
- let lifecycle: (String, String, i64) = lifecycle_store
- .connection()
- .query_row(
- "SELECT repository.visibility, repository.state,
- (SELECT count(*) FROM repository_collaborator
- WHERE repository_id = repository.id)
- FROM repository WHERE repository.slug = 'renamed'",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
- )
- .expect("read repository lifecycle state");
- assert_eq!(lifecycle, ("private".to_owned(), "active".to_owned(), 1));
- for action in [
- "repository.rename",
- "repository.archive",
- "repository.unarchive",
- ] {
- assert_eq!(
- lifecycle_store
- .connection()
- .query_row(
- "SELECT count(*) FROM audit_event
- WHERE action = ?1 AND actor = 'alice' AND outcome = 'success'",
- [action],
- |row| row.get::<_, i64>(0),
- )
- .expect("count a repository lifecycle audit event"),
- 1
- );
- }
- server.shutdown().await.expect("stop the issue Web server");
+ server.shutdown().await.expect("stop the public Web server");
+}
+
+fn create_session(database: &Path, token: &str, csrf: &str) {
+ let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
+ let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("read the current time")
+ .as_secs() as i64;
+ Store::open(database)
+ .expect("open the session database")
+ .connection()
+ .execute(
+ "INSERT INTO web_session
+ (session_hash, csrf_hash, account_id, created_at, expires_at)
+ SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
+ rusqlite::params![session_hash, csrf_hash, now, now + 3_600],
+ )
+ .expect("create a Web session");
}
fn form(fields: &[(&str, &str)]) -> String {
@@ -1639,53 +159,21 @@
serializer.finish()
}
-fn extract_feed_token(body: &str) -> String {
- let marker = "/feeds/";
- let start = body.find(marker).expect("find a feed URL") + marker.len();
- let token = body[start..].split('/').next().expect("read a feed token");
- assert_eq!(token.len(), 64);
- assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
- token.to_owned()
-}
-
-fn assert_hidden(address: SocketAddr, head: &str) {
- for route in [
- "/alice/example".to_owned(),
- format!("/alice/example/raw/{head}/README.md"),
- "/alice/example/atom.xml".to_owned(),
- "/alice/example/rss.xml".to_owned(),
- "/alice/example/search?q=text".to_owned(),
- "/alice/example/info/refs?service=git-upload-pack".to_owned(),
- ] {
- let response = request(address, "GET", &route, &[], &[]);
- assert_eq!(response.status, 404, "repository was visible at {route}");
- }
-}
-
struct Fixture {
instance: TempDir,
- worktree: PathBuf,
- repository_id: String,
- head: String,
- parent: String,
}
impl Fixture {
- fn new(format: &str) -> Self {
+ fn new() -> Self {
let instance = TempDir::new().expect("create an instance directory");
let repositories = instance.path().join("repositories");
fs::create_dir(&repositories).expect("create the repository directory");
- let id = if format == "sha1" {
- "11111111111111111111111111111111"
- } else {
- "22222222222222222222222222222222"
- };
- let bare = repositories.join(format!("{id}.git"));
- let empty_bare = repositories.join("33333333333333333333333333333333.git");
let worktree = instance.path().join("worktree");
+ let repository_id = "11111111111111111111111111111111";
+ let bare = repositories.join(format!("{repository_id}.git"));
run(Command::new("git")
- .args(["init", "-q", "-b", "main", "--object-format", format])
+ .args(["init", "-q", "-b", "main"])
.arg(&worktree));
run(Command::new("git").arg("-C").arg(&worktree).args([
"config",
@@ -1694,73 +182,24 @@
]));
fs::write(
worktree.join("README.md"),
- b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [license](LICENSE) [bad](javascript:alert(1))\n\n\n\n<script>alert(2)</script>\n",
+ "# tit fixture\n\n**safe**\n\n<script>alert(1)</script>\n",
)
.expect("write the README");
- fs::write(worktree.join("LICENSE"), b"fixture license\n").expect("write the license");
- fs::create_dir(worktree.join("nested")).expect("create a nested directory");
- fs::write(worktree.join("nested/file.txt"), b"first line\n").expect("write the text file");
- fs::write(worktree.join("binary.dat"), b"binary\0content").expect("write the binary file");
- let mut large = vec![b'x'; 2 * 1024 * 1024];
- let prefix = b"large content starts\n";
- large[..prefix.len()].copy_from_slice(prefix);
- let suffix = b"\nlarge content ends\n";
- let suffix_start = large.len() - suffix.len();
- large[suffix_start..].copy_from_slice(suffix);
- fs::write(worktree.join("large.txt"), large).expect("write the large file");
- fs::write(
- worktree.join("<img src=x onerror=alert(4)>.txt"),
- b"escaped path\n",
- )
- .expect("write the hostile path");
- fs::write(worktree.join("non-å.txt"), b"non-UTF-8 path\n")
- .expect("write the percent-encoded path");
- fs::write(worktree.join("malformed.txt"), b"start \xff needle\n")
- .expect("write malformed UTF-8 content");
- for index in 0..=50 {
- fs::write(
- worktree.join(format!("summary-overflow-{index:02}.txt")),
- b"summary entry\n",
- )
- .expect("write a summary-limit file");
- }
- commit_all(&worktree, "first commit");
- let parent = rev_parse(&worktree, "HEAD");
- for index in 1..=100 {
- commit_empty(&worktree, &format!("intermediate commit {index}"));
- }
- fs::write(
- worktree.join("nested/file.txt"),
- b"first line\nsecond line\n",
- )
- .expect("update the text file");
- commit_all(&worktree, "<script>alert(3)</script>");
- let head = rev_parse(&worktree, "HEAD");
- for (tag, target) in [
- ("v0.1", parent.as_str()),
- ("v0.2", "HEAD~2"),
- ("v0.3", "HEAD~1"),
- ("v0.4", "HEAD"),
- ] {
- run(Command::new("git").arg("-C").arg(&worktree).args([
- "-c",
- "tag.gpgSign=false",
- "tag",
- tag,
- target,
- ]));
- }
run(Command::new("git")
.arg("-C")
.arg(&worktree)
- .args(["branch", "feature"]));
-
+ .args(["add", "README.md"]));
run(Command::new("git")
- .args(["init", "-q", "--bare", "--object-format", format])
+ .arg("-C")
+ .arg(&worktree)
+ .args(["commit", "-q", "-m", "initial commit"])
+ .env("GIT_AUTHOR_NAME", "Fixture Author")
+ .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
+ .env("GIT_COMMITTER_NAME", "Fixture Author")
+ .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
+ run(Command::new("git")
+ .args(["init", "-q", "--bare"])
.arg(&bare));
- run(Command::new("git")
- .args(["init", "-q", "--bare", "--object-format", format])
- .arg(&empty_bare));
run(Command::new("git").arg("-C").arg(&bare).args([
"symbolic-ref",
"HEAD",
@@ -1771,13 +210,7 @@
.arg(&worktree)
.args(["push", "-q"])
.arg(&bare)
- .args(["main", "feature"]));
- run(Command::new("git")
- .arg("-C")
- .arg(&worktree)
- .args(["push", "-q"])
- .arg(&bare)
- .arg("--tags"));
+ .arg("main"));
let database = instance.path().join(store::DATABASE_FILE);
let mut store = Store::open(&database).expect("open the fixture database");
@@ -1792,10 +225,10 @@
.expect("create the repository owner");
store
.create_repository(&NewRepository {
- id,
+ id: repository_id,
owner: "alice",
slug: "example",
- object_format: format,
+ object_format: "sha1",
default_branch: "refs/heads/main",
created_at: 2,
origin: RepositoryOrigin::Imported,
@@ -1804,70 +237,9 @@
correlation_id: "test-import",
})
.expect("create the repository record");
- store
- .create_repository(&NewRepository {
- id: "33333333333333333333333333333333",
- owner: "alice",
- slug: "empty",
- object_format: format,
- default_branch: "refs/heads/main",
- created_at: 2,
- origin: RepositoryOrigin::Created,
- initial_references: &[],
- actor: "admin-cli",
- correlation_id: "test-create",
- })
- .expect("create the empty repository record");
- drop(store);
- Self {
- instance,
- worktree,
- repository_id: id.to_owned(),
- head,
- parent,
- }
+ Self { instance }
}
-}
-
-fn commit_all(worktree: &Path, message: &str) {
- run(Command::new("git")
- .arg("-C")
- .arg(worktree)
- .args(["add", "-A"]));
- run(Command::new("git")
- .arg("-C")
- .arg(worktree)
- .args(["commit", "-q", "-m", message])
- .env("GIT_AUTHOR_NAME", "Fixture Author")
- .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
- .env("GIT_COMMITTER_NAME", "Fixture Author")
- .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
-}
-
-fn commit_empty(worktree: &Path, message: &str) {
- run(Command::new("git")
- .arg("-C")
- .arg(worktree)
- .args(["commit", "-q", "--allow-empty", "-m", message])
- .env("GIT_AUTHOR_NAME", "Fixture Author")
- .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
- .env("GIT_COMMITTER_NAME", "Fixture Author")
- .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
-}
-
-fn rev_parse(repository: &Path, revision: &str) -> String {
- let output = Command::new("git")
- .arg("-C")
- .arg(repository)
- .args(["rev-parse", revision])
- .output()
- .expect("read a Git object ID");
- assert!(output.status.success());
- String::from_utf8(output.stdout)
- .expect("a hexadecimal object ID")
- .trim()
- .to_owned()
}
fn run(command: &mut Command) {
@@ -1887,6 +259,12 @@
body: &[u8],
) -> HttpResponse {
let mut stream = TcpStream::connect(address).expect("connect to the public Web server");
+ stream
+ .set_read_timeout(Some(RESPONSE_TIMEOUT))
+ .expect("set the response timeout");
+ stream
+ .set_write_timeout(Some(RESPONSE_TIMEOUT))
+ .expect("set the request timeout");
let mut head = format!(
"{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
body.len()
@@ -1898,14 +276,7 @@
stream
.write_all(head.as_bytes())
.expect("write HTTP request headers");
- if let Err(error) = stream.write_all(body)
- && !matches!(
- error.kind(),
- std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
- )
- {
- panic!("write an HTTP request: {error}");
- }
+ stream.write_all(body).expect("write the HTTP request");
let mut response = Vec::new();
if let Err(error) = stream.read_to_end(&mut response)
&& error.kind() != std::io::ErrorKind::ConnectionReset
@@ -1921,23 +292,6 @@
assert_eq!(response.header("x-frame-options"), "DENY");
assert_eq!(response.header("referrer-policy"), "no-referrer");
assert_eq!(response.header("cache-control"), "no-store");
- assert_eq!(response.header("x-request-id").len(), 32);
-}
-
-fn assert_repository_navigation(response: &HttpResponse, owner: &str, repository: &str) {
- let text = response.text();
- assert!(text.contains(&format!(
- "<h1><a href=\"/{owner}\">{owner}</a>/<a href=\"/{owner}/{repository}\">{repository}</a></h1>"
- )));
- for suffix in [
- "", "/refs", "/issues", "/pulls", "/watch", "/rss.xml", "/search",
- ] {
- let link = format!("/{owner}/{repository}{suffix}");
- assert!(
- text.contains(&format!("href=\"{link}\"")),
- "repository navigation is missing {link}"
- );
- }
}
struct HttpResponse {
tests/pull_requests.rs
Mode 100644 → 100644; object 8818d416de28 → 9d0c9e159a4f
@@ -1,17 +1,16 @@
-use crate::{git, pull_request, store};
+use crate::{git, maintenance::MaintenanceGate, pull_request, store};
use std::fs;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
-use std::sync::Arc;
use std::time::Duration;
use git::read::{Mergeability, ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
use git::repository::GitRepository;
use gix::hash::ObjectId;
-use pull_request::{PullRequestError, PullRequestService};
+use pull_request::{NewPullRequest, PullRequestError, PullRequestReview, PullRequestService};
use rusqlite::params;
use store::{GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, Store, StoreError};
use tempfile::TempDir;
@@ -22,15 +21,15 @@
let fixture = Fixture::new(object_format, index);
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
let opened = service
- .open(
- "alice",
- "project",
- "alice",
- "Add the feature",
- "Keep the revision context.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Add the feature",
+ body: "Keep the revision context.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a pull request");
assert_eq!(opened.number, 1);
assert_eq!(fixture.pull_ref(1), opened.head_object_id);
@@ -101,15 +100,15 @@
opened.head_object_id
);
assert!(matches!(
- service.open(
- "alice",
- "project",
- "bob",
- "Reader change",
- "Readers cannot open pull requests.",
- "refs/heads/main",
- "refs/heads/feature",
- ),
+ service.open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "bob",
+ title: "Reader change",
+ body: "Readers cannot open pull requests.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ }),
Err(PullRequestError::Store(StoreError::PullRequestDenied))
));
@@ -219,25 +218,39 @@
pending_revision.revision_number
);
- let service = Arc::new(service);
- let handles = ["Concurrent A", "Concurrent B"].map(|title| {
- let service = Arc::clone(&service);
- std::thread::spawn(move || {
- service
- .open(
- "alice",
- "project",
- "alice",
- title,
- "Use one stable number.",
- "refs/heads/main",
- "refs/heads/feature",
- )
- .expect("open a concurrent pull request")
- .number
+ let gate = MaintenanceGate::default();
+ let services = [
+ PullRequestService::new_with_gate(
+ &fixture.database,
+ &fixture.repositories,
+ gate.clone(),
+ ),
+ PullRequestService::new_with_gate(&fixture.database, &fixture.repositories, gate),
+ ];
+ let handles = ["Concurrent A", "Concurrent B"]
+ .into_iter()
+ .zip(services)
+ .map(|(title, service)| {
+ std::thread::spawn(move || {
+ service
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title,
+ body: "Use one stable number.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
+ .expect("open a concurrent pull request")
+ .number
+ })
})
- });
- let mut numbers = handles.map(|handle| handle.join().expect("join an opener"));
+ .collect::<Vec<_>>();
+ let mut numbers = handles
+ .into_iter()
+ .map(|handle| handle.join().expect("join an opener"))
+ .collect::<Vec<_>>();
numbers.sort_unstable();
assert_eq!(numbers, [3, 4]);
assert_eq!(fixture.pull_ref(3), next_head.to_string());
@@ -278,15 +291,15 @@
let fixture = Fixture::new(object_format, index + 30);
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
let opened = service
- .open(
- "alice",
- "project",
- "alice",
- "Fast-forward the feature",
- "Move the base ref to the reviewed head.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Fast-forward the feature",
+ body: "Move the base ref to the reviewed head.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a fast-forward pull request");
assert!(matches!(
service.merge("alice", "project", 1, "bob", "fast-forward"),
@@ -352,15 +365,15 @@
let fixture = Fixture::new("sha1", 40);
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Stale base",
- "Do not merge a stale comparison.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Stale base",
+ body: "Do not merge a stale comparison.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a pull request");
fixture.commit_on("main", "base.txt", "new base\n", "move the base");
assert!(matches!(
@@ -424,15 +437,15 @@
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Merge the rename",
- "Keep the rename and executable mode.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Merge the rename",
+ body: "Keep the rename and executable mode.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a divergent pull request");
service
.merge("alice", "project", 1, "alice", "merge-commit")
@@ -469,15 +482,15 @@
let base = rev_parse(&fixture.bare, "refs/heads/main");
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Conflicting merge",
- "Do not create a conflict commit.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Conflicting merge",
+ body: "Do not create a conflict commit.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a conflicting pull request");
assert!(matches!(
service.merge("alice", "project", 1, "alice", "merge-commit"),
@@ -501,15 +514,15 @@
let fixture = Fixture::new("sha1", 70);
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Recover this merge",
- "Complete metadata after the ref update.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Recover this merge",
+ body: "Complete metadata after the ref update.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a recoverable pull request");
begin_test_merge_intent(&fixture, 1, "71000000000000000000000000000000");
let git = GitRepository::open(&fixture.bare).expect("open the recovery repository");
@@ -534,15 +547,15 @@
let concurrent = Fixture::new("sha1", 71);
let service = PullRequestService::new(&concurrent.database, &concurrent.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Race the base",
- "A concurrent base update wins before this ref moves.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Race the base",
+ body: "A concurrent base update wins before this ref moves.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a concurrent pull request");
begin_test_merge_intent(&concurrent, 1, "72000000000000000000000000000000");
concurrent.commit_on("main", "raced.txt", "concurrent\n", "race the merge");
@@ -593,15 +606,15 @@
);
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Clean divergence",
- "The branches change different paths.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Clean divergence",
+ body: "The branches change different paths.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a clean divergent pull request");
let object_state = git_object_state(&fixture.bare);
let clean = service
@@ -619,15 +632,15 @@
"change feature content",
);
service
- .open(
- "alice",
- "project",
- "alice",
- "Conflicting divergence",
- "The branches change the same line.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Conflicting divergence",
+ body: "The branches change the same line.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a conflicting pull request");
let object_state = git_object_state(&fixture.bare);
let conflicting = service
@@ -641,15 +654,15 @@
fixture.merge_feature_into_main();
service
- .open(
- "alice",
- "project",
- "alice",
- "Merged head",
- "The head is already in the base.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Merged head",
+ body: "The head is already in the base.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open an already merged pull request");
let merged = service
.compare("alice", "project", 3, None, None)
@@ -658,15 +671,15 @@
fixture.create_unrelated_branch();
service
- .open(
- "alice",
- "project",
- "alice",
- "Unrelated head",
- "The branches do not have a common commit.",
- "refs/heads/main",
- "refs/heads/unrelated",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Unrelated head",
+ body: "The branches do not have a common commit.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/unrelated",
+ })
.expect("open an unrelated pull request");
let unrelated = service
.compare("alice", "project", 4, None, None)
@@ -726,76 +739,85 @@
fixture.commit_bytes_on("feature", byte_path, b"byte path\n", "add a byte path");
let service = PullRequestService::new(&fixture.database, &fixture.repositories);
service
- .open(
- "alice",
- "project",
- "alice",
- "Review anchors",
- "Keep each review action.",
- "refs/heads/main",
- "refs/heads/feature",
- )
+ .open(&NewPullRequest {
+ owner: "alice",
+ repository: "project",
+ actor: "alice",
+ title: "Review anchors",
+ body: "Keep each review action.",
+ base_ref: "refs/heads/main",
+ head_ref: "refs/heads/feature",
+ })
.expect("open a reviewed pull request");
service
- .review(
- "alice",
- "project",
- 1,
- 1,
- "bob",
- "comment",
- "A **general** comment.",
- None,
- None,
- None,
- )
+ .review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 1,
+ actor: "bob",
+ kind: "comment",
+ body: "A **general** comment.",
+ path: None,
+ side: None,
+ line: None,
+ })
.expect("add a reader comment");
service
- .review(
- "alice", "project", 1, 1, "bob", "approved", "", None, None, None,
- )
+ .review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 1,
+ actor: "bob",
+ kind: "approved",
+ body: "",
+ path: None,
+ side: None,
+ line: None,
+ })
.expect("approve the revision");
service
- .review(
- "alice",
- "project",
- 1,
- 1,
- "alice",
- "changes-requested",
- "Change this line.",
- None,
- None,
- None,
- )
+ .review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 1,
+ actor: "alice",
+ kind: "changes-requested",
+ body: "Change this line.",
+ path: None,
+ side: None,
+ line: None,
+ })
.expect("request changes");
let line_id = service
- .review(
- "alice",
- "project",
- 1,
- 1,
- "bob",
- "line-comment",
- "Use a clearer value.",
- Some(byte_path),
- Some("head"),
- Some(1),
- )
+ .review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 1,
+ actor: "bob",
+ kind: "line-comment",
+ body: "Use a clearer value.",
+ path: Some(byte_path),
+ side: Some("head"),
+ line: Some(1),
+ })
.expect("add a line comment");
assert!(matches!(
- service.review(
- "alice",
- "project",
- 1,
- 1,
- "bob",
- "line-comment",
- "This line does not exist.",
- Some(byte_path),
- Some("head"),
- Some(2),
- ),
+ service.review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 1,
+ actor: "bob",
+ kind: "line-comment",
+ body: "This line does not exist.",
+ path: Some(byte_path),
+ side: Some("head"),
+ line: Some(2),
+ }),
Err(PullRequestError::ReviewAnchor)
));
@@ -839,14 +861,34 @@
let store = Store::open(&fixture.database).expect("open the review policy store");
let first_page = store
- .pull_request_detail_page("alice", "project", 1, Some("bob"), 1, 1, 2)
+ .pull_request_detail_page(
+ "alice",
+ "project",
+ 1,
+ Some("bob"),
+ crate::store::TimelinePagination {
+ primary_page: 1,
+ timeline_page: 1,
+ page_size: 2,
+ },
+ )
.expect("read the first bounded review page");
assert_eq!(first_page.reviews.len(), 2);
assert!(first_page.reviews_has_next);
assert_eq!(first_page.timeline.len(), 2);
assert!(first_page.timeline_has_next);
let second_page = store
- .pull_request_detail_page("alice", "project", 1, Some("bob"), 2, 2, 2)
+ .pull_request_detail_page(
+ "alice",
+ "project",
+ 1,
+ Some("bob"),
+ crate::store::TimelinePagination {
+ primary_page: 2,
+ timeline_page: 2,
+ page_size: 2,
+ },
+ )
.expect("read the second bounded review page");
assert_eq!(second_page.reviews_page, 2);
assert_eq!(second_page.reviews.len(), 2);
@@ -867,18 +909,18 @@
)
.expect("remove the reader");
assert!(matches!(
- service.review(
- "alice",
- "project",
- 1,
- 2,
- "bob",
- "comment",
- "This must stay hidden.",
- None,
- None,
- None,
- ),
+ service.review(&PullRequestReview {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ revision: 2,
+ actor: "bob",
+ kind: "comment",
+ body: "This must stay hidden.",
+ path: None,
+ side: None,
+ line: None,
+ }),
Err(PullRequestError::Store(StoreError::PullRequestHidden))
));
}
tests/repository_policy.rs
Mode 100644 → 100644; object 5627b1e60d9e → 2defcbea37b2
@@ -74,17 +74,11 @@
assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Read);
assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Write);
assert_allowed(&policy, Some("maintainer"), RepositoryOperation::Maintain);
- assert_denied(&policy, Some("maintainer"), RepositoryOperation::Own);
assert_allowed(&policy, Some("writer"), RepositoryOperation::Read);
assert_allowed(&policy, Some("writer"), RepositoryOperation::Write);
assert_denied(&policy, Some("writer"), RepositoryOperation::Maintain);
- assert_denied(&policy, Some("writer"), RepositoryOperation::Own);
assert_allowed(&policy, Some("reader"), RepositoryOperation::Read);
- for operation in [
- RepositoryOperation::Write,
- RepositoryOperation::Maintain,
- RepositoryOperation::Own,
- ] {
+ for operation in [RepositoryOperation::Write, RepositoryOperation::Maintain] {
assert_denied(&policy, Some("reader"), operation);
}
for actor in [None, Some("stranger"), Some("suspended"), Some("missing")] {
@@ -124,23 +118,10 @@
})
.expect("create a policy repository");
let policy = RepositoryPolicy::new(&database);
- assert_eq!(
- policy
- .public_repositories()
- .expect("list repositories")
- .len(),
- 1
- );
store
.set_repository_visibility("owner", "project", "private", 3, "admin-cli", "test")
.expect("make the repository private");
- assert!(
- policy
- .public_repositories()
- .expect("list repositories")
- .is_empty()
- );
store
.set_repository_collaborator("owner", "project", "member", "writer", &audit(3))
.expect("add a writer");
@@ -378,12 +359,11 @@
);
}
-fn operations() -> [RepositoryOperation; 4] {
+fn operations() -> [RepositoryOperation; 3] {
[
RepositoryOperation::Read,
RepositoryOperation::Write,
RepositoryOperation::Maintain,
- RepositoryOperation::Own,
]
}
tests/serve.rs
Mode 100644 → 100644; object e54389ae6f07 → 026cb4881a13
@@ -1,34 +1,20 @@
-#[allow(
- dead_code,
- reason = "the server test uses only part of the shared test support"
-)]
-mod support;
-
use std::env;
use std::fs;
use std::io::{Read, Write};
-use std::net::{SocketAddr, TcpStream};
-use std::os::unix::fs::PermissionsExt;
-use std::path::Path;
-use std::process::{Child, Command, ExitStatus, Output, Stdio};
-use std::sync::{Mutex, MutexGuard};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
-use support::{create_ssh_key_fixture, free_address};
use tempfile::TempDir;
-static SERVER_TEST_LOCK: Mutex<()> = Mutex::new(());
+mod ssh_fixture;
-fn server_test_lock() -> MutexGuard<'static, ()> {
- SERVER_TEST_LOCK
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
-}
+use ssh_fixture::create_ssh_key;
#[test]
fn serves_an_imported_repository_through_http_and_ssh() {
- let _server_test = server_test_lock();
let instance = TempDir::new().expect("create an instance directory");
let http = free_address();
let ssh = free_address();
@@ -36,33 +22,29 @@
fs::write(
&config,
format!(
- "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+ "version = 1\npublic_url = \"http://{http}/\"\n\n\
+ [http]\nlisten = \"{http}\"\n\n\
+ [ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
ssh.port()
),
)
.expect("write the server configuration");
+
let private_key = instance.path().join("administrator");
- create_ssh_key_fixture(&private_key);
+ create_ssh_key(&private_key);
let public_key = fs::read_to_string(private_key.with_extension("pub"))
.expect("read the administrator public key");
- command(
+ run_tit(
instance.path(),
- [
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "setup",
- "admin",
- "alice",
- public_key.trim(),
- ],
+ &config,
+ &["setup", "admin", "alice", public_key.trim()],
);
let source = create_source_repository(instance.path());
- command(
+ run_tit(
instance.path(),
- [
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
+ &config,
+ &[
"admin",
"repository",
"import",
@@ -71,607 +53,27 @@
source.to_str().expect("a UTF-8 source path"),
],
);
- let _settings_member_key = provision_account(instance.path(), "carol", "active", false);
+
let mut server = spawn_server(&config);
wait_for_listener(http, &mut server);
wait_for_listener(ssh, &mut server);
+
let health = http_get(http, "/healthz");
assert!(health.starts_with("HTTP/1.1 200"));
assert!(health.ends_with("\r\n\r\nready\n"));
- let backup_directory = TempDir::new().expect("create a backup directory");
- let backup = backup_directory.path().join("instance.tar");
- let backup_output = Command::new(tit_binary())
- .args([
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "backup",
- backup.to_str().expect("a UTF-8 backup path"),
- ])
- .output()
- .expect("request an online backup");
- assert!(
- backup_output.status.success(),
- "online backup failed: {}",
- String::from_utf8_lossy(&backup_output.stderr)
- );
- assert!(String::from_utf8_lossy(&backup_output.stdout).contains("contains credentials"));
- assert_eq!(
- fs::metadata(&backup)
- .expect("inspect the backup")
- .permissions()
- .mode()
- & 0o777,
- 0o600
- );
-
- let restored = TempDir::new().expect("create a restore target");
- fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
- .expect("make the restore target private");
- let restore_output = Command::new(tit_binary())
- .args([
- "restore",
- backup.to_str().expect("a UTF-8 backup path"),
- restored.path().to_str().expect("a UTF-8 restore path"),
- ])
- .output()
- .expect("restore the online backup");
- assert!(
- restore_output.status.success(),
- "restore failed: {}",
- String::from_utf8_lossy(&restore_output.stderr)
- );
- assert!(String::from_utf8_lossy(&restore_output.stdout).contains("is not active"));
- let restored_database = rusqlite::Connection::open(restored.path().join("tit.sqlite3"))
- .expect("open the restored database");
- let restored_repository: String = restored_database
- .query_row(
- "SELECT id FROM repository WHERE slug = 'example'",
- [],
- |row| row.get(0),
- )
- .expect("read the restored repository");
- drop(restored_database);
- let restored_readme = Command::new("git")
- .args(["--git-dir"])
- .arg(
- restored
- .path()
- .join("repositories")
- .join(format!("{restored_repository}.git")),
- )
- .args(["show", "main:README.md"])
- .output()
- .expect("read the restored Git repository");
- assert!(restored_readme.status.success());
- assert_eq!(restored_readme.stdout, b"serve fixture\n");
-
- let second = Command::new(tit_binary())
- .args([
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "serve",
- ])
- .output()
- .expect("start a second tit server");
- assert!(!second.status.success());
- assert!(String::from_utf8_lossy(&second.stderr).contains("owns the instance lock"));
-
- let control_socket = instance.path().join("control.sock");
- assert_eq!(
- fs::symlink_metadata(&control_socket)
- .expect("inspect the control socket")
- .permissions()
- .mode()
- & 0o777,
- 0o600
- );
-
- let anonymous_home = http_get(http, "/");
- assert!(
- anonymous_home.contains("Recently updated public repositories"),
- "anonymous home response:\n{anonymous_home}"
- );
- assert!(anonymous_home.contains(">alice/example</a>"));
- assert!(anonymous_home.contains("<a href=\"/signup\">Create account</a>"));
- assert!(anonymous_home.contains("<a href=\"/recover\">Recover account</a>"));
- assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
- assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
- assert!(http_get(http, "/alice/example").contains("trunk head"));
-
- let login_approval = http_form(http, "/login/ssh", &[]);
- assert!(login_approval.starts_with("HTTP/1.1 200"));
- let secret = between(&login_approval, "name=\"secret\" value=\"", "\">");
- let login_csrf_cookies = response_cookies(&login_approval);
- let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
- let removed_login = ssh_exec(ssh, &private_key, &["login", secret]);
- assert!(!removed_login.status.success());
- assert!(String::from_utf8_lossy(&removed_login.stderr).contains("help"));
- let ssh_approval = ssh_exec(ssh, &private_key, &["auth", secret]);
- assert!(ssh_approval.status.success());
- let ssh_approval_output =
- String::from_utf8(ssh_approval.stdout).expect("read SSH approval output");
- assert!(ssh_approval_output.contains(&format!("Origin: http://{http}")));
- assert!(ssh_approval_output.contains("Account: alice"));
- let rejected_login = http_form_with_headers(
- http,
- "/login/ssh/complete",
- &[("secret", secret), ("login-csrf", &"0".repeat(64))],
- &[("Cookie", &login_csrf_cookies)],
- );
- assert!(rejected_login.starts_with("HTTP/1.1 400"));
- let rejected_login_id = response_header(&rejected_login, "x-request-id").to_owned();
- let login = http_form_with_headers(
- http,
- "/login/ssh/complete",
- &[("secret", secret), ("login-csrf", login_csrf)],
- &[("Cookie", &login_csrf_cookies)],
- );
- assert!(login.starts_with("HTTP/1.1 303"), "{login}");
- let login_id = response_header(&login, "x-request-id").to_owned();
- let cookies = response_cookies(&login);
- let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
- assert!(account.starts_with("HTTP/1.1 200"));
- assert!(account.contains("<dd>alice</dd>"));
- assert!(account.contains("<a href=\"/account\">Account</a>"));
- assert!(account.contains("<a href=\"/logout\">Log out</a>"));
- assert!(!account.contains("<a href=\"/login\">Log in</a>"));
- assert!(!account.contains("<a href=\"/signup\">Create account</a>"));
- assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
- assert!(account.contains("action=\"/account/repositories\""));
- assert!(!account.contains("object-format"));
- assert!(account.contains("<ul class=\"key-list\">"));
- assert!(!account.contains("<table>"));
- for path in ["/login", "/signup", "/recover"] {
- let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
- assert!(response.starts_with("HTTP/1.1 303"));
- assert_eq!(response_header(&response, "location"), "/account");
- }
- let signed_in_home = http_get_with_headers(http, "/", &[("Cookie", &cookies)]);
- assert!(signed_in_home.contains("<h1>alice</h1>"));
- assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
- assert!(signed_in_home.contains("<h2>Recently updated public repositories</h2>"));
- assert!(signed_in_home.contains("<div class=\"two-column\">"));
- assert!(
- signed_in_home
- .find("<h2>Your repositories</h2>")
- .expect("find the owned repository section")
- < signed_in_home
- .find("<h2>Recently updated public repositories</h2>")
- .expect("find the recent repository section")
- );
- assert_eq!(signed_in_home.matches(">alice/example</a>").count(), 2);
- assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
- assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
- assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
- assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
- assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
- let signed_in_missing =
- http_get_with_headers(http, "/this-page-does-not-exist", &[("Cookie", &cookies)]);
- assert!(signed_in_missing.starts_with("HTTP/1.1 404"));
- assert!(signed_in_missing.contains("<a href=\"/account\">Account</a>"));
- assert!(signed_in_missing.contains("<a href=\"/logout\">Log out</a>"));
- assert!(!signed_in_missing.contains("<a href=\"/login\">Log in</a>"));
- let csrf = cookie_value(&cookies, "tit-csrf");
- let second_key = instance.path().join("alice-second");
- create_ssh_key_fixture(&second_key);
- let second_public_key =
- fs::read_to_string(second_key.with_extension("pub")).expect("read the second public key");
- let second_fingerprint = ssh_fingerprint(&second_key);
- let begin_add = http_form_with_headers(
- http,
- "/account/keys/add",
- &[
- ("csrf", csrf),
- ("label", "laptop"),
- ("public-key", second_public_key.trim()),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(begin_add.starts_with("HTTP/1.1 200"), "{begin_add}");
- assert!(begin_add.contains("ssh -p"));
- assert!(begin_add.contains(" auth "));
- let add_secret = between(&begin_add, "name=\"secret\" value=\"", "\">");
- assert!(
- ssh_exec(ssh, &private_key, &["auth", add_secret])
- .status
- .success()
- );
- let complete_add = http_form_with_headers(
- http,
- "/account/keys/add/complete",
- &[
- ("csrf", csrf),
- ("secret", add_secret),
- ("label", "laptop"),
- ("public-key", second_public_key.trim()),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(complete_add.starts_with("HTTP/1.1 303"), "{complete_add}");
- let replay_add = http_form_with_headers(
- http,
- "/account/keys/add/complete",
- &[
- ("csrf", csrf),
- ("secret", add_secret),
- ("label", "replay"),
- ("public-key", second_public_key.trim()),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(replay_add.starts_with("HTTP/1.1 400"));
- assert!(ssh_exec(ssh, &second_key, &["help"]).status.success());
- let account_with_key = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
- assert!(account_with_key.contains("laptop"));
- assert!(account_with_key.contains(&second_fingerprint));
-
- let begin_revoke = http_form_with_headers(
- http,
- "/account/keys/revoke",
- &[("csrf", csrf), ("fingerprint", &second_fingerprint)],
- &[("Cookie", &cookies)],
- );
- assert!(begin_revoke.starts_with("HTTP/1.1 200"));
- let revoke_secret = between(&begin_revoke, "name=\"secret\" value=\"", "\">");
- assert!(
- ssh_exec(ssh, &private_key, &["auth", revoke_secret])
- .status
- .success()
- );
- let complete_revoke = http_form_with_headers(
- http,
- "/account/keys/revoke/complete",
- &[
- ("csrf", csrf),
- ("secret", revoke_secret),
- ("fingerprint", &second_fingerprint),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(
- complete_revoke.starts_with("HTTP/1.1 303"),
- "{complete_revoke}"
- );
- assert!(!ssh_exec(ssh, &second_key, &["help"]).status.success());
-
- let initial_fingerprint = ssh_fingerprint(&private_key);
- let begin_final_revoke = http_form_with_headers(
- http,
- "/account/keys/revoke",
- &[("csrf", csrf), ("fingerprint", &initial_fingerprint)],
- &[("Cookie", &cookies)],
- );
- let final_secret = between(&begin_final_revoke, "name=\"secret\" value=\"", "\">");
- assert!(
- ssh_exec(ssh, &private_key, &["auth", final_secret])
- .status
- .success()
- );
- let final_revoke = http_form_with_headers(
- http,
- "/account/keys/revoke/complete",
- &[
- ("csrf", csrf),
- ("secret", final_secret),
- ("fingerprint", &initial_fingerprint),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(final_revoke.starts_with("HTTP/1.1 400"));
- assert!(final_revoke.contains("must keep at least one active SSH key"));
-
- let profile_without_contact_email = http_get(http, "/alice");
- assert!(profile_without_contact_email.starts_with("HTTP/1.1 200"));
- assert!(!profile_without_contact_email.contains("mailto:"));
- let profile_update = http_form_with_headers(
- http,
- "/account/profile",
- &[
- ("csrf", csrf),
- ("bio", "Builds small systems."),
- ("contact-email", "alice@example.test"),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(profile_update.starts_with("HTTP/1.1 303"));
- let public_profile = http_get(http, "/alice");
- assert!(public_profile.starts_with("HTTP/1.1 200"));
- assert!(public_profile.contains("Builds small systems."));
- assert!(public_profile.contains("mailto:alice@example.test"));
- assert!(public_profile.contains("href=\"/alice/example\""));
- let settings = http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
- assert!(settings.starts_with("HTTP/1.1 200"));
- assert!(settings.contains("<h2>Repository settings</h2>"));
- assert!(settings.contains("<option value=\"refs/heads/trunk\" selected>"));
- let pulls = http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
- assert!(pulls.contains("value=\"refs/heads/trunk\" required"));
- for invalid in [
- "refs/heads/missing",
- "refs/tags/v1",
- "main",
- "refs/heads/../bad",
- ] {
- let rejected = http_form_with_headers(
- http,
- "/alice/example/settings/default-branch",
- &[("csrf", csrf), ("default-branch", invalid)],
- &[("Cookie", &cookies)],
- );
- assert!(
- rejected.starts_with("HTTP/1.1 400"),
- "{invalid}: {rejected}"
- );
- }
- let default_branch_update = http_form_with_headers(
- http,
- "/alice/example/settings/default-branch",
- &[("csrf", csrf), ("default-branch", "refs/heads/main")],
- &[("Cookie", &cookies)],
- );
- assert!(default_branch_update.starts_with("HTTP/1.1 303"));
- let updated_pulls =
- http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
- assert!(updated_pulls.contains("value=\"refs/heads/main\" required"));
- assert!(!http_get(http, "/alice/example").contains("trunk head"));
- assert_eq!(
- fs::read_to_string(
- instance
- .path()
- .join("repositories")
- .join(format!("{restored_repository}.git"))
- .join("HEAD")
- )
- .expect("read the changed symbolic HEAD"),
- "ref: refs/heads/main\n"
- );
- let settings_update = http_form_with_headers(
- http,
- "/alice/example/settings/general",
- &[
- ("csrf", csrf),
- ("description", "A repository with self-service settings."),
- ("visibility", "public"),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(settings_update.starts_with("HTTP/1.1 303"));
- let collaborator_update = http_form_with_headers(
- http,
- "/alice/example/settings/collaborators",
- &[
- ("csrf", csrf),
- ("username", "carol"),
- ("role", "reader"),
- ("action", "set"),
- ],
- &[("Cookie", &cookies)],
- );
- assert!(collaborator_update.starts_with("HTTP/1.1 303"));
- let updated_settings =
- http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
- assert!(updated_settings.contains("A repository with self-service settings."));
- assert!(updated_settings.contains("carol · reader"));
- assert!(http_get(http, "/alice/example").contains(
- "<p class=\"repository-description\">A repository with self-service settings.</p>"
- ));
- let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
- assert!(logout_page.starts_with("HTTP/1.1 200"));
- assert!(logout_page.contains("<form method=\"post\" action=\"/logout\">"));
- assert!(logout_page.contains(&format!("name=\"csrf\" value=\"{csrf}\"")));
- let rejected_repository = http_form_with_headers(
- http,
- "/account/repositories",
- &[("csrf", &"0".repeat(64)), ("name", "web-created")],
- &[("Cookie", &cookies)],
- );
- assert!(rejected_repository.starts_with("HTTP/1.1 403"));
- let created_repository = http_form_with_headers(
- http,
- "/account/repositories",
- &[("csrf", csrf), ("name", "web-created")],
- &[("Cookie", &cookies)],
- );
- assert!(created_repository.starts_with("HTTP/1.1 303"));
- assert_eq!(
- response_header(&created_repository, "location"),
- "/alice/web-created"
- );
- let web_create_id = response_header(&created_repository, "x-request-id").to_owned();
- assert!(http_get(http, "/alice/web-created").starts_with("HTTP/1.1 200"));
- let rejected_logout = http_form_with_headers(
- http,
- "/logout",
- &[("csrf", &"0".repeat(64))],
- &[("Cookie", &cookies)],
- );
- assert!(rejected_logout.starts_with("HTTP/1.1 403"));
- let cancelled_logout = http_form_with_headers(
- http,
- "/logout",
- &[("csrf", csrf), ("confirm", "no")],
- &[("Cookie", &cookies)],
- );
- assert!(cancelled_logout.starts_with("HTTP/1.1 303"));
- assert_eq!(response_header(&cancelled_logout, "location"), "/account");
- assert!(
- http_get_with_headers(http, "/account", &[("Cookie", &cookies)])
- .starts_with("HTTP/1.1 200")
- );
- let logout = http_form_with_headers(
- http,
- "/logout",
- &[("csrf", csrf), ("confirm", "yes")],
- &[("Cookie", &cookies)],
- );
- assert!(logout.starts_with("HTTP/1.1 303"));
- let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
- assert!(ended.starts_with("HTTP/1.1 303"));
-
- let upload_challenge_page = http_form(http, "/login", &[("username", "alice")]);
- let upload_challenge = between(
- &upload_challenge_page,
- "<textarea id=\"challenge-display\" readonly rows=\"10\">",
- "</textarea>",
- );
- let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
- let browser_upload_challenge = upload_challenge.replace('\n', "\r\n");
- let upload_csrf_cookies = response_cookies(&upload_challenge_page);
- let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
- let downloaded_challenge = http_form_with_headers(
- http,
- "/login/challenge.txt",
- &[
- ("username", "alice"),
- ("challenge", &browser_upload_challenge),
- ("login-csrf", upload_csrf),
- ],
- &[("Cookie", &upload_csrf_cookies)],
- );
- assert!(downloaded_challenge.starts_with("HTTP/1.1 200"));
- assert_eq!(
- downloaded_challenge
- .split_once("\r\n\r\n")
- .expect("split the challenge response")
- .1,
- upload_challenge
- );
- let wrong_upload_type = http_form_with_headers(
- http,
- "/login/verify-file",
- &[("signature-file", &upload_signature)],
- &[("Cookie", &upload_csrf_cookies)],
- );
- assert!(wrong_upload_type.starts_with("HTTP/1.1 400"));
- let malformed_upload = http_body(
- http,
- "/login/verify-file",
- "multipart/form-data; boundary=tit-broken-boundary",
- "--tit-broken-boundary\r\ninvalid",
- &[("Cookie", &upload_csrf_cookies)],
- );
- assert!(malformed_upload.starts_with("HTTP/1.1 400"));
- let invalid_signature = http_form_with_headers(
- http,
- "/login/verify",
- &[
- ("username", "alice"),
- ("challenge", &browser_upload_challenge),
- ("signature", "not an SSHSIG envelope"),
- ("login-csrf", upload_csrf),
- ],
- &[("Cookie", &upload_csrf_cookies)],
- );
- assert!(invalid_signature.starts_with("HTTP/1.1 400"));
- assert!(invalid_signature.contains("The signature is not valid"));
- let uploaded = http_multipart(
- http,
- "/login/verify-file",
- &[
- ("username", "alice"),
- ("challenge", &browser_upload_challenge),
- ("signature-file", &upload_signature),
- ("login-csrf", upload_csrf),
- ],
- &[("Cookie", &upload_csrf_cookies)],
- );
- assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
- let private_cookies = response_cookies(&uploaded);
- let paste_challenge_page = http_form(http, "/login", &[("username", "alice")]);
- let paste_challenge = between(
- &paste_challenge_page,
- "<textarea id=\"challenge-display\" readonly rows=\"10\">",
- "</textarea>",
- );
- let paste_signature = sign_challenge(instance.path(), &private_key, paste_challenge);
- let paste_cookies = response_cookies(&paste_challenge_page);
- let paste_csrf = cookie_value(&paste_cookies, "tit-login-csrf");
- let pasted = http_form_with_headers(
- http,
- "/login/verify",
- &[
- ("username", "alice"),
- ("challenge", &paste_challenge.replace('\n', "\r\n")),
- ("signature", &paste_signature.replace('\n', "\r\n")),
- ("login-csrf", paste_csrf),
- ],
- &[("Cookie", &paste_cookies)],
- );
- assert!(pasted.starts_with("HTTP/1.1 303"), "{pasted}");
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the repository database");
- database
- .execute(
- "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
- [],
- )
- .expect("make the repository private");
- assert!(http_get(http, "/alice/example").starts_with("HTTP/1.1 404"));
- let public_profile_with_private_repository = http_get(http, "/alice");
- assert!(public_profile_with_private_repository.starts_with("HTTP/1.1 200"));
- assert!(!public_profile_with_private_repository.contains("href=\"/alice/example\""));
- let private_summary =
- http_get_with_headers(http, "/alice/example", &[("Cookie", &private_cookies)]);
- assert!(private_summary.starts_with("HTTP/1.1 200"));
- let private_feed = http_get_with_headers(
- http,
- "/alice/example/rss.xml",
- &[("Cookie", &private_cookies)],
- );
- assert!(private_feed.starts_with("HTTP/1.1 200"));
- assert!(private_feed.contains("cache-control: private, no-store"));
- database
- .execute(
- "UPDATE repository SET visibility = 'public' WHERE slug = 'example'",
- [],
- )
- .expect("make the repository public");
- drop(database);
-
- let invitation_output = Command::new(tit_binary())
- .args([
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "invite-code",
- ])
- .output()
- .expect("request an invitation");
- assert!(invitation_output.status.success());
- let invitation = String::from_utf8(invitation_output.stdout)
- .expect("read the invitation output")
- .trim()
- .strip_prefix("Signup code: ")
- .expect("read the invitation code")
- .to_owned();
- let member_key = instance.path().join("member");
- create_ssh_key_fixture(&member_key);
- let member_public =
- fs::read_to_string(member_key.with_extension("pub")).expect("read the member public key");
- let signup = http_form(
- http,
- "/signup",
- &[
- ("invitation", invitation.as_str()),
- ("username", "bob"),
- ("public-key", member_public.trim()),
- ],
- );
- assert!(signup.starts_with("HTTP/1.1 200"), "{signup}");
- let recovery = between(&signup, "<pre><code>", "</code></pre>");
- assert!(recovery.starts_with("tit-recovery-v1:"));
-
- let summary = http_get(http, "/alice/example");
- assert!(summary.starts_with("HTTP/1.1 200"));
- assert!(summary.contains("serve fixture"));
+ let repository = http_get(http, "/alice/example");
+ assert!(repository.starts_with("HTTP/1.1 200"));
+ assert!(repository.contains("serve fixture"));
let http_clone = instance.path().join("http-clone");
- command(
+ run_git(
instance.path(),
- [
+ &[
"clone",
"-q",
&format!("http://{http}/alice/example.git"),
- http_clone.to_str().expect("a UTF-8 HTTP clone path"),
+ http_clone.to_str().expect("a UTF-8 clone path"),
],
);
assert_eq!(
@@ -679,95 +81,10 @@
b"serve fixture\n"
);
- assert!(ssh_clone_succeeds(
- ssh,
- &member_key,
- &instance.path().join("member-clone")
- ));
-
- let replacement_key = instance.path().join("replacement");
- create_ssh_key_fixture(&replacement_key);
- let replacement_public = fs::read_to_string(replacement_key.with_extension("pub"))
- .expect("read the replacement public key");
- let recovered = http_form(
- http,
- "/recover",
- &[
- ("recovery", recovery),
- ("username", "bob"),
- ("public-key", replacement_public.trim()),
- ],
- );
- assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
- let recovery_id = response_header(&recovered, "x-request-id").to_owned();
- assert!(!ssh_clone_succeeds(
- ssh,
- &member_key,
- &instance.path().join("revoked-clone")
- ));
- assert!(ssh_clone_succeeds(
- ssh,
- &replacement_key,
- &instance.path().join("replacement-clone")
- ));
-
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the audit database");
- let mut statement = database
- .prepare(
- "SELECT action, actor, target, outcome, correlation_id
- FROM audit_event ORDER BY id",
- )
- .expect("prepare the audit query");
- let audits = statement
- .query_map([], |row| {
- Ok((
- row.get::<_, String>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- row.get::<_, String>(3)?,
- row.get::<_, String>(4)?,
- ))
- })
- .expect("query audit history")
- .collect::<Result<Vec<_>, _>>()
- .expect("read audit history");
- assert!(audits.iter().any(|event| {
- event.0 == "login" && event.3 == "failure" && event.4 == rejected_login_id
- }));
- assert!(
- audits
- .iter()
- .any(|event| event.0 == "login" && event.3 == "success" && event.4 == login_id)
- );
- assert!(audits.iter().any(|event| {
- event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
- }));
- assert!(audits.iter().any(|event| {
- event.0 == "repository.create"
- && event.1 == "alice"
- && event.2 == "alice/web-created"
- && event.3 == "success"
- && event.4 == web_create_id
- }));
- for event in &audits {
- let visible = format!(
- "{} {} {} {} {}",
- event.0, event.1, event.2, event.3, event.4
- );
- assert!(!visible.contains(recovery));
- assert!(!visible.contains(secret));
- assert!(!visible.contains(upload_challenge));
- assert!(!visible.contains(&upload_signature));
- assert!(!visible.contains(paste_challenge));
- assert!(!visible.contains(&paste_signature));
- }
- drop(statement);
- drop(database);
-
let ssh_clone = instance.path().join("ssh-clone");
let ssh_command = format!(
- "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+ "ssh -F /dev/null -i {} -o BatchMode=yes -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
private_key.display()
);
let output = Command::new("git")
@@ -779,7 +96,7 @@
.arg(&ssh_clone)
.env("GIT_SSH_COMMAND", ssh_command)
.output()
- .expect("clone through the tit SSH server");
+ .expect("clone through the SSH server");
assert!(
output.status.success(),
"SSH clone failed: {}",
@@ -790,974 +107,19 @@
b"serve fixture\n"
);
- let locked = Command::new(tit_binary())
- .args([
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "admin",
- "repository",
- "inspect",
- "alice",
- "example",
- ])
- .output()
- .expect("run an offline command while the server owns the instance");
- assert_eq!(locked.status.code(), Some(1));
- assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
-
- let metrics = http_get(http, "/metrics");
- assert!(metrics.starts_with("HTTP/1.1 200"));
- assert!(metrics.contains("tit_http_requests_total "));
- assert!(metrics.contains("tit_http_requests_in_flight 1"));
- assert!(metrics.contains("tit_ssh_connections_total "));
- assert!(metrics.contains("tit_ssh_operations_total "));
-
- let authorization_secret = "Bearer tit-secret-authorization";
- let cookie_secret = "tit-session=tit-secret-cookie";
- let feed_secret = "tit-secret-feed-token";
- let _ = http_get_with_headers(
- http,
- "/",
- &[
- ("Authorization", authorization_secret),
- ("Cookie", cookie_secret),
- ],
- );
- let _ = http_get(http, &format!("/feeds/{feed_secret}.atom"));
-
- let logs = server.terminate_capture();
- let logs = String::from_utf8(logs).expect("read structured server logs");
- assert!(!logs.contains(authorization_secret));
- assert!(!logs.contains(cookie_secret));
- assert!(!logs.contains(feed_secret));
- for secret in [
- recovery,
- secret,
- upload_challenge,
- paste_challenge,
- invitation.trim(),
- private_cookies.as_str(),
- upload_signature.as_str(),
- paste_signature.as_str(),
- ] {
- assert!(!logs.contains(secret));
- }
- let events: Vec<serde_json::Value> = logs
- .lines()
- .map(|line| serde_json::from_str(line).expect("parse a structured server log"))
- .collect();
- assert!(events.iter().any(|event| {
- event["event"] == "http.request"
- && event["request_id"]
- .as_str()
- .is_some_and(|request_id| request_id.len() == 32)
- }));
- assert!(events.iter().any(|event| {
- event["event"] == "ssh.operation"
- && event["operation_id"]
- .as_str()
- .is_some_and(|operation_id| operation_id.len() == 32)
- }));
- assert!(
- events
- .iter()
- .any(|event| event["event"] == "server.shutdown" && event["outcome"] == "completed")
- );
-
- assert!(!control_socket.exists());
- let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
- .expect("read the generated SSH host key");
- assert_eq!(
- fs::metadata(instance.path().join("ssh_host_ed25519_key"))
- .expect("inspect the generated SSH host key")
- .permissions()
- .mode()
- & 0o777,
- 0o600
- );
- let mut restarted = spawn_server(&config);
- wait_for_listener(http, &mut restarted);
- wait_for_listener(ssh, &mut restarted);
- assert_eq!(
- fs::read(instance.path().join("ssh_host_ed25519_key"))
- .expect("read the reused SSH host key"),
- host_key
- );
- restarted.terminate();
-}
-
-#[test]
-fn keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh() {
- let _server_test = server_test_lock();
- let instance = TempDir::new().expect("create an instance directory");
- let http = free_address();
- let ssh = free_address();
- let config = instance.path().join("config.toml");
- fs::write(
- &config,
- format!(
- "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
- ssh.port()
- ),
- )
- .expect("write the server configuration");
- let private_key = instance.path().join("administrator");
- create_ssh_key_fixture(&private_key);
- let public_key = fs::read_to_string(private_key.with_extension("pub"))
- .expect("read the administrator public key");
- let config_text = config.to_str().expect("a UTF-8 configuration path");
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "setup",
- "admin",
- "alice",
- public_key.trim(),
- ],
- );
- let source = create_source_repository(instance.path());
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "admin",
- "repository",
- "import",
- "alice",
- "private",
- source.to_str().expect("a UTF-8 source path"),
- ],
- );
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "admin",
- "repository",
- "visibility",
- "alice",
- "private",
- "private",
- ],
- );
- let mut server = spawn_server(&config);
- wait_for_listener(http, &mut server);
- wait_for_listener(ssh, &mut server);
- let discovery = http_get(http, "/alice/private.git/info/refs?service=git-upload-pack");
- assert!(discovery.starts_with("HTTP/1.1 404"), "{discovery}");
- let home = http_get(http, "/");
- assert!(home.starts_with("HTTP/1.1 200"));
- assert!(!home.contains("/alice/private"));
- for route in [
- "/alice/private",
- "/alice/private/refs",
- "/alice/private/rss.xml",
- "/alice/private/search?q=serve&ref=HEAD",
- "/alice/private/commit/main",
- "/alice/private/diff/main/main",
- "/alice/private/tree/main",
- "/alice/private/tree/main/nested",
- "/alice/private/blob/main/README.md",
- "/alice/private/raw/main/README.md",
- "/alice/private/blame/main/README.md",
- "/alice/private/archive/main.tar",
- ] {
- let response = http_get(http, route);
- assert!(
- response.starts_with("HTTP/1.1 404"),
- "route leaked: {route}"
- );
- assert!(!response.contains("serve fixture"), "route leaked: {route}");
- }
-
- let ssh_command = format!(
- "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
- private_key.display()
- );
- let ssh_discovery = Command::new("git")
- .args([
- "ls-remote",
- &format!("ssh://ignored@127.0.0.1:{}/alice/private.git", ssh.port()),
- ])
- .env("GIT_SSH_COMMAND", ssh_command)
- .output()
- .expect("query the private repository through SSH");
- assert!(ssh_discovery.status.success());
-
- let unknown_key = instance.path().join("unknown");
- create_ssh_key_fixture(&unknown_key);
- assert!(!ssh_clone_repository_succeeds(
- ssh,
- &unknown_key,
- "alice",
- "private",
- &instance.path().join("unknown-clone")
- ));
server.terminate();
}
-#[test]
-fn creates_owned_repositories_with_stable_ssh_command_output() {
- let _server_test = server_test_lock();
- let instance = TempDir::new().expect("create an instance directory");
- let http = free_address();
- let ssh = free_address();
- let config = instance.path().join("config.toml");
- fs::write(
- &config,
- format!(
- "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
- ssh.port()
- ),
- )
- .expect("write the server configuration");
- let private_key = instance.path().join("administrator");
- create_ssh_key_fixture(&private_key);
- let public_key = fs::read_to_string(private_key.with_extension("pub"))
- .expect("read the administrator public key");
- let config_text = config.to_str().expect("a UTF-8 configuration path");
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "setup",
- "admin",
- "alice",
- public_key.trim(),
- ],
- );
- let member_key = provision_account(instance.path(), "bob", "active", false);
-
- let mut server = spawn_server(&config);
- wait_for_listener(http, &mut server);
- wait_for_listener(ssh, &mut server);
-
- let human = ssh_exec(ssh, &member_key, &["repo", "create", "example"]);
- assert!(human.status.success());
- assert_eq!(
- String::from_utf8(human.stdout).expect("read human command output"),
- "Created repository bob/example.\n"
- );
- assert!(human.stderr.is_empty());
- assert!(ssh_clone_repository_succeeds(
- ssh,
- &member_key,
- "bob",
- "example",
- &instance.path().join("created-clone")
- ));
- let created_clone = instance.path().join("created-clone");
- command(&created_clone, ["symbolic-ref", "HEAD", "refs/heads/main"]);
- fs::write(created_clone.join("README.md"), b"base\n").expect("write pull-request base");
- git_commit(&created_clone, "create main");
- assert!(git_push(&member_key, &created_clone, &["main"]).success());
- command(&created_clone, ["switch", "-q", "-c", "feature"]);
- fs::write(created_clone.join("feature.txt"), b"feature\n").expect("write pull-request feature");
- git_commit(&created_clone, "create feature");
- assert!(git_push(&member_key, &created_clone, &["feature"]).success());
- let base = git_revision(&created_clone, "main");
- let head = git_revision(&created_clone, "feature");
- command(&created_clone, ["switch", "-q", "main"]);
- command(&created_clone, ["switch", "-q", "-c", "feature-two"]);
- fs::write(created_clone.join("feature-two.txt"), b"second feature\n")
- .expect("write the second pull-request feature");
- git_commit(&created_clone, "create second feature");
- assert!(git_push(&member_key, &created_clone, &["feature-two"]).success());
- let pull_request_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the pull-request command database");
- let repository_id: String = pull_request_database
- .query_row(
- "SELECT id FROM repository WHERE slug = 'example'",
- [],
- |row| row.get(0),
- )
- .expect("read the pull-request repository ID");
- let bare = instance
- .path()
- .join("repositories")
- .join(format!("{repository_id}.git"));
- command(
- instance.path(),
- [
- "--git-dir",
- bare.to_str().expect("a UTF-8 bare path"),
- "update-ref",
- "refs/pull/1/head",
- &head,
- ],
- );
- pull_request_database
- .execute(
- "INSERT INTO pull_request
- (id, repository_id, number, title, body, state, author_account_id,
- base_ref, head_ref, base_object_id, head_object_id, created_at, updated_at)
- SELECT '11111111111111111111111111111111', ?1, 1, 'Feature', '', 'open',
- account.id, 'refs/heads/main', 'refs/heads/feature', ?2, ?3, 10, 10
- FROM account WHERE username = 'bob'",
- rusqlite::params![repository_id, base, head],
- )
- .expect("create a pull-request command fixture");
- pull_request_database
- .execute(
- "INSERT INTO pull_request_revision
- (id, pull_request_id, number, author_account_id, base_object_id,
- head_object_id, created_at)
- SELECT '22222222222222222222222222222222',
- '11111111111111111111111111111111', 1, account.id, ?1, ?2, 10
- FROM account WHERE username = 'bob'",
- rusqlite::params![base, head],
- )
- .expect("create a pull-request revision fixture");
- pull_request_database
- .execute(
- "INSERT INTO repository_counter (repository_id, next_pull_request_number)
- VALUES (?1, 2)
- ON CONFLICT (repository_id)
- DO UPDATE SET next_pull_request_number = 2",
- [&repository_id],
- )
- .expect("advance the pull-request number fixture");
- drop(pull_request_database);
-
- let human_checkout = ssh_exec(ssh, &member_key, &["pr", "checkout", "bob/example", "1"]);
- assert!(human_checkout.status.success());
- assert_eq!(
- String::from_utf8(human_checkout.stdout).expect("read human checkout output"),
- "git fetch origin refs/pull/1/head:refs/heads/pr-1\ngit checkout pr-1\n"
- );
- assert!(human_checkout.stderr.is_empty());
- let machine_checkout = ssh_exec(
- ssh,
- &member_key,
- &["pr", "checkout", "bob/example", "1", "--output", "json"],
- );
- assert!(machine_checkout.status.success());
- let machine_checkout: serde_json::Value =
- serde_json::from_slice(&machine_checkout.stdout).expect("parse machine checkout output");
- assert_eq!(machine_checkout["version"], 1);
- assert_eq!(machine_checkout["status"], "success");
- assert_eq!(machine_checkout["repository"]["owner"], "bob");
- assert_eq!(machine_checkout["repository"]["name"], "example");
- assert_eq!(machine_checkout["pull_request"]["number"], 1);
- assert_eq!(machine_checkout["pull_request"]["ref"], "refs/pull/1/head");
- assert_eq!(
- machine_checkout["commands"]["fetch"],
- "git fetch origin refs/pull/1/head:refs/heads/pr-1"
- );
- let listed_pull_requests = ssh_exec(
- ssh,
- &member_key,
- &[
- "pr",
- "list",
- "bob/example",
- "--state",
- "open",
- "--output",
- "json",
- ],
- );
- assert!(listed_pull_requests.status.success());
- let listed_pull_requests: serde_json::Value =
- serde_json::from_slice(&listed_pull_requests.stdout).expect("parse pull-request list");
- assert_eq!(listed_pull_requests["version"], 1);
- assert_eq!(listed_pull_requests["pull_requests"][0]["number"], 1);
- let closed_pull_request = ssh_exec(ssh, &member_key, &["pr", "close", "bob/example", "1"]);
- assert!(closed_pull_request.status.success());
- assert_eq!(
- String::from_utf8(closed_pull_request.stdout).expect("read pull-request close output"),
- "Closed pull request bob/example#1.\n"
- );
- assert!(
- ssh_exec(ssh, &member_key, &["pr", "reopen", "bob/example", "1"])
- .status
- .success()
- );
- let created_pull_request = ssh_exec_with_input(
- ssh,
- &member_key,
- &[
- "pr",
- "create",
- "bob/example",
- "refs/heads/main",
- "refs/heads/feature-two",
- "--output",
- "json",
- ],
- b"Second feature\nOpened through SSH.\n",
- );
- assert!(
- created_pull_request.status.success(),
- "pr create failed: stdout={} stderr={}",
- String::from_utf8_lossy(&created_pull_request.stdout),
- String::from_utf8_lossy(&created_pull_request.stderr)
- );
- let created_pull_request: serde_json::Value =
- serde_json::from_slice(&created_pull_request.stdout)
- .expect("parse pull-request create output");
- assert_eq!(created_pull_request["version"], 1);
- assert_eq!(created_pull_request["pull_request"]["number"], 2);
- let checkout_clone = instance.path().join("pull-request-checkout");
- assert!(ssh_clone_repository_succeeds(
- ssh,
- &member_key,
- "bob",
- "example",
- &checkout_clone
- ));
- assert!(
- git_fetch_ref(
- &member_key,
- &checkout_clone,
- "refs/pull/1/head:refs/heads/pr-1"
- )
- .success()
- );
- command(&checkout_clone, ["checkout", "-q", "pr-1"]);
- assert_eq!(git_revision(&checkout_clone, "HEAD"), head);
-
- let machine = ssh_exec(
- ssh,
- &private_key,
- &["repo", "create", "hash-agile", "--output", "json"],
- );
- assert!(machine.status.success());
- assert_eq!(
- String::from_utf8(machine.stdout).expect("read machine command output"),
- "{\"version\":1,\"status\":\"success\",\"repository\":{\"owner\":\"alice\",\"name\":\"hash-agile\"}}\n"
- );
- assert!(machine.stderr.is_empty());
-
- let created_issue = ssh_exec_with_input(
- ssh,
- &member_key,
- &["issue", "create", "bob/example"],
- b"First issue\nBody with **Markdown**.\n",
- );
- assert!(created_issue.status.success());
- assert_eq!(
- String::from_utf8(created_issue.stdout).expect("read issue create output"),
- "Created issue bob/example#1.\n"
- );
- assert!(created_issue.stderr.is_empty());
- let human_issues = ssh_exec(ssh, &member_key, &["issue", "list", "bob/example"]);
- assert!(human_issues.status.success());
- assert_eq!(
- String::from_utf8(human_issues.stdout).expect("read human issue list"),
- "#1 open First issue\n"
- );
- let machine_issues = ssh_exec(
- ssh,
- &member_key,
- &["issue", "list", "bob/example", "--output", "json"],
- );
- assert!(machine_issues.status.success());
- let machine_issues: serde_json::Value =
- serde_json::from_slice(&machine_issues.stdout).expect("parse machine issue list");
- assert_eq!(machine_issues["version"], 1);
- assert_eq!(machine_issues["status"], "success");
- assert_eq!(machine_issues["repository"]["owner"], "bob");
- assert_eq!(machine_issues["repository"]["name"], "example");
- assert_eq!(machine_issues["issues"][0]["number"], 1);
- assert_eq!(machine_issues["issues"][0]["title"], "First issue");
- let issue_comment = ssh_exec_with_input(
- ssh,
- &member_key,
- &["issue", "comment", "bob/example", "1", "--output", "json"],
- b"A bounded SSH comment.",
- );
- assert!(issue_comment.status.success());
- let issue_comment: serde_json::Value =
- serde_json::from_slice(&issue_comment.stdout).expect("parse issue comment");
- assert_eq!(issue_comment["operation"], "commented");
- assert!(issue_comment["comment_id"].is_string());
- assert!(
- ssh_exec(ssh, &member_key, &["issue", "close", "bob/example", "1"])
- .status
- .success()
- );
- let reopened_issue = ssh_exec(
- ssh,
- &member_key,
- &["issue", "reopen", "bob/example", "1", "--output", "json"],
- );
- assert!(reopened_issue.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&reopened_issue.stdout)
- .expect("parse issue reopen")["operation"],
- "reopened"
- );
-
- let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the issue access database");
- access_database
- .execute_batch("UPDATE repository SET visibility = 'private' WHERE slug = 'example';")
- .expect("make the issue repository private");
- let hidden = ssh_exec(
- ssh,
- &private_key,
- &["issue", "list", "bob/example", "--output", "json"],
- );
- assert!(!hidden.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&hidden.stdout)
- .expect("parse hidden issue error")["error"]["code"],
- "repository-unavailable"
- );
- let hidden_checkout = ssh_exec(
- ssh,
- &private_key,
- &["pr", "checkout", "bob/example", "1", "--output", "json"],
- );
- assert!(!hidden_checkout.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&hidden_checkout.stdout)
- .expect("parse hidden pull-request error")["error"]["code"],
- "pull-request-unavailable"
- );
- access_database
- .execute(
- "INSERT INTO repository_collaborator
- (repository_id, account_id, role, created_at)
- SELECT repository.id, account.id, 'reader', 10
- FROM repository, account
- WHERE repository.slug = 'example' AND account.username = 'alice'",
- [],
- )
- .expect("give the administrator reader access");
- drop(access_database);
- let denied_pull_request = ssh_exec(
- ssh,
- &private_key,
- &["pr", "close", "bob/example", "1", "--output", "json"],
- );
- assert!(!denied_pull_request.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&denied_pull_request.stdout)
- .expect("parse denied pull-request mutation")["error"]["code"],
- "permission-denied"
- );
- let reader_create = ssh_exec_with_input(
- ssh,
- &private_key,
- &["issue", "create", "bob/example", "--output", "json"],
- b"Reader issue\nCreated through the shared service.",
- );
- assert!(reader_create.status.success());
- let reader_create: serde_json::Value =
- serde_json::from_slice(&reader_create.stdout).expect("parse machine issue create");
- assert_eq!(reader_create["version"], 1);
- assert_eq!(reader_create["status"], "success");
- assert_eq!(reader_create["issue"]["number"], 2);
- assert_eq!(reader_create["issue"]["author"], "alice");
-
- let invalid_issue = ssh_exec_with_input(
- ssh,
- &private_key,
- &["issue", "create", "bob/example", "--output", "json"],
- b"\nbody without a title",
- );
- assert!(!invalid_issue.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&invalid_issue.stdout)
- .expect("parse invalid issue input")["error"]["code"],
- "invalid-input"
- );
-
- let duplicate = ssh_exec(
- ssh,
- &member_key,
- &["repo", "create", "example", "--output", "json"],
- );
- assert!(!duplicate.status.success());
- assert_eq!(
- String::from_utf8(duplicate.stdout).expect("read machine error output"),
- "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"repository-exists\"}}\n"
- );
- assert!(duplicate.stderr.is_empty());
-
- let invalid = ssh_exec(ssh, &member_key, &["repo", "create", "../bad"]);
- assert!(!invalid.status.success());
- assert!(invalid.stdout.is_empty());
- assert_eq!(
- String::from_utf8(invalid.stderr).expect("read human error output"),
- "tit: The repository name is not valid.\n"
- );
- let malformed = ssh_exec(ssh, &member_key, &["repo", "create", "--output", "json"]);
- assert!(!malformed.status.success());
- assert_eq!(
- String::from_utf8(malformed.stdout).expect("read invalid command output"),
- "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"invalid-command\"}}\n"
- );
- assert!(malformed.stderr.is_empty());
-
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the account database");
- database
- .execute(
- "UPDATE account SET state = 'suspended' WHERE username = 'bob'",
- [],
- )
- .expect("suspend the command account");
- drop(database);
- let suspended = ssh_exec(
- ssh,
- &member_key,
- &["repo", "create", "blocked", "--output", "json"],
- );
- assert!(!suspended.status.success());
- assert_eq!(
- String::from_utf8(suspended.stdout).expect("read suspended account output"),
- "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
- );
- assert!(suspended.stderr.is_empty());
- let suspended_issues = ssh_exec(
- ssh,
- &member_key,
- &["issue", "list", "bob/example", "--output", "json"],
- );
- assert!(!suspended_issues.status.success());
- assert_eq!(
- serde_json::from_slice::<serde_json::Value>(&suspended_issues.stdout)
- .expect("parse suspended issue output")["error"]["code"],
- "repository-unavailable"
- );
- server.terminate();
-
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the repository database");
- let repositories: Vec<(String, String, String)> = database
- .prepare(
- "SELECT account.username, repository.slug, repository.object_format
- FROM repository JOIN account ON account.id = repository.owner_account_id
- ORDER BY repository.slug",
- )
- .expect("prepare the repository query")
- .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
- .expect("query created repositories")
- .collect::<Result<_, _>>()
- .expect("read created repositories");
- assert_eq!(
- repositories,
- vec![
- ("bob".to_owned(), "example".to_owned(), "sha1".to_owned()),
- (
- "alice".to_owned(),
- "hash-agile".to_owned(),
- "sha1".to_owned()
- ),
- ]
- );
- let issues: Vec<(i64, String, String, String)> = database
- .prepare(
- "SELECT issue.number, issue.title, issue.body, account.username
- FROM issue JOIN account ON account.id = issue.author_account_id
- ORDER BY issue.number",
- )
- .expect("prepare the issue query")
- .query_map([], |row| {
- Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
- })
- .expect("query created issues")
- .collect::<Result<_, _>>()
- .expect("read created issues");
- assert_eq!(
- issues,
- vec![
- (
- 1,
- "First issue".to_owned(),
- "Body with **Markdown**.\n".to_owned(),
- "bob".to_owned(),
- ),
- (
- 2,
- "Reader issue".to_owned(),
- "Created through the shared service.".to_owned(),
- "alice".to_owned(),
- ),
- ]
- );
- let issue_events: i64 = database
- .query_row(
- "SELECT count(*) FROM repository_event WHERE kind = 'issue-created'",
- [],
- |row| row.get(0),
- )
- .expect("count issue create events");
- assert_eq!(issue_events, 2);
- let audit: Vec<(String, String)> = database
- .prepare(
- "SELECT target, outcome FROM audit_event
- WHERE action = 'repository.create' AND actor IN ('alice', 'bob')
- ORDER BY id",
- )
- .expect("prepare the repository audit query")
- .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
- .expect("query repository audit events")
- .collect::<Result<_, _>>()
- .expect("read repository audit events");
- assert_eq!(
- audit,
- vec![
- ("bob/example".to_owned(), "success".to_owned()),
- ("alice/hash-agile".to_owned(), "success".to_owned()),
- ("bob/example".to_owned(), "failure".to_owned()),
- ("bob/blocked".to_owned(), "failure".to_owned()),
- ]
- );
+fn free_address() -> SocketAddr {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind a temporary port");
+ listener.local_addr().expect("read the temporary address")
}
-#[test]
-fn enforces_account_roles_and_ref_policy_through_the_production_ssh_server() {
- let _server_test = server_test_lock();
- let instance = TempDir::new().expect("create an instance directory");
- let http = free_address();
- let ssh = free_address();
- let config = instance.path().join("config.toml");
- fs::write(
- &config,
- format!(
- "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
- ssh.port()
- ),
- )
- .expect("write the server configuration");
- let config_text = config.to_str().expect("a UTF-8 configuration path");
- let owner_key = instance.path().join("owner");
- create_ssh_key_fixture(&owner_key);
- let owner_public =
- fs::read_to_string(owner_key.with_extension("pub")).expect("read the owner public key");
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "setup",
- "admin",
- "alice",
- owner_public.trim(),
- ],
- );
-
- let source = create_source_repository(instance.path());
- for repository in ["private", "public"] {
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "admin",
- "repository",
- "import",
- "alice",
- repository,
- source.to_str().expect("a UTF-8 source path"),
- ],
- );
- }
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "admin",
- "repository",
- "visibility",
- "alice",
- "private",
- "private",
- ],
- );
-
- let maintainer_key = provision_account(instance.path(), "maintainer", "active", false);
- let writer_key = provision_account(instance.path(), "writer", "active", false);
- let reader_key = provision_account(instance.path(), "reader", "active", false);
- let outsider_key = provision_account(instance.path(), "outsider", "active", false);
- let suspended_key = provision_account(instance.path(), "suspended", "active", false);
- let revoked_key = provision_account(instance.path(), "revoked", "active", true);
- for (username, role) in [
- ("maintainer", "maintainer"),
- ("writer", "writer"),
- ("reader", "reader"),
- ("suspended", "writer"),
- ("revoked", "writer"),
- ] {
- command(
- instance.path(),
- [
- "--config",
- config_text,
- "admin",
- "repository",
- "collaborator-set",
- "alice",
- "private",
- username,
- role,
- ],
- );
- }
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the repository database");
- database
- .execute(
- "UPDATE account SET state = 'suspended' WHERE username = 'suspended'",
- [],
- )
- .expect("suspend an account fixture");
- drop(database);
-
- let mut server = spawn_server(&config);
- wait_for_listener(http, &mut server);
- wait_for_listener(ssh, &mut server);
- for (name, key) in [
- ("owner", &owner_key),
- ("maintainer", &maintainer_key),
- ("writer", &writer_key),
- ("reader", &reader_key),
- ] {
- assert!(ssh_clone_repository_succeeds(
- ssh,
- key,
- "alice",
- "private",
- &instance.path().join(format!("{name}-private"))
- ));
- }
- for (name, key) in [
- ("outsider", &outsider_key),
- ("suspended", &suspended_key),
- ("revoked", &revoked_key),
- ] {
- assert!(!ssh_clone_repository_succeeds(
- ssh,
- key,
- "alice",
- "private",
- &instance.path().join(format!("{name}-private"))
- ));
- }
- assert!(ssh_clone_repository_succeeds(
- ssh,
- &outsider_key,
- "alice",
- "public",
- &instance.path().join("outsider-public")
- ));
-
- let writer_clone = instance.path().join("writer-private");
- command(&writer_clone, ["switch", "-q", "-c", "topic"]);
- fs::write(writer_clone.join("writer.txt"), b"writer update\n").expect("write a writer change");
- git_commit(&writer_clone, "writer update");
- assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
- fs::write(writer_clone.join("writer-2.txt"), b"second writer update\n")
- .expect("write a second writer change");
- git_commit(&writer_clone, "second writer update");
- assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
- assert!(git_push(&writer_key, &writer_clone, &["--delete", "topic"]).success());
- assert!(!git_push(&writer_key, &writer_clone, &["HEAD:trunk"]).success());
- assert!(!git_push(&writer_key, &writer_clone, &["HEAD:refs/notes/test"]).success());
-
- let owner_clone = instance.path().join("owner-private");
- fs::write(owner_clone.join("owner.txt"), b"owner update\n").expect("write an owner change");
- git_commit(&owner_clone, "owner update");
- assert!(git_push(&owner_key, &owner_clone, &["trunk"]).success());
- assert!(!git_push(&owner_key, &owner_clone, &["--delete", "trunk"]).success());
- command(&owner_clone, ["switch", "-q", "-c", "force-test"]);
- fs::write(owner_clone.join("force.txt"), b"first history\n").expect("write a branch change");
- git_commit(&owner_clone, "first branch history");
- assert!(git_push(&owner_key, &owner_clone, &["force-test"]).success());
- command(&owner_clone, ["reset", "--hard", "HEAD~1"]);
- fs::write(owner_clone.join("force.txt"), b"replacement history\n")
- .expect("write replacement history");
- git_commit(&owner_clone, "replacement branch history");
- let force_result = git_push_output(&owner_key, &owner_clone, &["--force", "force-test"]);
- assert!(!force_result.status.success());
- assert!(String::from_utf8_lossy(&force_result.stderr).contains("non-fast-forward"));
-
- let reader_clone = instance.path().join("reader-write-private");
- assert!(ssh_clone_repository_succeeds(
- ssh,
- &reader_key,
- "alice",
- "private",
- &reader_clone
- ));
- fs::write(reader_clone.join("reader.txt"), b"reader update\n").expect("write a reader change");
- git_commit(&reader_clone, "reader update");
- assert!(!git_push(&reader_key, &reader_clone, &["main"]).success());
-
- command(&writer_clone, ["switch", "-q", "main"]);
- fs::write(writer_clone.join("removed-role.txt"), b"removed role\n")
- .expect("write a change before role removal");
- git_commit(&writer_clone, "change before role removal");
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the repository database");
- database
- .execute(
- "DELETE FROM repository_collaborator
- WHERE account_id = (SELECT id FROM account WHERE username = 'writer')",
- [],
- )
- .expect("remove the writer role");
- drop(database);
- assert!(
- !git_push(
- &writer_key,
- &writer_clone,
- &["HEAD:refs/heads/removed-role"]
- )
- .success()
- );
- server.terminate();
- let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
- .expect("open the push audit database");
- let successful: i64 = database
- .query_row(
- "SELECT count(*) FROM audit_event
- WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'success'",
- [],
- |row| row.get(0),
- )
- .expect("count successful push audit events");
- let failed: i64 = database
- .query_row(
- "SELECT count(*) FROM audit_event
- WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'failure'",
- [],
- |row| row.get(0),
- )
- .expect("count failed push audit events");
- assert_eq!(successful, 3);
- assert!(failed >= 2);
-}
-
-fn spawn_server(config: &Path) -> ChildGuard {
- let child = Command::new(tit_binary())
- .args([
- "--config",
- config.to_str().expect("a UTF-8 configuration path"),
- "serve",
- ])
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .expect("start the tit server");
- ChildGuard(Some(child))
-}
-
-fn create_source_repository(parent: &Path) -> std::path::PathBuf {
+fn create_source_repository(parent: &Path) -> PathBuf {
let worktree = parent.join("source-worktree");
- command(
+ run_git(
parent,
- [
+ &[
"init",
"-q",
"-b",
@@ -1766,7 +128,8 @@
],
);
fs::write(worktree.join("README.md"), b"serve fixture\n").expect("write source content");
- command(&worktree, ["add", "."]);
+ run_git(&worktree, &["add", "."]);
+
let output = Command::new("git")
.args(["commit", "-q", "-m", "initial"])
.env("GIT_AUTHOR_NAME", "Tit Test")
@@ -1779,15 +142,16 @@
.current_dir(&worktree)
.output()
.expect("commit source content");
- assert!(output.status.success(), "Git commit failed");
- command(&worktree, ["branch", "trunk"]);
- command(&worktree, ["switch", "-q", "trunk"]);
- fs::write(worktree.join("TRUNK.md"), b"trunk default\n").expect("write trunk content");
- git_commit(&worktree, "trunk head");
+ assert!(
+ output.status.success(),
+ "Git commit failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
let bare = parent.join("source.git");
- command(
+ run_git(
parent,
- [
+ &[
"clone",
"-q",
"--bare",
@@ -1798,28 +162,50 @@
bare
}
-fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
- let executable = if matches!(arguments.first(), Some(&"--config")) {
- tit_binary()
- } else {
- "git".into()
- };
- let output = Command::new(executable)
+fn run_git(directory: &Path, arguments: &[&str]) {
+ let output = Command::new("git")
.args(arguments)
.current_dir(directory)
.output()
- .expect("run a fixture command");
+ .expect("run Git");
assert!(
output.status.success(),
- "fixture command failed: {}",
+ "Git command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
-fn tit_binary() -> std::path::PathBuf {
+fn run_tit(directory: &Path, config: &Path, arguments: &[&str]) {
+ let output = Command::new(tit_binary())
+ .arg("--config")
+ .arg(config)
+ .args(arguments)
+ .current_dir(directory)
+ .output()
+ .expect("run tit");
+ assert!(
+ output.status.success(),
+ "tit command failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+fn tit_binary() -> PathBuf {
env::var_os("TIT_RELEASE_BINARY")
.map(Into::into)
.unwrap_or_else(|| env!("CARGO_BIN_EXE_tit").into())
+}
+
+fn spawn_server(config: &Path) -> ChildGuard {
+ let child = Command::new(tit_binary())
+ .arg("--config")
+ .arg(config)
+ .arg("serve")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("start the tit server");
+ ChildGuard(Some(child))
}
fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
@@ -1851,463 +237,47 @@
}
fn http_get(address: SocketAddr, path: &str) -> String {
- http_get_with_headers(address, path, &[])
-}
-
-fn http_get_with_headers(address: SocketAddr, path: &str, headers: &[(&str, &str)]) -> String {
let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- let mut request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str("\r\n");
+ let timeout = Some(Duration::from_secs(10));
stream
- .write_all(request.as_bytes())
- .expect("write an HTTP request");
+ .set_read_timeout(timeout)
+ .expect("set the HTTP read timeout");
+ stream
+ .set_write_timeout(timeout)
+ .expect("set the HTTP write timeout");
+ write!(
+ stream,
+ "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+ )
+ .expect("write an HTTP request");
+
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("read an HTTP response");
response
-}
-
-fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
- http_form_with_headers(address, path, fields, &[])
-}
-
-fn http_form_with_headers(
- address: SocketAddr,
- path: &str,
- fields: &[(&str, &str)],
- headers: &[(&str, &str)],
-) -> String {
- let body = url::form_urlencoded::Serializer::new(String::new())
- .extend_pairs(fields.iter().copied())
- .finish();
- let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- let mut request = format!(
- "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n",
- body.len()
- );
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str("\r\n");
- request.push_str(&body);
- stream
- .write_all(request.as_bytes())
- .expect("write an HTTP form");
- let mut response = String::new();
- stream
- .read_to_string(&mut response)
- .expect("read an HTTP response");
- response
-}
-
-fn response_cookies(response: &str) -> String {
- response
- .lines()
- .filter_map(|line| line.split_once(':'))
- .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
- .map(|(_, value)| {
- value
- .trim()
- .split_once(';')
- .map_or(value.trim(), |(cookie, _)| cookie)
- })
- .collect::<Vec<_>>()
- .join("; ")
-}
-
-fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
- response
- .lines()
- .filter_map(|line| line.split_once(':'))
- .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
- .map(|(_, value)| value.trim())
- .expect("read a response header")
-}
-
-fn http_multipart(
- address: SocketAddr,
- path: &str,
- fields: &[(&str, &str)],
- headers: &[(&str, &str)],
-) -> String {
- let boundary = "tit-test-boundary";
- let mut body = String::new();
- for (name, value) in fields {
- if *name == "signature-file" {
- body.push_str(&format!(
- "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"signature.sig\"\r\nContent-Type: application/octet-stream\r\n\r\n{value}\r\n"
- ));
- } else {
- body.push_str(&format!(
- "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
- ));
- }
- }
- body.push_str(&format!("--{boundary}--\r\n"));
- let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- let mut request = format!(
- "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: multipart/form-data; boundary={boundary}\r\nContent-Length: {}\r\nConnection: close\r\n",
- body.len()
- );
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str("\r\n");
- request.push_str(&body);
- stream
- .write_all(request.as_bytes())
- .expect("write a multipart HTTP form");
- let mut response = String::new();
- stream
- .read_to_string(&mut response)
- .expect("read an HTTP response");
- response
-}
-
-fn http_body(
- address: SocketAddr,
- path: &str,
- content_type: &str,
- body: &str,
- headers: &[(&str, &str)],
-) -> String {
- let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- let mut request = format!(
- "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
- body.len()
- );
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str("\r\n");
- request.push_str(body);
- stream
- .write_all(request.as_bytes())
- .expect("write an HTTP request body");
- let mut response = String::new();
- stream
- .read_to_string(&mut response)
- .expect("read an HTTP response");
- response
-}
-
-fn cookie_value<'a>(cookies: &'a str, name: &str) -> &'a str {
- cookies
- .split("; ")
- .find_map(|cookie| cookie.strip_prefix(&format!("{name}=")))
- .expect("find the cookie")
-}
-
-fn sign_challenge(directory: &Path, private_key: &Path, challenge: &str) -> String {
- let nonce = challenge
- .lines()
- .find_map(|line| line.strip_prefix("nonce="))
- .expect("find the Web login nonce");
- let path = directory.join(format!("web-login-{nonce}.challenge"));
- fs::write(&path, challenge).expect("write the Web login challenge");
- let output = Command::new("ssh-keygen")
- .args(["-q", "-Y", "sign", "-f"])
- .arg(private_key)
- .args(["-n", "tit-auth"])
- .arg(&path)
- .output()
- .expect("sign the Web login challenge");
- assert!(
- output.status.success(),
- "cannot sign the Web login challenge: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- fs::read_to_string(path.with_extension("challenge.sig")).expect("read the Web login signature")
-}
-
-fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
- value
- .split_once(start)
- .and_then(|(_, tail)| tail.split_once(end))
- .map(|(value, _)| value)
- .expect("find the response value")
-}
-
-fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
- ssh_clone_repository_succeeds(address, private_key, "alice", "example", target)
-}
-
-fn ssh_clone_repository_succeeds(
- address: SocketAddr,
- private_key: &Path,
- owner: &str,
- repository: &str,
- target: &Path,
-) -> bool {
- let ssh_command = format!(
- "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
- private_key.display()
- );
- Command::new("git")
- .args([
- "clone",
- "-q",
- &format!(
- "ssh://ignored@127.0.0.1:{}/{owner}/{repository}.git",
- address.port(),
- ),
- ])
- .arg(target)
- .env("GIT_SSH_COMMAND", ssh_command)
- .output()
- .expect("clone through the tit SSH server")
- .status
- .success()
-}
-
-fn ssh_exec(address: SocketAddr, private_key: &Path, command: &[&str]) -> Output {
- Command::new("ssh")
- .args([
- "-F",
- "/dev/null",
- "-o",
- "BatchMode=yes",
- "-o",
- "IdentitiesOnly=yes",
- "-o",
- "StrictHostKeyChecking=no",
- "-o",
- "UserKnownHostsFile=/dev/null",
- "-o",
- "LogLevel=ERROR",
- "-i",
- ])
- .arg(private_key)
- .args(["-p", &address.port().to_string()])
- .arg(format!("ignored@{}", address.ip()))
- .args(command)
- .output()
- .expect("run an SSH repository command")
-}
-
-fn ssh_exec_with_input(
- address: SocketAddr,
- private_key: &Path,
- command: &[&str],
- input: &[u8],
-) -> Output {
- let mut child = Command::new("ssh")
- .args([
- "-F",
- "/dev/null",
- "-o",
- "BatchMode=yes",
- "-o",
- "IdentitiesOnly=yes",
- "-o",
- "StrictHostKeyChecking=no",
- "-o",
- "UserKnownHostsFile=/dev/null",
- "-o",
- "LogLevel=ERROR",
- "-i",
- ])
- .arg(private_key)
- .args(["-p", &address.port().to_string()])
- .arg(format!("ignored@{}", address.ip()))
- .args(command)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .expect("start an SSH issue command");
- child
- .stdin
- .take()
- .expect("open SSH issue input")
- .write_all(input)
- .expect("write SSH issue input");
- child
- .wait_with_output()
- .expect("finish an SSH issue command")
-}
-
-fn provision_account(
- instance: &Path,
- username: &str,
- state: &str,
- revoked: bool,
-) -> std::path::PathBuf {
- let private_key = instance.join(username);
- create_ssh_key_fixture(&private_key);
- let public_key =
- fs::read_to_string(private_key.with_extension("pub")).expect("read an account public key");
- let mut fields = public_key.split_whitespace();
- let canonical = format!(
- "{} {}",
- fields.next().expect("read the key algorithm"),
- fields.next().expect("read the key data")
- );
- let fingerprint_output = Command::new("ssh-keygen")
- .args(["-E", "sha256", "-lf"])
- .arg(private_key.with_extension("pub"))
- .output()
- .expect("read an SSH key fingerprint");
- assert!(fingerprint_output.status.success());
- let fingerprint_text =
- String::from_utf8(fingerprint_output.stdout).expect("read a UTF-8 SSH key fingerprint");
- let fingerprint = fingerprint_text
- .split_whitespace()
- .nth(1)
- .expect("read the SSH key fingerprint");
- let database = rusqlite::Connection::open(instance.join("tit.sqlite3"))
- .expect("open the repository database");
- database
- .execute(
- "INSERT INTO account (username, is_administrator, state, created_at)
- VALUES (?1, 0, ?2, 1)",
- rusqlite::params![username, state],
- )
- .expect("create an account fixture");
- let account_id = database.last_insert_rowid();
- database
- .execute(
- "INSERT INTO ssh_public_key
- (account_id, canonical_key, fingerprint, created_at, label, revoked_at)
- VALUES (?1, ?2, ?3, 1, 'initial', ?4)",
- rusqlite::params![account_id, canonical, fingerprint, revoked.then_some(2)],
- )
- .expect("create an SSH key fixture");
- private_key
-}
-
-fn ssh_fingerprint(private_key: &Path) -> String {
- let output = Command::new("ssh-keygen")
- .args(["-E", "sha256", "-lf"])
- .arg(private_key.with_extension("pub"))
- .output()
- .expect("read an SSH key fingerprint");
- assert!(output.status.success());
- String::from_utf8(output.stdout)
- .expect("read a UTF-8 SSH key fingerprint")
- .split_whitespace()
- .nth(1)
- .expect("read the SSH key fingerprint")
- .to_owned()
-}
-
-fn git_commit(worktree: &Path, message: &str) {
- command(worktree, ["add", "."]);
- let output = Command::new("git")
- .args(["commit", "-q", "-m", message])
- .env("GIT_AUTHOR_NAME", "Tit Test")
- .env("GIT_AUTHOR_EMAIL", "tit@example.test")
- .env("GIT_COMMITTER_NAME", "Tit Test")
- .env("GIT_COMMITTER_EMAIL", "tit@example.test")
- .env("GIT_CONFIG_COUNT", "1")
- .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
- .env("GIT_CONFIG_VALUE_0", "false")
- .current_dir(worktree)
- .output()
- .expect("commit a Git change");
- assert!(
- output.status.success(),
- "Git commit failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
-
-fn git_push(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> ExitStatus {
- git_push_output(private_key, worktree, refspecs).status
-}
-
-fn git_push_output(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> Output {
- let ssh_command = format!(
- "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
- private_key.display()
- );
- Command::new("git")
- .arg("push")
- .arg("origin")
- .args(refspecs)
- .env("GIT_SSH_COMMAND", ssh_command)
- .current_dir(worktree)
- .output()
- .expect("push through the tit SSH server")
-}
-
-fn git_fetch_ref(private_key: &Path, worktree: &Path, refspec: &str) -> ExitStatus {
- let ssh_command = format!(
- "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
- private_key.display()
- );
- Command::new("git")
- .args(["fetch", "origin", refspec])
- .env("GIT_SSH_COMMAND", ssh_command)
- .current_dir(worktree)
- .status()
- .expect("fetch through the tit SSH server")
-}
-
-fn git_revision(worktree: &Path, revision: &str) -> String {
- let output = Command::new("git")
- .args(["rev-parse", revision])
- .current_dir(worktree)
- .output()
- .expect("resolve a Git revision");
- assert!(
- output.status.success(),
- "cannot resolve Git revision: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- String::from_utf8(output.stdout)
- .expect("read a Git revision")
- .trim()
- .to_owned()
}
struct ChildGuard(Option<Child>);
impl ChildGuard {
- fn terminate_capture(&mut self) -> Vec<u8> {
- let child = self.0.take().expect("the server process is active");
+ fn terminate(&mut self) {
+ let mut child = self.0.take().expect("the server process is active");
let signal = Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.output()
.expect("send SIGTERM to the tit server");
assert!(signal.status.success(), "cannot send SIGTERM");
- let output = child
- .wait_with_output()
- .expect("wait for the tit server and read its output");
- assert!(
- output.status.success(),
- "tit serve did not stop cleanly: {}",
- output.status
- );
- output.stderr
- }
-
- fn terminate(&mut self) {
- if let Some(mut child) = self.0.take() {
- let signal = Command::new("kill")
- .args(["-TERM", &child.id().to_string()])
- .output()
- .expect("send SIGTERM to the tit server");
- assert!(signal.status.success(), "cannot send SIGTERM");
- let status = child.wait().expect("wait for the tit server");
- assert!(status.success(), "tit serve did not stop cleanly: {status}");
- }
- }
-
- fn stop(&mut self) {
- if let Some(mut child) = self.0.take() {
- child.kill().expect("stop the tit server");
- child.wait().expect("wait for the tit server");
- }
+ let status = child.wait().expect("wait for the tit server");
+ assert!(status.success(), "tit serve did not stop cleanly: {status}");
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
- self.stop();
+ if let Some(mut child) = self.0.take() {
+ child.kill().expect("stop the tit server");
+ child.wait().expect("wait for the tit server");
+ }
}
}
tests/sqlite.rs
Mode 100644 → 100644; object d98bbbef6772 → 6859f8acd703
@@ -10,7 +10,8 @@
use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
use store::{
AuditContext, GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue,
- NewRepository, NewRepositoryReference, RepositoryOrigin, Store, StoreError,
+ NewRepository, NewRepositoryReference, RepositoryCollaboratorUpdate, RepositoryOrigin,
+ RepositorySettingsUpdate, Store, StoreError, TimelinePagination,
};
use tempfile::TempDir;
@@ -697,26 +698,26 @@
)
.expect("create a collaborator");
store
- .update_repository_settings(
- "alice",
- "project",
- "alice",
- "A small public repository.",
- "private",
- 23,
- "settings",
- )
+ .update_repository_settings(&RepositorySettingsUpdate {
+ owner: "alice",
+ slug: "project",
+ actor: "alice",
+ description: "A small public repository.",
+ visibility: "private",
+ changed_at: 23,
+ correlation_id: "settings",
+ })
.expect("update repository settings");
store
- .update_repository_collaborator(
- "alice",
- "project",
- "alice",
- "bob",
- Some("reader"),
- 24,
- "collaborator",
- )
+ .update_repository_collaborator(&RepositoryCollaboratorUpdate {
+ owner: "alice",
+ slug: "project",
+ actor: "alice",
+ username: "bob",
+ role: Some("reader"),
+ changed_at: 24,
+ correlation_id: "collaborator",
+ })
.expect("add a repository collaborator");
let settings = store
.repository_settings("alice", "project", "alice")
@@ -730,15 +731,15 @@
Err(StoreError::PullRequestDenied)
));
store
- .update_repository_settings(
- "alice",
- "project",
- "alice",
- "A small public repository.",
- "public",
- 25,
- "settings-public",
- )
+ .update_repository_settings(&RepositorySettingsUpdate {
+ owner: "alice",
+ slug: "project",
+ actor: "alice",
+ description: "A small public repository.",
+ visibility: "public",
+ changed_at: 25,
+ correlation_id: "settings-public",
+ })
.expect("restore public visibility");
assert!(matches!(
@@ -969,7 +970,17 @@
.expect("reopen an issue as a writer");
let detail = store
- .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 50)
+ .issue_detail(
+ "alice",
+ "project",
+ 1,
+ Some("maintainer"),
+ TimelinePagination {
+ primary_page: 1,
+ timeline_page: 1,
+ page_size: 50,
+ },
+ )
.expect("read the issue timeline");
assert_eq!(detail.repository.slug, "project");
assert_eq!(detail.issue.title, "Writer edit");
@@ -1000,14 +1011,34 @@
.all(|events| events[0].sequence < events[1].sequence)
);
let first_activity_page = store
- .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 1)
+ .issue_detail(
+ "alice",
+ "project",
+ 1,
+ Some("maintainer"),
+ TimelinePagination {
+ primary_page: 1,
+ timeline_page: 1,
+ page_size: 1,
+ },
+ )
.expect("read the first bounded activity page");
assert_eq!(first_activity_page.comments.len(), 1);
assert!(!first_activity_page.comments_has_next);
assert_eq!(first_activity_page.timeline.len(), 1);
assert!(first_activity_page.timeline_has_next);
let second_timeline_page = store
- .issue_detail("alice", "project", 1, Some("maintainer"), 1, 2, 1)
+ .issue_detail(
+ "alice",
+ "project",
+ 1,
+ Some("maintainer"),
+ TimelinePagination {
+ primary_page: 1,
+ timeline_page: 2,
+ page_size: 1,
+ },
+ )
.expect("read the second timeline page");
assert_eq!(second_timeline_page.timeline_page, 2);
assert_eq!(second_timeline_page.timeline.len(), 1);
tests/ssh_fixture.rs
Mode → 100644; object → 834883fca4d5
@@ -1,0 +1,15 @@
+use std::path::Path;
+use std::process::Command;
+
+pub(crate) fn create_ssh_key(path: &Path) {
+ let output = Command::new("ssh-keygen")
+ .args(["-q", "-t", "ed25519", "-N", "", "-f"])
+ .arg(path)
+ .output()
+ .expect("run the stock ssh-keygen client");
+ assert!(
+ output.status.success(),
+ "create an SSH key fixture: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
tests/support/mod.rs
Mode 100644 → 100644; object d249db685df2 → 36e4f67c434f
@@ -65,19 +65,6 @@
);
}
-pub(crate) fn create_ssh_key_fixture(path: &Path) {
- let output = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-f"])
- .arg(path)
- .output()
- .expect("run the stock ssh-keygen client");
- assert!(
- output.status.success(),
- "create an SSH key fixture: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
-
pub(crate) fn read_stock_ssh_configuration() {
let output = Command::new("ssh")
.args(["-G", "-F", "/dev/null", "localhost"])
tests/web_session.rs
Mode 100644 → 100644; object 3827ad13a9e3 → 97a296e325a2
@@ -1,7 +1,7 @@
use crate::{account, auth, session, store};
-#[allow(dead_code, reason = "the Web session test uses one shared test helper")]
-mod support;
+#[path = "ssh_fixture.rs"]
+mod ssh_fixture;
use std::fs;
use std::path::Path;
@@ -11,13 +11,13 @@
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
-use support::create_ssh_key_fixture;
use tempfile::TempDir;
use url::Url;
use account::{AccountError, AccountKeyRequest, AccountService};
use auth::SshPublicKey;
use session::{SessionError, WebLoginService};
+use ssh_fixture::create_ssh_key as create_ssh_key_fixture;
use store::{InitialAdministrator, Store, StoreError};
#[test]