From 34bf1cce8b5b1c0ca2ce002f5b8313e208c15596 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 08:49:48 +0300 Subject: [PATCH 01/11] feat(forge): P38 F1 server skeleton, auth, bootstrap CLI sc-forge serve boots on SQLite (WAL, synchronous=FULL, numbered migrations), with a router table that declares credential class, scope and actor rule per route; users, organizations, access tokens, admin users, /version, /healthz, /user; the admin CLI (user create/change-password/generate-access-token, org create); argon2 password hashing, token generation and constant-time compare in crates/crypto. check --pending replays the built slice against the in-tree server in CI. --- .github/workflows/ci.yml | 14 + CLAUDE.md | 5 +- Cargo.lock | 158 +++- Cargo.toml | 9 +- ROADMAP.md | 5 +- crates/crypto/Cargo.toml | 1 + crates/crypto/src/credentials.rs | 122 +++ crates/crypto/src/lib.rs | 2 + crates/forge/Cargo.toml | 35 + crates/forge/conformance/README.md | 8 + crates/forge/conformance/departures.json | 48 +- crates/forge/conformance/forge-check.sh | 26 + crates/forge/conformance/run.py | 34 +- crates/forge/conformance/slice-pending.json | 4 + crates/forge/src/admin.rs | 108 +++ crates/forge/src/api.rs | 466 +++++++++++ crates/forge/src/auth.rs | 187 +++++ crates/forge/src/cli.rs | 242 ++++++ crates/forge/src/db.rs | 185 +++++ crates/forge/src/http.rs | 91 +++ crates/forge/src/lib.rs | 19 + crates/forge/src/limits.rs | 266 +++++++ crates/forge/src/main.rs | 18 + crates/forge/src/names.rs | 98 +++ crates/forge/src/routes.rs | 356 +++++++++ crates/forge/src/scope.rs | 182 +++++ crates/forge/src/server.rs | 158 ++++ crates/forge/src/store.rs | 367 +++++++++ crates/forge/src/timefmt.rs | 45 ++ crates/forge/tests/common/mod.rs | 222 ++++++ crates/forge/tests/e2e_a.rs | 727 ++++++++++++++++++ crates/forge/tests/route_table.rs | 102 +++ .../specs/2026-10-22-p38-forge-design.md | 31 + 33 files changed, 4313 insertions(+), 28 deletions(-) create mode 100644 crates/crypto/src/credentials.rs create mode 100644 crates/forge/Cargo.toml create mode 100755 crates/forge/conformance/forge-check.sh create mode 100644 crates/forge/conformance/slice-pending.json create mode 100644 crates/forge/src/admin.rs create mode 100644 crates/forge/src/api.rs create mode 100644 crates/forge/src/auth.rs create mode 100644 crates/forge/src/cli.rs create mode 100644 crates/forge/src/db.rs create mode 100644 crates/forge/src/http.rs create mode 100644 crates/forge/src/lib.rs create mode 100644 crates/forge/src/limits.rs create mode 100644 crates/forge/src/main.rs create mode 100644 crates/forge/src/names.rs create mode 100644 crates/forge/src/routes.rs create mode 100644 crates/forge/src/scope.rs create mode 100644 crates/forge/src/server.rs create mode 100644 crates/forge/src/store.rs create mode 100644 crates/forge/src/timefmt.rs create mode 100644 crates/forge/tests/common/mod.rs create mode 100644 crates/forge/tests/e2e_a.rs create mode 100644 crates/forge/tests/route_table.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1723be8..25ede6b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,20 @@ jobs: - name: doc run: cargo doc --workspace --no-deps + # sc-forge conformance (P38): replays the recorded Gitea contract against the in-tree + # server, bootstrapped the way Engine bootstraps Gitea (admin CLI, then serve). Only + # the exchanges the built slices answer run; crates/forge/conformance/slice-pending.json + # lists the rest and shrinks as slices land. + forge-conformance: + name: sc-forge conformance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@2fe4ca74464c5902a4f6e302d0a619b4ea911ccc # 1.96.1 + - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + - name: check + run: bash crates/forge/conformance/forge-check.sh + # Advisory (not a required context): proves the Windows build and the owner-only # ACL on .sc/local-key, the `sc keygen` identity and the TLS serve key (#214, # #281). Deliberately not `cargo test --workspace`; the wider suite still has diff --git a/CLAUDE.md b/CLAUDE.md index 0da660df..e50b7a8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,10 @@ reaching for TLS elsewhere, add a function to `tlsio` instead. **Object-store SDKs must stay quarantined in `objio`** — if you find yourself reaching for S3 elsewhere, add a function to `objio` instead. **`repo` must not depend on `gitio`** — `cli` links both and passes imported snapshots down; `repo` stays -Git-agnostic. +Git-agnostic. **`forge` (the `sc-forge` Git forge, P38, unix-only) is a third top-level adapter +and may link only `{crypto, core}`** — never `repo`, `vfs` or `gitio` (hosted +repositories are plain Git); HTTP, SQLite and process supervision stay confined +to it, and password hashing and token generation live in `crypto`. ## Core invariants (do not break) diff --git a/Cargo.lock b/Cargo.lock index dceccc66..55040deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.7" @@ -778,6 +790,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -1894,6 +1915,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "faster-hex" version = "0.10.1" @@ -3449,6 +3482,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heapless" version = "0.8.0" @@ -3558,9 +3600,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3575,6 +3617,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "human_format" version = "1.2.1" @@ -3604,6 +3652,7 @@ dependencies = [ "http 1.4.2", "http-body 1.1.0", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3629,16 +3678,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.4.2", "http-body 1.1.0", + "httparse", "hyper", "ipnet", "libc", @@ -4088,6 +4138,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4633,6 +4694,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "4.0.0" @@ -5202,6 +5274,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5414,6 +5511,7 @@ dependencies = [ name = "scl-crypto" version = "0.1.0" dependencies = [ + "argon2", "blake3", "chacha20poly1305", "ed25519-dalek", @@ -5443,6 +5541,22 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] +[[package]] +name = "scl-forge" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "http-body-util", + "hyper", + "hyper-util", + "rusqlite", + "scl-crypto", + "serde_json", + "thiserror 2.0.21", + "tokio", +] + [[package]] name = "scl-gitio" version = "0.1.0" @@ -5924,6 +6038,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6496,9 +6622,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -6506,9 +6632,21 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6857,6 +6995,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 312077a1..4bf48400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/core", "crates/vfs", "crates/gitio", "crates/crypto", "crates/repo", "crates/cli", "crates/tlsio", "crates/objio", "apps/desktop/src-tauri"] +members = ["crates/core", "crates/vfs", "crates/gitio", "crates/crypto", "crates/repo", "crates/cli", "crates/tlsio", "crates/objio", "crates/forge", "apps/desktop/src-tauri"] exclude = ["vendor/glib-0.18.5-patched", "fuzz"] # Tauri 2's Linux GTK3 stack is pinned to glib 0.18.5, whose VariantStrIter @@ -36,3 +36,10 @@ too_many_arguments = "allow" # deliberate: commit/snapshot-assembly fns [profile.release] lto = true + +# Argon2 is deliberately slow; unoptimised it makes every password test take +# seconds. Optimise the hashing crates only, the rest keeps debug info. +[profile.dev.package.argon2] +opt-level = 3 +[profile.dev.package.blake2] +opt-level = 3 diff --git a/ROADMAP.md b/ROADMAP.md index 387a452c..df8a3f6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -701,8 +701,9 @@ a Gitea-compatible Git forge (bare repos over smart HTTP, a recorded subset of `/api/v1`, SQLite metadata) so a platform can swap its project Git host by config. Design: `docs/superpowers/specs/2026-10-22-p38-forge-design.md`. Slices F0 (contract -capture) through F6 (acceptance on the first deployment). Hosted repos are bare -Git and get none of the `.sc/` features. +capture) through F6 (acceptance on the first deployment); F0 and F1 (server +skeleton, users, organizations, tokens, `admin` CLI) are built. Hosted repos are +bare Git and get none of the `.sc/` features. Other follow-up work is tracked as GitHub issues and listed under Deferred below. diff --git a/crates/crypto/Cargo.toml b/crates/crypto/Cargo.toml index 194f0cc1..0d261bd1 100644 --- a/crates/crypto/Cargo.toml +++ b/crates/crypto/Cargo.toml @@ -17,6 +17,7 @@ blake3 = "1.8.5" hex = "0.4.3" thiserror = "2.0.18" ed25519-dalek = "3.0.0" +argon2 = "0.5" [dev-dependencies] rand_chacha = "0.3" diff --git a/crates/crypto/src/credentials.rs b/crates/crypto/src/credentials.rs new file mode 100644 index 00000000..237afda0 --- /dev/null +++ b/crates/crypto/src/credentials.rs @@ -0,0 +1,122 @@ +//! Credential primitives for `sc-forge`: password hashing, access-token +//! generation and hashing, constant-time comparison. Lives here because +//! RustCrypto (argon2, blake3) and the RNG stay quarantined in this crate. + +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use std::sync::OnceLock; + +/// Hash `password` with Argon2id and a fresh random salt; returns a PHC string. +pub fn hash_password(password: &str) -> String { + use rand_core::{OsRng, RngCore}; + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut salt); + let salt = SaltString::encode_b64(&salt).expect("16 bytes is a valid salt length"); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("argon2 hashing with default parameters cannot fail") + .to_string() +} + +/// Verify `password` against a PHC `stored` hash. With `None` (no such account) +/// it still spends one full verification against a fixed dummy hash and returns +/// `false`, so response time does not reveal whether the login exists. +pub fn verify_password(password: &str, stored: Option<&str>) -> bool { + static DUMMY: OnceLock = OnceLock::new(); + // An empty or unparsable stored hash (an organization row, a damaged row) + // takes the same dummy-verification path as a missing account, so it costs + // the same time and cannot be told apart. + let real = stored.and_then(|s| PasswordHash::new(s).ok()); + let dummy; + let (parsed, real_account) = match real { + Some(p) => (p, true), + None => { + dummy = DUMMY.get_or_init(|| hash_password("sc-forge-dummy")); + ( + PasswordHash::new(dummy).expect("our own hash parses"), + false, + ) + } + }; + let ok = Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok(); + ok && real_account +} + +/// A fresh access token: 40 lowercase hex characters (the length Gitea issues; +/// clients treat it as opaque) from the OS CSPRNG. +pub fn generate_token() -> String { + crate::random_hex(20) +} + +/// The stored form of an access token: BLAKE3, hex. Tokens are high-entropy +/// random values, so a fast hash is enough and lets lookups be an index probe. +pub fn hash_token(token: &str) -> String { + blake3::hash(token.as_bytes()).to_hex().to_string() +} + +/// Constant-time equality: unequal lengths are unequal, equal lengths fold every +/// XOR into one accumulator and never short-circuit. +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_round_trip_and_rejection() { + let h = hash_password("correct horse"); + assert!(h.starts_with("$argon2id$")); + assert!(verify_password("correct horse", Some(&h))); + assert!(!verify_password("correct horsf", Some(&h))); + assert!(!verify_password("", Some(&h))); + } + + #[test] + fn same_password_hashes_differently() { + assert_ne!(hash_password("pw"), hash_password("pw")); + } + + #[test] + fn absent_account_and_garbage_hash_never_verify() { + assert!(!verify_password("anything", None)); + assert!(!verify_password("anything", Some("not-a-phc-string"))); + } + + #[test] + fn token_is_40_hex_and_varies() { + let (a, b) = (generate_token(), generate_token()); + assert_eq!(a.len(), 40); + assert!(a + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))); + assert_ne!(a, b); + } + + #[test] + fn token_hash_is_stable_hex_and_not_the_token() { + let t = generate_token(); + assert_eq!(hash_token(&t), hash_token(&t)); + assert_eq!(hash_token(&t).len(), 64); + assert_ne!(hash_token(&t), t); + assert_ne!(hash_token(&t), hash_token(&generate_token())); + } + + #[test] + fn ct_eq_matches_equality() { + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); + assert!(ct_eq(b"", b"")); + } +} diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index 2b4a94b6..bb104d74 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -9,6 +9,7 @@ //! XChaCha20-Poly1305; the DEK is wrapped once per recipient via X25519 ECDH + //! HKDF. Authorization is "do you hold a recipient private key." +pub mod credentials; pub mod envelope; pub mod error; pub mod key; @@ -16,6 +17,7 @@ pub mod private; pub mod provider; pub mod signing; +pub use credentials::{ct_eq, generate_token, hash_password, hash_token, verify_password}; pub use envelope::{ decrypt_path, encrypt_path, encrypt_path_randomized, encrypt_path_randomized_with_rng, open, revoke, rewrap_for, seal, unwrap_dek_with, wrap_dek_for, diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml new file mode 100644 index 00000000..b9930c20 --- /dev/null +++ b/crates/forge/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "scl-forge" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "sc-forge: a Gitea-compatible Git forge (unix only)" + +[lib] +name = "scl_forge" +path = "src/lib.rs" + +[[bin]] +name = "sc-forge" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +anyhow = "1.0.102" +scl-crypto = { path = "../crypto" } + +# The whole implementation is unix-only (src/lib.rs is #![cfg(unix)]); keeping +# these out of other targets stops the Windows workspace check from compiling +# bundled SQLite for an empty crate. +[target.'cfg(unix)'.dependencies] +http-body-util = "0.1.5" +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1.21", features = ["tokio", "server"] } +rusqlite = { version = "0.40.2", features = ["bundled"] } +serde_json = "1.0.154" +thiserror = "2.0.18" +tokio = { version = "1.54.1", features = ["rt-multi-thread", "net", "io-util", "signal", "time", "macros", "sync"] } + +[lints] +workspace = true diff --git a/crates/forge/conformance/README.md b/crates/forge/conformance/README.md index de825f76..7cbc4297 100644 --- a/crates/forge/conformance/README.md +++ b/crates/forge/conformance/README.md @@ -45,6 +45,14 @@ python3 -I run.py diff fixtures/1.22.6 fixtures/1.26.2 # offline version diff python3 -I run.py show fixtures/1.26.2 --only G --body # what was recorded ``` +Against the in-tree server use `./forge-check.sh` (it bootstraps an empty data directory with the `admin` CLI, +starts `sc-forge serve` on a free loopback port, runs `check` with the departures and `slice-pending.json`, and +stops the server; CI runs it). `check --pending FILE` takes a JSON map `{scenario: N}`: the server is expected to +answer the first N exchanges of that scenario, the scenario must then stop at its next setup call (a route from a +later slice), and the rest are reported as pending. A server that answers more than N, or a scenario that +completes, fails the run, so the file shrinks as slices land and is empty when the contract is whole. It is for the +in-tree build only; never pass it when checking real Gitea. + `check` exits non-zero on any failure. Requirements on the server under test: a fresh instance whose admin token has all scopes, the ability to create users through `POST /admin/users` and tokens through `POST /users/{u}/tokens` with Basic auth, and git smart HTTP on the same base URL. diff --git a/crates/forge/conformance/departures.json b/crates/forge/conformance/departures.json index 0efd5620..b3746a36 100644 --- a/crates/forge/conformance/departures.json +++ b/crates/forge/conformance/departures.json @@ -516,13 +516,15 @@ "kind": "http", "match": "POST /api/v1/users/alice/tokens", "expect": { - "status": 403, + "status": [ + 401, + 403 + ], "message_contains": [ - "scope", - "write:user" + "password" ] }, - "reason": "scope-error text differs by version; sc-forge names the scope" + "reason": "token routes take the user's password only; a token sent as the Basic password fails password verification, so sc-forge answers with a password-class refusal instead of Gitea's scope error" }, { "scenario": "X", @@ -530,13 +532,15 @@ "kind": "http", "match": "GET /api/v1/users/alice/tokens", "expect": { - "status": 403, + "status": [ + 401, + 403 + ], "message_contains": [ - "scope", - "read:user" + "password" ] }, - "reason": "scope-error text differs by version; sc-forge names the scope" + "reason": "token routes take the user's password only; a token sent as the Basic password fails password verification, so sc-forge answers with a password-class refusal instead of Gitea's scope error" }, { "scenario": "B", @@ -1181,7 +1185,7 @@ 404 ], "message_contains": [ - "not found" + "hasorgoruservisible" ], "fields": { "can_read": false @@ -1302,5 +1306,29 @@ ] }, "reason": "the only status on this commit was the refused bogus state: no statuses, combined state pending; ignore_body: the recorded body lists that status" + }, + { + "scenario": "X", + "exchange": 32, + "kind": "note", + "match": "readonly_token_as_password_status", + "expect": { + "value": 401 + }, + "reason": "a read-only token sent as the Basic password on a token route fails password verification (401); Gitea answers 403 for the missing write:user scope" + }, + { + "scenario": "A", + "exchange": 2, + "kind": "http", + "match": "GET /api/healthz", + "expect": { + "status": 200, + "ignore_fields": [ + "description", + "checks" + ] + }, + "reason": "the health body names this server and reports only the checks it has (database), not Gitea's description or cache check; status and content type stay compared" } -] \ No newline at end of file +] diff --git a/crates/forge/conformance/forge-check.sh b/crates/forge/conformance/forge-check.sh new file mode 100755 index 00000000..2fdf7e0f --- /dev/null +++ b/crates/forge/conformance/forge-check.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Run `run.py check` against the in-tree sc-forge: bootstrap an empty data dir the way +# Engine does (admin CLI), start the server on a free loopback port, check, stop. +# Usage: forge-check.sh [extra run.py check args] (CI runs it with none) +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/../../.." && pwd)" +cargo build --quiet --manifest-path "$root/Cargo.toml" -p scl-forge +bin="${CARGO_TARGET_DIR:-$root/target}/debug/sc-forge" +data="$(mktemp -d)" +pid="" +cleanup() { [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; rm -rf "$data"; } +trap cleanup EXIT +"$bin" admin user create --data-dir "$data" --username siteadmin --password "$(openssl rand -hex 16)" \ + --email admin@example.invalid --admin --must-change-password=false >/dev/null +token="$("$bin" admin user generate-access-token --data-dir "$data" --username siteadmin --token-name conformance --scopes all --raw)" +port="$(python3 -I -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')" +"$bin" serve --data-dir "$data" --listen "127.0.0.1:$port" --public-url "http://127.0.0.1:$port" 2>"$data/serve.log" & +pid=$! +for _ in $(seq 50); do + python3 -I -c "import urllib.request as u;u.urlopen('http://127.0.0.1:$port/api/healthz')" 2>/dev/null && break + sleep 0.1 +done +python3 -I "$here/run.py" check --base-url "http://127.0.0.1:$port" --admin-token "$token" \ + --fixtures "$here/fixtures/1.22.6" --departures "$here/departures.json" --pending "$here/slice-pending.json" \ + --only A,B "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; } diff --git a/crates/forge/conformance/run.py b/crates/forge/conformance/run.py index 188c2be5..4abbd202 100644 --- a/crates/forge/conformance/run.py +++ b/crates/forge/conformance/run.py @@ -910,15 +910,19 @@ def scan_fixtures(out, secret_values): return hits -def run_all(c, only, out=None, fixtures=None, departures=()): - """Run every scenario in order (they read earlier state); `only` filters what is reported/written.""" +def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): + """Run every scenario in order (they read earlier state); `only` filters what is reported/written. + + `pending` (check only) maps a scenario id to the number of leading exchanges the server under test is expected + to answer; later exchanges need routes from a slice that is not built yet. The run must stop exactly there.""" summary = [] + pending = pending or {} for sid, slug, needs_base, fn in SCENARIOS: selected = not only or sid in only fname = f"{sid}-{slug}.json" c.ex, c.cursor, c.diffs = [], 0, [] c.reset_scenario() - c.expected, skip, stale = [], None, [] + c.expected, skip, stale, deferred, ndeferred = [], None, [], None, 0 c.overrides, c.web_skipped = {}, 0 if c.mode == "check" and selected: fp = os.path.join(fixtures, fname) @@ -954,6 +958,10 @@ def run_all(c, only, out=None, fixtures=None, departures=()): f"cannot match the recorded one, so it needs ignore_body with a reason") continue c.overrides[i] = dict(dep, match=matches[k] or got) + if sid in pending: + deferred = pending[sid] + ndeferred = max(0, len(c.expected) - deferred) + c.expected = c.expected[:deferred] t0 = time.time() err = None try: @@ -983,7 +991,9 @@ def run_all(c, only, out=None, fixtures=None, departures=()): lines += [f" {'expected difference honoured' if lv == 'honoured' else lv}: {t}" for lv, t in ds] # unpaired exchanges and errors: each counts as a failed exchange missing = len(c.expected) - len(c.ex) - if err: + if err and deferred is not None and len(c.ex) == deferred: + lines.append(f" (pending: {ndeferred} later exchange(s) need a later slice; the scenario stopped at #{deferred}: {err[:100]})") + elif err: if skip and norm_msg(skip) == norm_msg(err): lines.append(f" (fixture recorded the same error: {skip[:200]})") else: @@ -991,16 +1001,19 @@ def run_all(c, only, out=None, fixtures=None, departures=()): lines.append(f" ERROR {err}" + ("" if skip else " (the fixture recorded no error)")) if skip: lines.append(f" fixture recorded a different error: {skip[:300]}") - elif skip: + elif skip and deferred is None: nfail += 1 lines.append(f" FAIL fixture recorded an error but the scenario completed: {skip[:300]}") + elif deferred is not None and ndeferred: + nfail += 1 + lines.append(f" FAIL the scenario completed but {ndeferred} exchange(s) are listed as pending: update the pending file") if missing > 0: nfail += missing lines.append(f" FAIL {missing} recorded exchange(s) not produced (fixture has {len(c.expected)}, run made {len(c.ex)})") if stale: nfail += len(stale) lines += [" FAIL " + x for x in stale] - summary.append((sid, slug, "FAIL" if nfail else "ok", lines, len(c.ex), dt, nfail, c.web_skipped)) + summary.append((sid, slug, "FAIL" if nfail else "ok", lines, len(c.ex), dt, nfail, c.web_skipped, ndeferred)) return summary @@ -1027,7 +1040,11 @@ def cmd_run(a): f.write("\n") leaks = scan_fixtures(a.out, c.secrets) else: - summary = run_all(c, only, fixtures=a.fixtures, departures=deps) + pend = J.load(open(a.pending)) if getattr(a, "pending", None) else {} + if set(pend) - ids: + print(f"pending names unknown scenario(s) {sorted(set(pend) - ids)}", file=sys.stderr) + return 2 + summary = run_all(c, only, fixtures=a.fixtures, departures=deps, pending=pend) finally: if c.tmp: shutil.rmtree(c.tmp, ignore_errors=True) @@ -1043,9 +1060,11 @@ def cmd_run(a): print(f"LEAK {h}") nfail = sum(s[6] for s in summary if len(s) > 6) web = sum(s[7] for s in summary if len(s) > 7) + pend_n = sum(s[8] for s in summary if len(s) > 8) print(f"\n{len(summary) - bad}/{len(summary)} scenarios ok in {time.time() - t0:.0f}s" + (f"; {nfail} failed exchange(s)" if a.cmd == "check" else "") + (f"; {web} web exchange(s) skipped (check --include-web runs them)" if web else "") + + (f"; {pend_n} exchange(s) pending a later slice" if pend_n else "") + (f"; {len(leaks)} credential-shaped value(s) in the fixtures" if leaks else "")) return 1 if bad or leaks else 0 @@ -1124,6 +1143,7 @@ def main(): else: p.add_argument("--fixtures", required=True) p.add_argument("--departures", help="JSON list of reviewed differences from the recorded behaviour") + p.add_argument("--pending", help="JSON map scenario id -> leading exchange count the server answers; the rest wait for a later slice") p.add_argument("--include-web", action="store_true", help="also run the exchanges tagged `web` (Gitea web UI routes); skipped by default") p.add_argument("--strict-first-read", action="store_true", diff --git a/crates/forge/conformance/slice-pending.json b/crates/forge/conformance/slice-pending.json new file mode 100644 index 00000000..f54000ae --- /dev/null +++ b/crates/forge/conformance/slice-pending.json @@ -0,0 +1,4 @@ +{ + "A": 6, + "B": 25 +} diff --git a/crates/forge/src/admin.rs b/crates/forge/src/admin.rs new file mode 100644 index 00000000..d61b1cd9 --- /dev/null +++ b/crates/forge/src/admin.rs @@ -0,0 +1,108 @@ +//! `sc-forge admin …`: operate on the data directory directly, no network. +//! Mirrors the `gitea admin` commands Codento Engine's bootstrap scripts call. + +use crate::db; +use crate::names; +use crate::store::{self, NewAccount, StoreError, UserPatch}; +use crate::timefmt::now; +use anyhow::{anyhow, bail, Result}; +use scl_crypto::hash_password; +use std::path::Path; + +/// `admin user create`. +pub fn user_create( + data_dir: &Path, + username: &str, + password: &str, + email: &str, + admin: bool, + must_change_password: bool, +) -> Result<()> { + if password.is_empty() { + bail!("password must not be empty"); + } + if names::check(username).is_err() { + bail!("invalid or reserved username {username:?}"); + } + let conn = db::open_for_admin(data_dir)?; + let acct = NewAccount { + login: username.into(), + email: email.into(), + password_hash: hash_password(password), + must_change_password, + is_admin: admin, + visibility: "public".into(), + ..Default::default() + }; + match store::create_account(&conn, &acct, now()) { + Ok(_) => Ok(()), + Err(StoreError::Exists) => bail!("user already exists [name: {username}]"), + Err(e) => Err(e.into()), + } +} + +/// `admin user change-password`. +pub fn user_change_password( + data_dir: &Path, + username: &str, + password: &str, + must_change_password: bool, +) -> Result<()> { + if password.is_empty() { + bail!("password must not be empty"); + } + let conn = db::open_for_admin(data_dir)?; + let user = store::find_by_login(&conn, username)? + .filter(|u| !u.is_org) + .ok_or_else(|| anyhow!("user does not exist [name: {username}]"))?; + let patch = UserPatch { + password_hash: Some(hash_password(password)), + must_change_password: Some(must_change_password), + ..Default::default() + }; + store::update_user(&conn, user.id, &patch)?; + Ok(()) +} + +/// `admin user generate-access-token`; returns the one-time secret. +pub fn generate_access_token( + data_dir: &Path, + username: &str, + token_name: &str, + scopes: &[String], +) -> Result { + let conn = db::open_for_admin(data_dir)?; + let user = store::find_by_login(&conn, username)? + .filter(|u| !u.is_org) + .ok_or_else(|| anyhow!("user does not exist [name: {username}]"))?; + match store::mint(&conn, user.id, token_name, scopes, now()) { + Ok((_, secret)) => Ok(secret), + Err(store::MintError::BadScope(s)) => bail!("invalid access token scope: {s}"), + Err(store::MintError::Store(StoreError::Exists)) => { + bail!("access token name has been used already: {token_name}") + } + Err(store::MintError::Store(e)) => Err(e.into()), + } +} + +/// `admin org create`. +pub fn org_create(data_dir: &Path, name: &str, owner: &str) -> Result<()> { + if names::check(name).is_err() { + bail!("invalid or reserved organization name {name:?}"); + } + let mut conn = db::open_for_admin(data_dir)?; + let owner = store::find_by_login(&conn, owner)? + .filter(|u| !u.is_org) + .ok_or_else(|| anyhow!("owner does not exist [name: {owner}]"))?; + let acct = NewAccount { + login: name.into(), + is_org: true, + visibility: "public".into(), + ..Default::default() + }; + match store::create_org(&mut conn, &acct, owner.id, now()) { + Ok(_) => Ok(()), + Err(StoreError::Exists) => bail!("user already exists [name: {name}]"), + Err(e) => Err(e.into()), + } +} diff --git a/crates/forge/src/api.rs b/crates/forge/src/api.rs new file mode 100644 index 00000000..9d3d60bc --- /dev/null +++ b/crates/forge/src/api.rs @@ -0,0 +1,466 @@ +//! F1 handlers: version, current user, users, admin users, organizations, +//! access tokens. Authentication, scope and actor rules already passed in +//! `routes::dispatch`; handlers add only the per-resource rules. + +use crate::http::{error, not_found, Resp}; +use crate::names::{self, NameError}; +use crate::routes::Ctx; +use crate::store::{self, NewAccount, StoreError, User, UserPatch}; +use crate::timefmt::{now, rfc3339}; +use scl_crypto::hash_password; +use serde_json::{json, Value}; + +/// The Gitea release whose behaviour `sc-forge` reproduces; clients may sniff it. +pub const GITEA_VERSION: &str = "1.22.6"; + +const DEFAULT_PAGE: i64 = 30; +const MAX_PAGE: i64 = 50; + +fn user_json(base: &str, u: &User) -> Value { + json!({ + "id": u.id, + "login": u.login, + "login_name": u.login_name, + "source_id": 0, + "full_name": u.full_name, + "email": u.email, + "avatar_url": format!("{base}/avatars/{}", u.login.to_lowercase()), + "html_url": format!("{base}/{}", u.login), + "language": "", + "is_admin": u.is_admin, + "last_login": rfc3339(u.created), + "created": rfc3339(u.created), + "restricted": false, + "active": true, + "prohibit_login": false, + "location": u.location, + "website": u.website, + "description": u.description, + "visibility": u.visibility, + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 0, + "username": u.login, + }) +} + +fn org_json(base: &str, o: &User) -> Value { + json!({ + "id": o.id, + "name": o.login, + "full_name": o.full_name, + "email": o.email, + "avatar_url": format!("{base}/avatars/{}", o.login.to_lowercase()), + "description": o.description, + "website": o.website, + "location": o.location, + "visibility": o.visibility, + "repo_admin_change_team_access": false, + "username": o.login, + }) +} + +fn no_such_user(ctx: &Ctx, name: &str) -> Resp { + error( + &ctx.app.base, + 404, + &format!("user redirect does not exist [name: {name}]"), + ) +} + +fn no_such_org(ctx: &Ctx, name: &str) -> Resp { + not_found( + &ctx.app.base, + "GetOrgByName", + Some(&format!("user redirect does not exist [name: {name}]")), + ) +} + +/// A JSON object body; an empty body is `{}`. +fn body(ctx: &Ctx) -> Result { + if ctx.req.body.is_empty() { + return Ok(json!({})); + } + match serde_json::from_slice::(&ctx.req.body) { + Ok(v) if v.is_object() => Ok(v), + _ => Err(error(&ctx.app.base, 400, "invalid JSON body")), + } +} + +fn str_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_str).map(String::from) +} + +fn page_params(ctx: &Ctx) -> (i64, i64) { + let num = |k: &str| ctx.req.param(k).and_then(|v| v.parse::().ok()); + let page = num("page").filter(|p| *p >= 1).unwrap_or(1); + let limit = num("limit") + .filter(|l| *l >= 1) + .unwrap_or(DEFAULT_PAGE) + .min(MAX_PAGE); + (page, limit) +} + +/// 200 with `X-Total-Count` and Gitea's `Link` header. +fn paged(ctx: &Ctx, path: &str, total: i64, page: i64, limit: i64, items: Vec) -> Resp { + let last = ((total + limit - 1) / limit).max(1); + let url = |p: i64, rel: &str| { + format!( + "<{}/api/v1{path}?limit={limit}&page={p}>; rel=\"{rel}\"", + ctx.app.base + ) + }; + let mut links = Vec::new(); + if page < last { + links.push(url(page + 1, "next")); + links.push(url(last, "last")); + } + if page > 1 { + links.push(url(1, "first")); + links.push(url(page - 1, "prev")); + } + let mut r = Resp::json(200, &Value::Array(items)).with("x-total-count", total.to_string()); + if !links.is_empty() { + r = r.with("link", links.join(",")); + } + r +} + +pub fn version(_: &mut Ctx) -> Resp { + Resp::json(200, &json!({"version": GITEA_VERSION})) +} + +pub fn not_implemented(ctx: &mut Ctx) -> Resp { + error(&ctx.app.base, 501, "not implemented yet") +} + +pub fn current_user(ctx: &mut Ctx) -> Resp { + Resp::json(200, &user_json(&ctx.app.base, &ctx.who.user)) +} + +pub fn get_user(ctx: &mut Ctx) -> Resp { + let name = ctx.param("u").to_string(); + match store::find_by_login(ctx.conn, &name) { + Ok(Some(u)) => { + let me = &ctx.who.user; + if u.visibility == "private" && u.id != me.id && !me.is_admin { + return no_such_user(ctx, &name); + } + Resp::json(200, &user_json(&ctx.app.base, &u)) + } + Ok(None) => no_such_user(ctx, &name), + Err(e) => ctx.app.internal(&e), + } +} + +/// Visible to the caller: public and limited organizations to any signed-in +/// caller, private ones to owners and site admins. +fn org_visible(ctx: &Ctx, org: &User) -> rusqlite::Result { + let me = &ctx.who.user; + Ok(org.visibility != "private" || me.is_admin || store::is_org_owner(ctx.conn, org.id, me.id)?) +} + +pub fn get_org(ctx: &mut Ctx) -> Resp { + let name = ctx.param("o").to_string(); + let org = match store::find_by_login(ctx.conn, &name) { + Ok(Some(o)) if o.is_org => o, + Ok(_) => return no_such_org(ctx, &name), + Err(e) => return ctx.app.internal(&e), + }; + match org_visible(ctx, &org) { + Ok(true) => Resp::json(200, &org_json(&ctx.app.base, &org)), + Ok(false) => not_found(&ctx.app.base, "HasOrgOrUserVisible", None), + Err(e) => ctx.app.internal(&e), + } +} + +pub fn org_permissions(ctx: &mut Ctx) -> Resp { + let (uname, oname) = (ctx.param("u").to_string(), ctx.param("o").to_string()); + let subject = match store::find_by_login(ctx.conn, &uname) { + Ok(Some(u)) if !u.is_org => u, + Ok(_) => return no_such_user(ctx, &uname), + Err(e) => return ctx.app.internal(&e), + }; + let org = match store::find_by_login(ctx.conn, &oname) { + Ok(Some(o)) if o.is_org => o, + Ok(_) => return no_such_org(ctx, &oname), + Err(e) => return ctx.app.internal(&e), + }; + let full = match store::is_org_owner(ctx.conn, org.id, subject.id) { + Ok(owner) => owner || subject.is_admin, + Err(e) => return ctx.app.internal(&e), + }; + if !full && org.visibility == "private" { + return not_found(&ctx.app.base, "HasOrgOrUserVisible", None); + } + Resp::json( + 200, + &json!({"is_owner": full, "is_admin": full, "can_write": full, "can_read": full, "can_create_repository": full}), + ) +} + +fn name_error(label: &str, name: &str, e: NameError) -> String { + match e { + NameError::Reserved => format!("name is reserved [name: {name}]"), + NameError::Invalid => format!("[{label}]: invalid username"), + NameError::TooLong => format!("[{label}]: MaxSize"), + } +} + +fn valid_visibility(v: &str) -> bool { + matches!(v, "public" | "limited" | "private") +} + +pub fn create_org(ctx: &mut Ctx) -> Resp { + let b = match body(ctx) { + Ok(b) => b, + Err(r) => return r, + }; + let name = str_field(&b, "username").unwrap_or_default(); + if name.is_empty() { + return error(&ctx.app.base, 422, "[UserName]: Required"); + } + if let Err(e) = names::check(&name) { + return error(&ctx.app.base, 422, &name_error("UserName", &name, e)); + } + let visibility = str_field(&b, "visibility").unwrap_or_else(|| "public".into()); + if !valid_visibility(&visibility) { + return error(&ctx.app.base, 422, "[Visibility]: invalid visibility"); + } + let acct = NewAccount { + login: name.clone(), + is_org: true, + full_name: str_field(&b, "full_name").unwrap_or_default(), + email: str_field(&b, "email").unwrap_or_default(), + description: str_field(&b, "description").unwrap_or_default(), + website: str_field(&b, "website").unwrap_or_default(), + location: str_field(&b, "location").unwrap_or_default(), + visibility, + ..Default::default() + }; + let owner = ctx.who.user.id; + match store::create_org(ctx.conn, &acct, owner, now()) { + Ok(o) => Resp::json(201, &org_json(&ctx.app.base, &o)), + Err(StoreError::Exists) => error( + &ctx.app.base, + 422, + &format!("user already exists [name: {name}]"), + ), + Err(e) => ctx.app.internal(&e), + } +} + +pub fn admin_list_users(ctx: &mut Ctx) -> Resp { + let (page, limit) = page_params(ctx); + match store::list_users(ctx.conn, page, limit) { + Ok((users, total)) => { + let items = users.iter().map(|u| user_json(&ctx.app.base, u)).collect(); + paged(ctx, "/admin/users", total, page, limit, items) + } + Err(e) => ctx.app.internal(&e), + } +} + +pub fn admin_create_user(ctx: &mut Ctx) -> Resp { + let b = match body(ctx) { + Ok(b) => b, + Err(r) => return r, + }; + let name = str_field(&b, "username").unwrap_or_default(); + let email = str_field(&b, "email").unwrap_or_default(); + let password = str_field(&b, "password").unwrap_or_default(); + let base = ctx.app.base.clone(); + if name.is_empty() { + return error(&base, 422, "[Username]: Required"); + } + if email.is_empty() { + return error(&base, 422, "[Email]: Required"); + } + if !email.contains('@') { + return error(&base, 422, "[Email]: Email"); + } + if password.is_empty() { + return error(&base, 422, "[Password]: Required"); + } + if let Err(e) = names::check(&name) { + return error(&base, 422, &name_error("Username", &name, e)); + } + let visibility = str_field(&b, "visibility").unwrap_or_else(|| "public".into()); + if !valid_visibility(&visibility) { + return error(&base, 422, "[Visibility]: invalid visibility"); + } + let hash = { + let _slot = ctx.app.gate.acquire(); + hash_password(&password) + }; + let acct = NewAccount { + login: name.clone(), + full_name: str_field(&b, "full_name").unwrap_or_default(), + email, + password_hash: hash, + must_change_password: b + .get("must_change_password") + .and_then(Value::as_bool) + .unwrap_or(true), + visibility, + ..Default::default() + }; + match store::create_account(ctx.conn, &acct, now()) { + Ok(u) => { + if let Some(ln) = str_field(&b, "login_name").filter(|s| !s.is_empty()) { + let _ = store::update_user( + ctx.conn, + u.id, + &UserPatch { + login_name: Some(ln), + ..Default::default() + }, + ); + } + match store::find_by_id(ctx.conn, u.id) { + Ok(Some(u)) => Resp::json(201, &user_json(&base, &u)), + Ok(None) => ctx.app.internal(&"created user vanished"), + Err(e) => ctx.app.internal(&e), + } + } + Err(StoreError::Exists) => { + error(&base, 422, &format!("user already exists [name: {name}]")) + } + Err(e) => ctx.app.internal(&e), + } +} + +pub fn admin_patch_user(ctx: &mut Ctx) -> Resp { + let b = match body(ctx) { + Ok(b) => b, + Err(r) => return r, + }; + let name = ctx.param("u").to_string(); + let target = match store::find_by_login(ctx.conn, &name) { + Ok(Some(u)) if !u.is_org => u, + Ok(_) => return no_such_user(ctx, &name), + Err(e) => return ctx.app.internal(&e), + }; + let base = ctx.app.base.clone(); + let visibility = str_field(&b, "visibility"); + if visibility.as_deref().is_some_and(|v| !valid_visibility(v)) { + return error(&base, 422, "[Visibility]: invalid visibility"); + } + let email = str_field(&b, "email"); + if email.as_deref().is_some_and(|e| !e.contains('@')) { + return error(&base, 422, "[Email]: Email"); + } + let password_hash = match str_field(&b, "password") { + Some(p) if p.is_empty() => return error(&base, 422, "[Password]: Required"), + Some(p) => { + let _slot = ctx.app.gate.acquire(); + Some(hash_password(&p)) + } + None => None, + }; + let patch = UserPatch { + login_name: str_field(&b, "login_name"), + full_name: str_field(&b, "full_name"), + email, + password_hash, + must_change_password: b.get("must_change_password").and_then(Value::as_bool), + is_admin: b.get("admin").and_then(Value::as_bool), + visibility, + description: str_field(&b, "description"), + website: str_field(&b, "website"), + location: str_field(&b, "location"), + }; + match store::update_user(ctx.conn, target.id, &patch) { + Ok(u) => Resp::json(200, &user_json(&base, &u)), + Err(e) => ctx.app.internal(&e), + } +} + +fn token_json(t: &store::Token, secret: Option<&str>) -> Value { + json!({ + "id": t.id, + "name": t.name, + "sha1": secret.unwrap_or(""), + "token_last_eight": t.last_eight, + "scopes": t.scopes, + }) +} + +/// The token's owner: the `{u}` of the path (already authorized as self or +/// site admin). +fn token_owner(ctx: &Ctx) -> Result { + let name = ctx.param("u"); + match store::find_by_login(ctx.conn, name) { + Ok(Some(u)) if !u.is_org => Ok(u), + Ok(_) => Err(no_such_user(ctx, name)), + Err(e) => Err(ctx.app.internal(&e)), + } +} + +pub fn list_tokens(ctx: &mut Ctx) -> Resp { + let owner = match token_owner(ctx) { + Ok(o) => o, + Err(r) => return r, + }; + let (page, limit) = page_params(ctx); + match store::list_tokens(ctx.conn, owner.id, page, limit) { + Ok((toks, total)) => { + let items = toks.iter().map(|t| token_json(t, None)).collect(); + let path = format!("/users/{}/tokens", owner.login); + paged(ctx, &path, total, page, limit, items) + } + Err(e) => ctx.app.internal(&e), + } +} + +pub fn create_token(ctx: &mut Ctx) -> Resp { + let owner = match token_owner(ctx) { + Ok(o) => o, + Err(r) => return r, + }; + let b = match body(ctx) { + Ok(b) => b, + Err(r) => return r, + }; + let base = ctx.app.base.clone(); + let name = str_field(&b, "name").unwrap_or_default(); + if name.is_empty() { + return error(&base, 422, "[Name]: Required"); + } + let requested: Vec = b + .get("scopes") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + if requested.is_empty() { + return error(&base, 400, "access token must have a scope"); + } + match store::mint(ctx.conn, owner.id, &name, &requested, now()) { + Ok((t, secret)) => Resp::json(201, &token_json(&t, Some(&secret))), + Err(store::MintError::BadScope(s)) => error( + &base, + 400, + &format!("invalid access token scope provided: invalid access token scope: {s}"), + ), + Err(store::MintError::Store(StoreError::Exists)) => { + error(&base, 400, "access token name has been used already") + } + Err(store::MintError::Store(e)) => ctx.app.internal(&e), + } +} + +pub fn delete_token(ctx: &mut Ctx) -> Resp { + let owner = match token_owner(ctx) { + Ok(o) => o, + Err(r) => return r, + }; + match store::delete_token(ctx.conn, owner.id, ctx.param("id")) { + Ok(true) => Resp::no_content(), + Ok(false) => error(&ctx.app.base, 404, "access token does not exist"), + Err(e) => ctx.app.internal(&e), + } +} diff --git a/crates/forge/src/auth.rs b/crates/forge/src/auth.rs new file mode 100644 index 00000000..ce73262d --- /dev/null +++ b/crates/forge/src/auth.rs @@ -0,0 +1,187 @@ +//! Credential parsing and resolution. Two route classes exist (see the spec's +//! "Authentication"): token routes accept access tokens only, and the token +//! management routes accept the user's password only. A Basic password is never +//! tried as a token on a password route and never as a password on a token +//! route, so a stolen token cannot mint tokens and a password cannot be used +//! against ordinary APIs. + +use crate::http::{error, signed_in_only, Req, Resp}; +use crate::routes::App; +use crate::store::{self, User}; +use rusqlite::Connection; +use scl_crypto::verify_password; + +/// What the `Authorization` header carried. +pub enum Cred { + Anonymous, + /// `token X` or `Bearer X`. + Header(String), + /// `Basic user:pass`. + Basic { + user: String, + pass: String, + }, +} + +/// The authenticated caller. +pub struct Authed { + pub user: User, + /// Token scopes; `None` when the caller proved a password (full scope). + pub scopes: Option>, +} + +fn b64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let (mut acc, mut bits) = (0u32, 0u32); + for c in s.trim_end_matches('=').bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +/// Parse the `Authorization` header. A malformed value is anonymous. +pub fn parse(req: &Req) -> Cred { + let Some(h) = req.header("authorization") else { + return Cred::Anonymous; + }; + let Some((scheme, rest)) = h.split_once(' ') else { + return Cred::Anonymous; + }; + let rest = rest.trim(); + match scheme.to_ascii_lowercase().as_str() { + "token" | "bearer" if !rest.is_empty() => Cred::Header(rest.to_string()), + "basic" => { + let Some(raw) = b64_decode(rest).and_then(|b| String::from_utf8(b).ok()) else { + return Cred::Anonymous; + }; + match raw.split_once(':') { + Some((u, p)) => Cred::Basic { + user: u.to_string(), + pass: p.to_string(), + }, + None => Cred::Basic { + user: raw, + pass: String::new(), + }, + } + } + _ => Cred::Anonymous, + } +} + +/// The name echoed in a 401 is whatever the caller sent; cap it. +fn shown(name: &str) -> String { + name.chars().take(80).collect() +} + +fn unknown_user(app: &App, name: &str) -> Resp { + error( + &app.base, + 401, + &format!("user does not exist [uid: 0, name: {}]", shown(name)), + ) +} + +/// Resolve a token credential (`token`/`Bearer` header, a Basic password that +/// is a token under any username, or a token as the Basic username with an +/// empty password). +pub fn by_token(app: &App, conn: &Connection, cred: &Cred, now: i64) -> Result { + let (secret, shown_name) = match cred { + Cred::Anonymous => return Err(signed_in_only()), + Cred::Header(t) => (t.as_str(), ""), + Cred::Basic { user, pass } if pass.is_empty() => (user.as_str(), user.as_str()), + Cred::Basic { pass, user } => (pass.as_str(), user.as_str()), + }; + match store::resolve_token(conn, secret, now) { + Ok(Some((user, tok))) => Ok(Authed { + user, + scopes: Some(tok.scopes), + }), + Ok(None) => Err(match cred { + // A Basic password that is not a token: say what this route takes. + Cred::Basic { pass, .. } if !pass.is_empty() => error( + &app.base, + 401, + &format!( + "auth required: this route accepts access tokens only [uid: 0, name: {}]", + shown(shown_name) + ), + ), + _ => unknown_user(app, shown_name), + }), + Err(e) => Err(app.internal(&e)), + } +} + +/// Resolve the user's password (`Basic login:password`). Every failure answers +/// the same text, runs one full Argon2 verification (against a dummy hash for +/// an unknown login), is counted for backoff, and is delayed. +pub fn by_password(app: &App, conn: &Connection, req: &Req, cred: &Cred) -> Result { + let Cred::Basic { user: login, pass } = cred else { + return Err(error( + &app.base, + 401, + "Basic authentication with the user's password is required", + ) + .with("www-authenticate", "Basic realm=\"sc-forge\"".into())); + }; + if app.backoff.source_blocked(req.source) { + return Err(error( + &app.base, + 429, + "too many failed password attempts from this address", + ) + .with("retry-after", "60".into())); + } + let found = match store::find_by_login(conn, login) { + Ok(u) => u.filter(|u| !u.is_org), + Err(e) => return Err(app.internal(&e)), + }; + let ok = { + let _slot = app.gate.acquire(); + verify_password(pass, found.as_ref().map(|u| u.password_hash.as_str())) + }; + match (ok, found) { + (true, Some(user)) => { + app.backoff.record_success(login); + if user.must_change_password { + return Err(error( + &app.base, + 403, + "You must change your password before using the API", + )); + } + Ok(Authed { user, scopes: None }) + } + _ => { + std::thread::sleep(app.backoff.record_failure(login, req.source)); + Err(error(&app.base, 401, "invalid username or password")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_decodes_basic_credentials() { + assert_eq!(b64_decode("YWxpY2U6cHc=").unwrap(), b"alice:pw"); + assert_eq!(b64_decode("YWxpY2U6").unwrap(), b"alice:"); + assert!(b64_decode("not base64!").is_none()); + } +} diff --git a/crates/forge/src/cli.rs b/crates/forge/src/cli.rs new file mode 100644 index 00000000..12a15172 --- /dev/null +++ b/crates/forge/src/cli.rs @@ -0,0 +1,242 @@ +use anyhow::{bail, Context, Result}; +use clap::{Args, Parser, Subcommand}; +use scl_forge::limits::{Cidr, Proxies}; +use scl_forge::server::{bind_gate, make_app, serve, Limits}; +use scl_forge::{admin, db}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(Parser)] +#[command(name = "sc-forge", version, about = "A Gitea-compatible Git forge")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Run the HTTP server. + Serve(ServeArgs), + /// Operate on the data directory directly (no network). + Admin { + #[command(subcommand)] + cmd: AdminCmd, + }, +} + +#[derive(Args)] +struct ServeArgs { + #[arg(long)] + data_dir: PathBuf, + /// Address to listen on, e.g. 127.0.0.1:3000. + #[arg(long)] + listen: SocketAddr, + /// External base URL used in every URL the API returns. + #[arg(long)] + public_url: String, + /// A TLS-terminating proxy fronts this server (allows a non-loopback bind). + #[arg(long)] + behind_proxy: bool, + /// Serve plaintext HTTP on a non-loopback address. + #[arg(long)] + allow_plaintext: bool, + /// Header carrying the client address, trusted only from `--trusted-proxies`. + #[arg(long)] + trusted_proxy_header: Option, + /// CIDR blocks whose forwarded-for header is trusted (comma separated). + #[arg(long, value_delimiter = ',')] + trusted_proxies: Vec, + /// Concurrent connections. + #[arg(long, default_value_t = 512)] + max_connections: usize, + /// Largest JSON request body, in bytes. + #[arg(long, default_value_t = 1 << 20)] + max_body_bytes: usize, + /// Concurrent password verifications (Argon2 is memory-hard). + #[arg(long, default_value_t = 4)] + argon2_concurrency: usize, +} + +#[derive(Subcommand)] +enum AdminCmd { + User { + #[command(subcommand)] + cmd: UserCmd, + }, + Org { + #[command(subcommand)] + cmd: OrgCmd, + }, +} + +#[derive(Subcommand)] +enum UserCmd { + Create { + #[arg(long)] + username: String, + #[arg(long)] + password: String, + #[arg(long)] + email: String, + #[arg(long)] + admin: bool, + /// `--must-change-password` and `--must-change-password=false` both parse. + #[arg(long, num_args = 0..=1, default_missing_value = "true", default_value = "true", action = clap::ArgAction::Set)] + must_change_password: bool, + #[arg(long)] + data_dir: PathBuf, + }, + ChangePassword { + #[arg(long)] + username: String, + #[arg(long)] + password: String, + #[arg(long, num_args = 0..=1, default_missing_value = "true", default_value = "true", action = clap::ArgAction::Set)] + must_change_password: bool, + #[arg(long)] + data_dir: PathBuf, + }, + GenerateAccessToken { + #[arg(long)] + username: String, + #[arg(long, default_value = "sc-forge-admin")] + token_name: String, + #[arg(long, value_delimiter = ',', default_value = "all")] + scopes: Vec, + /// Print only the token. + #[arg(long)] + raw: bool, + #[arg(long)] + data_dir: PathBuf, + }, +} + +#[derive(Subcommand)] +enum OrgCmd { + Create { + #[arg(long)] + name: String, + #[arg(long)] + owner: String, + #[arg(long)] + data_dir: PathBuf, + }, +} + +pub fn run() -> Result<()> { + match Cli::parse().cmd { + Cmd::Serve(a) => run_serve(a), + Cmd::Admin { cmd } => run_admin(cmd), + } +} + +fn run_admin(cmd: AdminCmd) -> Result<()> { + match cmd { + AdminCmd::User { + cmd: + UserCmd::Create { + username, + password, + email, + admin: a, + must_change_password, + data_dir, + }, + } => { + admin::user_create( + &data_dir, + &username, + &password, + &email, + a, + must_change_password, + )?; + println!("New user '{username}' has been successfully created!"); + } + AdminCmd::User { + cmd: + UserCmd::ChangePassword { + username, + password, + must_change_password, + data_dir, + }, + } => { + admin::user_change_password(&data_dir, &username, &password, must_change_password)?; + println!("{username}'s password has been successfully updated!"); + } + AdminCmd::User { + cmd: + UserCmd::GenerateAccessToken { + username, + token_name, + scopes, + raw, + data_dir, + }, + } => { + let t = admin::generate_access_token(&data_dir, &username, &token_name, &scopes)?; + if raw { + println!("{t}"); + } else { + println!("Access token was successfully created: {t}"); + } + } + AdminCmd::Org { + cmd: + OrgCmd::Create { + name, + owner, + data_dir, + }, + } => { + admin::org_create(&data_dir, &name, &owner)?; + println!("Organization '{name}' has been successfully created!"); + } + } + Ok(()) +} + +fn run_serve(a: ServeArgs) -> Result<()> { + bind_gate(&a.listen, a.behind_proxy, a.allow_plaintext).map_err(anyhow::Error::msg)?; + let cidrs = a + .trusted_proxies + .iter() + .map(|c| Cidr::parse(c)) + .collect::, _>>() + .map_err(anyhow::Error::msg)?; + if a.trusted_proxy_header.is_some() != !cidrs.is_empty() { + bail!("--trusted-proxy-header and --trusted-proxies must be given together"); + } + let proxies = Proxies { + header: a.trusted_proxy_header.map(|h| h.to_ascii_lowercase()), + cidrs, + }; + db::open_for_serve(&a.data_dir)?; + let app = make_app(a.data_dir, &a.public_url, proxies, a.argon2_concurrency); + let limits = Limits { + max_body: a.max_body_bytes, + max_connections: a.max_connections, + ..Limits::default() + }; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(async { + let listener = tokio::net::TcpListener::bind(a.listen) + .await + .with_context(|| format!("bind {}", a.listen))?; + eprintln!("sc-forge: listening on {}", listener.local_addr()?); + let stop = async { + use tokio::signal::unix::{signal, SignalKind}; + let (mut term, mut int) = ( + signal(SignalKind::terminate()).expect("signal"), + signal(SignalKind::interrupt()).expect("signal"), + ); + tokio::select! { _ = term.recv() => {}, _ = int.recv() => {} } + }; + serve(listener, app, limits, stop, Duration::from_secs(30)).await; + Ok(()) + }) +} diff --git a/crates/forge/src/db.rs b/crates/forge/src/db.rs new file mode 100644 index 00000000..bb7d2d91 --- /dev/null +++ b/crates/forge/src/db.rs @@ -0,0 +1,185 @@ +//! SQLite metadata: connection settings and numbered migrations. +//! +//! `serve` migrates at startup; `admin` migrates only a database that does not +//! exist yet (or is at schema version 0), so the documented `admin user create` +//! then `serve` sequence works on an empty data directory. On an existing +//! database `admin` refuses a version mismatch, and `serve` refuses a schema +//! newer than its own. + +use rusqlite::Connection; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +/// Schema version this binary reads and writes. +pub const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; + +const MIGRATIONS: &[&str] = &[ + // 1: users and organizations (one table), org owners, access tokens. + "CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT NOT NULL, + lower_login TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL CHECK (kind IN ('user', 'org')), + login_name TEXT NOT NULL DEFAULT '', + full_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL DEFAULT '', + must_change_password INTEGER NOT NULL DEFAULT 0, + is_admin INTEGER NOT NULL DEFAULT 0, + visibility TEXT NOT NULL DEFAULT 'public' + CHECK (visibility IN ('public', 'limited', 'private')), + created_unix INTEGER NOT NULL + ); + CREATE TABLE org_members ( + org_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (org_id, user_id) + ); + CREATE TABLE tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + hash TEXT NOT NULL UNIQUE, + last_eight TEXT NOT NULL, + scopes TEXT NOT NULL, + created_unix INTEGER NOT NULL, + expires_unix INTEGER, + UNIQUE (user_id, name) + );", +]; + +/// Database failures a caller reports to the operator. +#[derive(Debug, thiserror::Error)] +pub enum DbError { + #[error("database error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Schema(String), +} + +/// Path of the metadata database inside the data directory. +pub fn db_path(data_dir: &Path) -> PathBuf { + data_dir.join("forge.db") +} + +/// Open a connection with the durability and integrity settings every +/// connection needs (WAL, `synchronous=FULL`, foreign keys, a busy timeout). +pub fn connect(data_dir: &Path) -> Result { + let conn = Connection::open(db_path(data_dir))?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "FULL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(conn) +} + +fn version(conn: &Connection) -> Result { + Ok(conn.query_row("PRAGMA user_version", [], |r| r.get(0))?) +} + +fn migrate(conn: &mut Connection) -> Result<(), DbError> { + let have = version(conn)?; + for (i, sql) in MIGRATIONS.iter().enumerate().skip(have as usize) { + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", i as i64 + 1)?; + tx.commit()?; + } + Ok(()) +} + +/// Create the data directory (owner-only: the database holds password hashes). +fn ensure_dir(data_dir: &Path) -> Result<(), DbError> { + if !data_dir.exists() { + std::fs::create_dir_all(data_dir)?; + std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +/// Open for `serve`: migrate forward, refuse a newer schema. +pub fn open_for_serve(data_dir: &Path) -> Result<(), DbError> { + ensure_dir(data_dir)?; + let mut conn = connect(data_dir)?; + let have = version(&conn)?; + if have > SCHEMA_VERSION { + return Err(DbError::Schema(format!( + "database schema {have} is newer than this binary's {SCHEMA_VERSION}; upgrade sc-forge" + ))); + } + migrate(&mut conn) +} + +/// Open for `admin`: migrate only a new (version 0) database, refuse a mismatch. +pub fn open_for_admin(data_dir: &Path) -> Result { + ensure_dir(data_dir)?; + let mut conn = connect(data_dir)?; + let have = version(&conn)?; + if have == 0 { + migrate(&mut conn)?; + } else if have != SCHEMA_VERSION { + return Err(DbError::Schema(format!( + "database schema {have} differs from this binary's {SCHEMA_VERSION}; run `sc-forge serve` once with the matching binary" + ))); + } + Ok(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("scl-forge-db-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn admin_bootstraps_an_empty_dir_and_serve_accepts_it() { + let d = tmp("boot"); + let conn = open_for_admin(&d).unwrap(); + assert_eq!(version(&conn).unwrap(), SCHEMA_VERSION); + let mode = std::fs::metadata(&d).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "data dir holds password hashes"); + open_for_serve(&d).unwrap(); + let _ = std::fs::remove_dir_all(&d); + assert!(!d.exists()); + } + + #[test] + fn connection_settings_are_durable() { + let d = tmp("pragmas"); + let conn = open_for_admin(&d).unwrap(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + let sync: i64 = conn + .query_row("PRAGMA synchronous", [], |r| r.get(0)) + .unwrap(); + let fk: i64 = conn + .query_row("PRAGMA foreign_keys", [], |r| r.get(0)) + .unwrap(); + assert_eq!((mode.as_str(), sync, fk), ("wal", 2, 1)); // 2 = FULL + drop(conn); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn admin_refuses_a_mismatched_schema_and_serve_a_newer_one() { + let d = tmp("mismatch"); + drop(open_for_admin(&d).unwrap()); + let conn = connect(&d).unwrap(); + conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1) + .unwrap(); + drop(conn); + assert!(matches!(open_for_admin(&d), Err(DbError::Schema(_)))); + assert!(matches!(open_for_serve(&d), Err(DbError::Schema(_)))); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/forge/src/http.rs b/crates/forge/src/http.rs new file mode 100644 index 00000000..d5a68dc8 --- /dev/null +++ b/crates/forge/src/http.rs @@ -0,0 +1,91 @@ +//! Request/response types and Gitea's JSON error shapes. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::net::IpAddr; + +/// A fully read request, handed to the synchronous router. +pub struct Req { + pub method: String, + pub path: String, + pub query: String, + /// Header names lower-cased. + pub headers: HashMap, + pub body: Vec, + /// The client address after trusted-proxy resolution (rate-limit key). + pub source: IpAddr, +} + +impl Req { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).map(String::as_str) + } + + /// First value of query parameter `key`, percent-decoding not applied + /// (F1 parameters are plain integers). + pub fn param(&self, key: &str) -> Option<&str> { + self.query + .split('&') + .filter_map(|kv| kv.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + } +} + +/// A response the server writes out. +pub struct Resp { + pub status: u16, + pub headers: Vec<(&'static str, String)>, + pub body: Vec, +} + +const JSON_CT: &str = "application/json;charset=utf-8"; + +impl Resp { + pub fn json(status: u16, v: &Value) -> Resp { + Resp { + status, + headers: vec![("content-type", JSON_CT.into())], + body: serde_json::to_vec(v).expect("serializing a Value cannot fail"), + } + } + + pub fn no_content() -> Resp { + Resp { + status: 204, + headers: vec![], + body: vec![], + } + } + + pub fn with(mut self, name: &'static str, value: String) -> Resp { + self.headers.push((name, value)); + self + } +} + +/// Where Gitea's error bodies point (`url`); built from `--public-url`. +fn swagger(base: &str) -> String { + format!("{base}/api/swagger") +} + +/// `{"message", "url"}` with the recorded status and text. +pub fn error(base: &str, status: u16, message: &str) -> Resp { + Resp::json(status, &json!({"message": message, "url": swagger(base)})) +} + +/// Gitea's 404 shape: `errors` is null or a list of detail strings. +pub fn not_found(base: &str, message: &str, errors: Option<&str>) -> Resp { + Resp::json( + 404, + &json!({"errors": errors.map(|e| vec![e]), "message": message, "url": swagger(base)}), + ) +} + +/// The anonymous 403 recorded on every API route: no `url` key. +pub fn signed_in_only() -> Resp { + Resp::json( + 403, + &json!({"message": "Only signed in user is allowed to call APIs."}), + ) +} diff --git a/crates/forge/src/lib.rs b/crates/forge/src/lib.rs new file mode 100644 index 00000000..12f413d7 --- /dev/null +++ b/crates/forge/src/lib.rs @@ -0,0 +1,19 @@ +//! `scl-forge` — `sc-forge`, a Gitea-compatible Git forge. +//! +//! The whole implementation is unix-only (it supervises `git` child processes +//! from F2 on); on other targets the crate is empty and the binary prints a +//! notice. Design: `docs/superpowers/specs/2026-10-22-p38-forge-design.md`. +#![cfg(unix)] + +pub mod admin; +pub mod api; +pub mod auth; +pub mod db; +pub mod http; +pub mod limits; +pub mod names; +pub mod routes; +pub mod scope; +pub mod server; +pub mod store; +pub mod timefmt; diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs new file mode 100644 index 00000000..07fc2c77 --- /dev/null +++ b/crates/forge/src/limits.rs @@ -0,0 +1,266 @@ +//! Abuse limits: password-attempt backoff, the global Argon2 concurrency cap, +//! and trusted-proxy source resolution. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +/// Counting gate that bounds concurrent password verifications. +pub struct Gate { + free: Mutex, + cv: Condvar, +} + +/// A held slot in a [`Gate`]; released on drop. +pub struct Permit<'a>(&'a Gate); + +impl Gate { + pub fn new(slots: usize) -> Gate { + Gate { + free: Mutex::new(slots.max(1)), + cv: Condvar::new(), + } + } + + /// Block until a slot is free. + pub fn acquire(&self) -> Permit<'_> { + let mut n = self.free.lock().unwrap_or_else(|e| e.into_inner()); + while *n == 0 { + n = self.cv.wait(n).unwrap_or_else(|e| e.into_inner()); + } + *n -= 1; + Permit(self) + } +} + +impl Drop for Permit<'_> { + fn drop(&mut self) { + *self.0.free.lock().unwrap_or_else(|e| e.into_inner()) += 1; + self.0.cv.notify_one(); + } +} + +const WINDOW: Duration = Duration::from_secs(600); +const SOURCE_WINDOW: Duration = Duration::from_secs(60); +/// Failed attempts one source may make per minute before it is refused. +const SOURCE_CAP: usize = 30; +const MAX_DELAY: Duration = Duration::from_secs(2); +const MAX_ENTRIES: usize = 10_000; + +/// Failure bookkeeping for password logins. Not a lockout: a stranger failing +/// against `alice` slows other *wrong* guesses but never blocks alice's right +/// one, and a flooding source is refused alone. +#[derive(Default)] +pub struct Backoff { + by_login: Mutex>>, + by_source: Mutex>>, +} + +fn prune(v: &mut Vec, window: Duration) { + let now = Instant::now(); + v.retain(|t| now.duration_since(*t) < window); +} + +impl Backoff { + /// Has this source exhausted its per-minute failure budget? + pub fn source_blocked(&self, ip: IpAddr) -> bool { + let mut m = self.by_source.lock().unwrap_or_else(|e| e.into_inner()); + match m.get_mut(&ip) { + Some(v) => { + prune(v, SOURCE_WINDOW); + v.len() >= SOURCE_CAP + } + None => false, + } + } + + /// Record a failed attempt; returns how long to delay the failure response + /// (doubling from 50 ms, capped at 2 s). + pub fn record_failure(&self, login: &str, ip: IpAddr) -> Duration { + let n = { + let mut m = self.by_login.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > MAX_ENTRIES { + m.retain(|_, v| { + prune(v, WINDOW); + !v.is_empty() + }); + } + let v = m.entry(login.to_lowercase()).or_default(); + prune(v, WINDOW); + v.push(Instant::now()); + v.len() as u32 + }; + { + let mut m = self.by_source.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > MAX_ENTRIES { + m.retain(|_, v| { + prune(v, SOURCE_WINDOW); + !v.is_empty() + }); + } + m.entry(ip).or_default().push(Instant::now()); + } + Duration::from_millis(50u64.saturating_mul(1u64 << (n - 1).min(16))).min(MAX_DELAY) + } + + /// A success clears the login's failures. + pub fn record_success(&self, login: &str) { + self.by_login + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&login.to_lowercase()); + } +} + +/// One CIDR block, e.g. `10.0.0.0/8` or `::1/128`. +#[derive(Clone, Debug)] +pub struct Cidr { + net: IpAddr, + bits: u8, +} + +impl Cidr { + pub fn parse(s: &str) -> Result { + let (addr, bits) = s.split_once('/').unwrap_or((s, "")); + let net: IpAddr = addr + .parse() + .map_err(|_| format!("bad address in CIDR {s:?}"))?; + let max = if net.is_ipv4() { 32 } else { 128 }; + let bits = if bits.is_empty() { + max + } else { + bits.parse() + .map_err(|_| format!("bad prefix in CIDR {s:?}"))? + }; + if bits > max { + return Err(format!("prefix too long in CIDR {s:?}")); + } + Ok(Cidr { net, bits }) + } + + pub fn contains(&self, ip: IpAddr) -> bool { + fn v(ip: IpAddr) -> u128 { + match ip { + IpAddr::V4(a) => u128::from(u32::from(a)), + IpAddr::V6(a) => u128::from(a), + } + } + if self.net.is_ipv4() != ip.is_ipv4() { + return false; + } + let width = if ip.is_ipv4() { 32 } else { 128 }; + let shift = width - u32::from(self.bits); + shift == width || (v(self.net) >> shift) == (v(ip) >> shift) + } +} + +/// Trusted-proxy configuration for resolving the real client address. +#[derive(Clone, Default)] +pub struct Proxies { + pub header: Option, + pub cidrs: Vec, +} + +impl Proxies { + /// The address to rate-limit: the forwarded-for value only when the header + /// is configured **and** the connection's peer is inside a trusted CIDR; + /// otherwise the peer. (A header from any other peer is ignored, so a + /// client cannot spoof its address into the limiter.) + pub fn resolve(&self, peer: IpAddr, header_value: Option<&str>) -> IpAddr { + let Some(v) = header_value else { return peer }; + if self.header.is_none() || !self.cidrs.iter().any(|c| c.contains(peer)) { + return peer; + } + // Walk from the right: the first address that is not one of our own + // trusted proxies is the client as the outermost proxy saw it. + v.rsplit(',') + .filter_map(|s| s.trim().parse::().ok()) + .find(|a| !self.cidrs.iter().any(|c| c.contains(*a))) + .unwrap_or(peer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn delay_doubles_and_caps_and_success_resets() { + let b = Backoff::default(); + let d: Vec = (0..8) + .map(|_| b.record_failure("Alice", ip("1.1.1.1")).as_millis()) + .collect(); + assert_eq!(d, vec![50, 100, 200, 400, 800, 1600, 2000, 2000]); + b.record_success("alice"); // case-insensitive key + assert_eq!(b.record_failure("alice", ip("1.1.1.1")).as_millis(), 50); + } + + #[test] + fn a_flooding_source_is_blocked_but_others_are_not() { + let b = Backoff::default(); + for i in 0..SOURCE_CAP { + assert!(!b.source_blocked(ip("9.9.9.9")), "attempt {i}"); + b.record_failure(&format!("u{i}"), ip("9.9.9.9")); + } + assert!(b.source_blocked(ip("9.9.9.9"))); + assert!(!b.source_blocked(ip("8.8.8.8"))); + } + + #[test] + fn cidr_membership() { + let c = Cidr::parse("10.0.0.0/8").unwrap(); + assert!(c.contains(ip("10.1.2.3"))); + assert!(!c.contains(ip("11.0.0.1"))); + assert!(!c.contains(ip("::1"))); + assert!(Cidr::parse("::1/128").unwrap().contains(ip("::1"))); + assert!(Cidr::parse("0.0.0.0/0") + .unwrap() + .contains(ip("203.0.113.9"))); + assert!(Cidr::parse("10.0.0.0/33").is_err()); + } + + #[test] + fn forwarded_for_is_trusted_only_from_a_trusted_peer() { + let p = Proxies { + header: Some("x-forwarded-for".into()), + cidrs: vec![Cidr::parse("127.0.0.1/32").unwrap()], + }; + assert_eq!( + p.resolve(ip("127.0.0.1"), Some("6.6.6.6, 5.5.5.5")), + ip("5.5.5.5") + ); + // a chain of our own proxies is skipped from the right + assert_eq!( + p.resolve(ip("127.0.0.1"), Some("6.6.6.6, 5.5.5.5, 127.0.0.1")), + ip("5.5.5.5") + ); + assert_eq!(p.resolve(ip("7.7.7.7"), Some("6.6.6.6")), ip("7.7.7.7")); + assert_eq!(p.resolve(ip("127.0.0.1"), None), ip("127.0.0.1")); + assert_eq!( + Proxies::default().resolve(ip("127.0.0.1"), Some("6.6.6.6")), + ip("127.0.0.1") + ); + } + + #[test] + fn gate_bounds_concurrency() { + let g = Gate::new(1); + let p = g.acquire(); + std::thread::scope(|s| { + let h = s.spawn(|| { + let _q = g.acquire(); + }); + std::thread::sleep(Duration::from_millis(50)); + assert!( + !h.is_finished(), + "second acquire must wait for the first permit" + ); + drop(p); + }); + } +} diff --git a/crates/forge/src/main.rs b/crates/forge/src/main.rs new file mode 100644 index 00000000..8dd19a63 --- /dev/null +++ b/crates/forge/src/main.rs @@ -0,0 +1,18 @@ +//! `sc-forge` binary: `serve`, `admin …`. + +#[cfg(not(unix))] +fn main() { + eprintln!("sc-forge is unix-only"); + std::process::exit(2); +} + +#[cfg(unix)] +mod cli; + +#[cfg(unix)] +fn main() { + if let Err(e) = cli::run() { + eprintln!("sc-forge: {e:#}"); + std::process::exit(1); + } +} diff --git a/crates/forge/src/names.rs b/crates/forge/src/names.rs new file mode 100644 index 00000000..3eaf36e9 --- /dev/null +++ b/crates/forge/src/names.rs @@ -0,0 +1,98 @@ +//! User and organization name rules. They share the URL namespace with the +//! top-level routes, so they carry a reserved list (the union of Gitea's +//! 1.22.6 and 1.26.2 lists plus `api`, `admin`, `assets`, `user`, `login`). + +/// Why a name was refused; `message` is the Gitea-shaped text (without the +/// trailing `[name: x]` detail for the pattern cases). +pub enum NameError { + Reserved, + Invalid, + TooLong, +} + +const MAX_LEN: usize = 40; + +const RESERVED: &[&str] = &[ + ".", + "..", + ".well-known", + "admin", + "api", + "assets", + "attachments", + "avatar", + "avatars", + "captcha", + "commits", + "debug", + "devtools", + "error", + "explore", + "favicon.ico", + "ghost", + "issues", + "login", + "manifest.json", + "metrics", + "milestones", + "new", + "notifications", + "org", + "pulls", + "raw", + "repo", + "repo-avatars", + "robots.txt", + "search", + "serviceworker.js", + "ssh_info", + "swagger.v1.json", + "user", + "v2", + "healthz", + "version", + "-", +]; + +const RESERVED_SUFFIX: &[&str] = &[".keys", ".gpg", ".rss", ".atom"]; + +/// Validate a user or organization name. Matching is case-insensitive for the +/// reserved list; the character rule is Gitea's `^[0-9A-Za-z][-.\w]*$`. +pub fn check(name: &str) -> Result<(), NameError> { + if name.chars().count() > MAX_LEN { + return Err(NameError::TooLong); + } + let mut chars = name.chars(); + let ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')); + if !ok { + return Err(NameError::Invalid); + } + let lower = name.to_ascii_lowercase(); + if RESERVED.contains(&lower.as_str()) || RESERVED_SUFFIX.iter().any(|s| lower.ends_with(s)) { + return Err(NameError::Reserved); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn follows_the_recorded_rules() { + // Recorded 201: `trail.git`, `org.git`; recorded 422: the rest. + assert!(check("alice").is_ok()); + assert!(check("trail.git").is_ok()); + assert!(check("a_b-c.d").is_ok()); + assert!(matches!(check(".dot"), Err(NameError::Invalid))); + assert!(matches!(check("-dash"), Err(NameError::Invalid))); + assert!(matches!(check("has space"), Err(NameError::Invalid))); + assert!(matches!(check(""), Err(NameError::Invalid))); + assert!(matches!(check("API"), Err(NameError::Reserved))); + assert!(matches!(check("admin"), Err(NameError::Reserved))); + assert!(matches!(check("x.keys"), Err(NameError::Reserved))); + assert!(matches!(check(&"x".repeat(41)), Err(NameError::TooLong))); + assert!(check(&"x".repeat(40)).is_ok()); + } +} diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs new file mode 100644 index 00000000..14c25bdf --- /dev/null +++ b/crates/forge/src/routes.rs @@ -0,0 +1,356 @@ +//! The router table: every route declares its credential class, scope +//! requirement and actor rule in one place, and `dispatch` enforces them in a +//! fixed order (route match, authentication, scope, actor, handler) so no +//! handler can run unauthenticated or skip a scope check. + +use crate::auth::{self, Authed}; +use crate::http::{error, not_found, Req, Resp}; +use crate::limits::{Backoff, Gate, Proxies}; +use crate::scope::{self, Cat, Level, Need}; +use crate::{api, db}; +use rusqlite::Connection; +use std::path::PathBuf; + +/// Process-wide state shared by every request. +pub struct App { + pub data_dir: PathBuf, + /// `--public-url` without a trailing slash; the base of every URL in bodies. + pub base: String, + pub backoff: Backoff, + pub gate: Gate, + pub proxies: Proxies, +} + +impl App { + pub fn new(data_dir: PathBuf, public_url: &str, proxies: Proxies, argon_slots: usize) -> App { + App { + data_dir, + base: public_url.trim_end_matches('/').to_string(), + backoff: Backoff::default(), + gate: Gate::new(argon_slots), + proxies, + } + } + + /// Log the detail, answer a bare 500: database errors never reach clients. + pub fn internal(&self, e: &dyn std::fmt::Display) -> Resp { + eprintln!("sc-forge: internal error: {e}"); + error(&self.base, 500, "internal server error") + } +} + +/// Which credential a route accepts. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Class { + /// Access token only (`token`, `Bearer`, Basic with the token). + Token, + /// The user's password only (`Basic login:password`). + Password, +} + +/// Who may call, beyond holding a valid credential. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Actor { + Any, + SiteAdmin, + /// The caller must be the user named by this path parameter, or a site admin. + SelfOrSiteAdmin(&'static str), +} + +/// What a handler receives; authentication and policy already passed. +pub struct Ctx<'a> { + pub app: &'a App, + pub req: &'a Req, + pub conn: &'a mut Connection, + pub who: &'a Authed, + params: Params, +} + +impl Ctx<'_> { + pub fn param(&self, name: &str) -> &str { + self.params + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.as_str()) + .unwrap_or("") + } +} + +type Handler = fn(&mut Ctx) -> Resp; + +/// One declared route. `pattern` is relative to `/api/v1`; `{x}` captures a +/// segment. +pub struct Route { + pub method: &'static str, + pub pattern: &'static str, + pub class: Class, + pub need: Option, + pub actor: Actor, + pub handler: Handler, +} + +const fn r( + method: &'static str, + pattern: &'static str, + class: Class, + need: Option, + actor: Actor, + handler: Handler, +) -> Route { + Route { + method, + pattern, + class, + need, + actor, + handler, + } +} + +const fn n(cat: Cat, level: Level) -> Option { + Some(Need(cat, level)) +} + +use Actor::{Any, SelfOrSiteAdmin, SiteAdmin}; +use Class::{Password, Token}; + +/// The route table. F2 onward append rows; a row cannot omit its class, +/// scope, or actor rule. +pub static ROUTES: &[Route] = &[ + r("GET", "/version", Token, None, Any, api::version), + r( + "GET", + "/user", + Token, + n(Cat::User, Level::Read), + Any, + api::current_user, + ), + r( + "GET", + "/users/{u}", + Token, + n(Cat::User, Level::Read), + Any, + api::get_user, + ), + r( + "GET", + "/users/{u}/orgs/{o}/permissions", + Token, + n(Cat::Organization, Level::Read), + Any, + api::org_permissions, + ), + r( + "GET", + "/users/{u}/tokens", + Password, + None, + SelfOrSiteAdmin("u"), + api::list_tokens, + ), + r( + "POST", + "/users/{u}/tokens", + Password, + None, + SelfOrSiteAdmin("u"), + api::create_token, + ), + r( + "DELETE", + "/users/{u}/tokens/{id}", + Password, + None, + SelfOrSiteAdmin("u"), + api::delete_token, + ), + r( + "GET", + "/admin/users", + Token, + n(Cat::Admin, Level::Read), + SiteAdmin, + api::admin_list_users, + ), + r( + "POST", + "/admin/users", + Token, + n(Cat::Admin, Level::Write), + SiteAdmin, + api::admin_create_user, + ), + r( + "PATCH", + "/admin/users/{u}", + Token, + n(Cat::Admin, Level::Write), + SiteAdmin, + api::admin_patch_user, + ), + r( + "POST", + "/orgs", + Token, + n(Cat::Organization, Level::Write), + Any, + api::create_org, + ), + r( + "GET", + "/orgs/{o}", + Token, + n(Cat::Organization, Level::Read), + Any, + api::get_org, + ), + // Declared now so the scope check applies; the handler arrives with F2. + r( + "POST", + "/orgs/{o}/repos", + Token, + n(Cat::Organization, Level::Write), + Any, + api::not_implemented, + ), +]; + +/// Captured path parameters, in pattern order. +type Params = Vec<(&'static str, String)>; + +fn match_pattern(pattern: &'static str, path: &str) -> Option { + let (mut ps, mut xs) = (pattern.split('/'), path.split('/')); + let mut caps = Vec::new(); + loop { + match (ps.next(), xs.next()) { + (None, None) => return Some(caps), + (Some(p), Some(x)) => { + if let Some(name) = p.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + if x.is_empty() { + return None; + } + caps.push((name, x.to_string())); + } else if p != x { + return None; + } + } + _ => return None, + } + } +} + +/// Find the row for `method` and `path` (relative to `/api/v1`). `Err(true)` +/// means the path exists under another method. +pub fn resolve(method: &str, path: &str) -> Result<(&'static Route, Params), bool> { + let mut path_known = false; + for route in ROUTES { + if let Some(caps) = match_pattern(route.pattern, path) { + if route.method == method { + return Ok((route, caps)); + } + path_known = true; + } + } + Err(path_known) +} + +fn healthz(app: &App) -> Resp { + let ok = db::connect(&app.data_dir) + .and_then(|c| Ok(c.query_row("SELECT 1", [], |_| Ok(()))?)) + .is_ok(); + let now = crate::timefmt::rfc3339(crate::timefmt::now()); + let mut r = Resp::json( + if ok { 200 } else { 503 }, + &serde_json::json!({ + "status": if ok { "pass" } else { "fail" }, + "description": "sc-forge", + "checks": {"database:ping": [{"status": if ok { "pass" } else { "fail" }, "time": now}]}, + }), + ); + r.headers[0].1 = "application/json".into(); + r +} + +/// Handle one request end to end. Blocking (SQLite, Argon2): the server runs it +/// on a blocking thread. +pub fn dispatch(app: &App, req: &Req) -> Resp { + if req.method == "GET" && matches!(req.path.as_str(), "/api/healthz" | "/healthz") { + return healthz(app); + } + let Some(path) = req.path.strip_prefix("/api/v1") else { + return not_found(&app.base, "The target couldn't be found.", None); + }; + let (route, params) = match resolve(&req.method, path) { + Ok(x) => x, + Err(path_known) => { + // Authenticate before answering for a path with no row, so a + // caller learns nothing about the API surface anonymously + // (anonymous 403, bad token 401, as on declared routes). + let unauth = db::connect(&app.data_dir) + .map_err(|e| app.internal(&e)) + .and_then(|c| auth::by_token(app, &c, &auth::parse(req), crate::timefmt::now())); + if let Err(resp) = unauth { + return resp; + } + return if path_known { + error(&app.base, 405, "method not allowed") + } else { + not_found(&app.base, "not found: no such API route", None) + }; + } + }; + let mut conn = match db::connect(&app.data_dir) { + Ok(c) => c, + Err(e) => return app.internal(&e), + }; + let cred = auth::parse(req); + let now = crate::timefmt::now(); + let who = match route.class { + Class::Token => auth::by_token(app, &conn, &cred, now), + Class::Password => auth::by_password(app, &conn, req, &cred), + }; + let who = match who { + Ok(w) => w, + Err(resp) => return resp, + }; + if let (Some(need), Some(scopes)) = (route.need, &who.scopes) { + if !scope::allows(scopes, need) { + return error( + &app.base, + 403, + &format!( + "token does not have at least one of required scope(s): {}", + need.label() + ), + ); + } + } + let denied = match route.actor { + Actor::Any => false, + Actor::SiteAdmin => !who.user.is_admin, + Actor::SelfOrSiteAdmin(p) => { + let target = params + .iter() + .find(|(k, _)| *k == p) + .map(|(_, v)| v.as_str()) + .unwrap_or(""); + !who.user.is_admin && !who.user.login.eq_ignore_ascii_case(target) + } + }; + if denied { + let msg = match route.actor { + Actor::SiteAdmin => "You must be a site admin to use this route", + _ => "doer should be the site admin or be same as the contextUser", + }; + return error(&app.base, 403, msg); + } + (route.handler)(&mut Ctx { + app, + req, + conn: &mut conn, + who: &who, + params, + }) +} diff --git a/crates/forge/src/scope.rs b/crates/forge/src/scope.rs new file mode 100644 index 00000000..49463e78 --- /dev/null +++ b/crates/forge/src/scope.rs @@ -0,0 +1,182 @@ +//! Access-token scopes: parsing, normalization into Gitea's canonical order, +//! and the allow check. Accepted set: `all`, `read|write:{admin,issue, +//! organization,repository,user}` and `public-only`. + +/// A scope category a route can require. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cat { + Admin, + Issue, + Organization, + Repository, + User, +} + +impl Cat { + fn name(self) -> &'static str { + match self { + Cat::Admin => "admin", + Cat::Issue => "issue", + Cat::Organization => "organization", + Cat::Repository => "repository", + Cat::User => "user", + } + } +} + +/// Read or write access within a category (`write` implies `read`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Level { + Read, + Write, +} + +/// A route's scope requirement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Need(pub Cat, pub Level); + +impl Need { + /// The text Gitea prints in the 403 body, e.g. `[write:user]`. + pub fn label(self) -> String { + let l = if self.1 == Level::Write { + "write" + } else { + "read" + }; + format!("[{l}:{}]", self.0.name()) + } +} + +const CATS: [Cat; 5] = [ + Cat::Admin, + Cat::Issue, + Cat::Organization, + Cat::Repository, + Cat::User, +]; + +fn parse_one(s: &str) -> Option<(Cat, Level)> { + let (level, cat) = s.split_once(':')?; + let level = match level { + "read" => Level::Read, + "write" => Level::Write, + _ => return None, + }; + CATS.iter().find(|c| c.name() == cat).map(|c| (*c, level)) +} + +/// Normalize a requested scope list: validate, drop a `read` shadowed by a +/// `write` of the same category, collapse to `all` when present, and order as +/// Gitea does (`public-only`, then categories alphabetically). `Err` carries +/// the offending scope string. +pub fn normalize(requested: &[String]) -> Result, String> { + let mut public_only = false; + let mut all = false; + let mut by_cat: Vec<(Cat, Level)> = Vec::new(); + for s in requested { + match s.as_str() { + "public-only" => public_only = true, + "all" => all = true, + other => { + let (cat, level) = parse_one(other).ok_or_else(|| other.to_string())?; + match by_cat.iter_mut().find(|(c, _)| *c == cat) { + Some((_, l)) => *l = (*l).max_level(level), + None => by_cat.push((cat, level)), + } + } + } + } + let mut out = Vec::new(); + if public_only { + out.push("public-only".to_string()); + } + if all { + out.push("all".to_string()); + return Ok(out); + } + by_cat.sort_by_key(|(c, _)| c.name()); + for (c, l) in by_cat { + let l = if l == Level::Write { "write" } else { "read" }; + out.push(format!("{l}:{}", c.name())); + } + Ok(out) +} + +trait MaxLevel { + fn max_level(self, other: Self) -> Self; +} +impl MaxLevel for Level { + fn max_level(self, other: Self) -> Self { + if self == Level::Write || other == Level::Write { + Level::Write + } else { + Level::Read + } + } +} + +/// Does a stored (normalized) scope list satisfy `need`? +pub fn allows(scopes: &[String], need: Need) -> bool { + scopes.iter().any(|s| { + if s == "all" { + return true; + } + match parse_one(s) { + Some((c, l)) => c == need.0 && (l == Level::Write || need.1 == Level::Read), + None => false, + } + }) +} + +/// Is the token restricted to public repositories? +pub fn public_only(scopes: &[String]) -> bool { + scopes.iter().any(|s| s == "public-only") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn write_shadows_read_and_order_is_canonical() { + // Recorded on Gitea 1.22.6 (scenario B). + assert_eq!( + normalize(&v(&["read:repository", "write:repository"])).unwrap(), + v(&["write:repository"]) + ); + assert_eq!( + normalize(&v(&["read:user", "read:organization", "write:repository"])).unwrap(), + v(&["read:organization", "write:repository", "read:user"]) + ); + assert_eq!( + normalize(&v(&["read:repository", "public-only"])).unwrap(), + v(&["public-only", "read:repository"]) + ); + } + + #[test] + fn all_collapses_and_unknown_is_rejected() { + assert_eq!(normalize(&v(&["all", "read:user"])).unwrap(), v(&["all"])); + assert_eq!(normalize(&v(&["nonsense"])).unwrap_err(), "nonsense"); + assert_eq!( + normalize(&v(&["write:package"])).unwrap_err(), + "write:package" + ); + } + + #[test] + fn allows_follows_write_implies_read() { + let s = v(&["write:repository", "read:user"]); + assert!(allows(&s, Need(Cat::Repository, Level::Read))); + assert!(allows(&s, Need(Cat::Repository, Level::Write))); + assert!(allows(&s, Need(Cat::User, Level::Read))); + assert!(!allows(&s, Need(Cat::User, Level::Write))); + assert!(!allows(&s, Need(Cat::Admin, Level::Read))); + assert!(allows(&v(&["all"]), Need(Cat::Admin, Level::Write))); + assert!(!allows(&v(&["public-only"]), Need(Cat::User, Level::Read))); + } +} diff --git a/crates/forge/src/server.rs b/crates/forge/src/server.rs new file mode 100644 index 00000000..31bc39fe --- /dev/null +++ b/crates/forge/src/server.rs @@ -0,0 +1,158 @@ +//! The HTTP listener: hyper (http1) over tokio. Bounded body, header-read +//! timeout and connection cap here; routing, SQLite and Argon2 run on blocking +//! threads in `routes::dispatch`. + +use crate::http::{Req, Resp}; +use crate::limits::Proxies; +use crate::routes::{dispatch, App}; +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::{Bytes, Incoming}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +/// `Server:` header value: the server's own identity (`/version` answers +/// Gitea's number because clients sniff it). +const SERVER: &str = concat!("sc-forge/", env!("CARGO_PKG_VERSION")); + +/// Listener limits. +#[derive(Clone)] +pub struct Limits { + pub max_body: usize, + pub max_connections: usize, + pub header_timeout: Duration, +} + +impl Default for Limits { + fn default() -> Limits { + Limits { + max_body: 1 << 20, + max_connections: 512, + header_timeout: Duration::from_secs(10), + } + } +} + +/// Refuse to bind a non-loopback address in plaintext unless the operator said +/// so: tokens and passwords cross this socket. +pub fn bind_gate( + addr: &SocketAddr, + behind_proxy: bool, + allow_plaintext: bool, +) -> Result<(), String> { + if addr.ip().is_loopback() || behind_proxy || allow_plaintext { + return Ok(()); + } + Err(format!( + "refusing to listen on {addr}: it is not loopback and this server speaks plaintext HTTP; \ + put it behind a TLS proxy and pass --behind-proxy, or pass --allow-plaintext" + )) +} + +async fn handle( + app: Arc, + limits: Limits, + peer: IpAddr, + req: Request, +) -> Result>, std::convert::Infallible> { + let (parts, body) = req.into_parts(); + let headers = parts + .headers + .iter() + .filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string()))) + .collect::>(); + let body = match Limited::new(body, limits.max_body).collect().await { + Ok(b) => b.to_bytes().to_vec(), + Err(_) => { + let r = crate::http::error(&app.base, 413, "request body too large"); + return Ok(to_response(r)); + } + }; + let source = app.proxies.resolve( + peer, + headers + .get(app.proxies.header.as_deref().unwrap_or("")) + .map(String::as_str), + ); + let r = Req { + method: parts.method.as_str().to_string(), + path: parts.uri.path().to_string(), + query: parts.uri.query().unwrap_or("").to_string(), + headers, + body, + source, + }; + let resp = tokio::task::spawn_blocking(move || dispatch(&app, &r)) + .await + .unwrap_or_else(|_| { + Resp::json( + 500, + &serde_json::json!({"message": "internal server error"}), + ) + }); + Ok(to_response(resp)) +} + +fn to_response(r: Resp) -> Response> { + let mut b = Response::builder() + .status(r.status) + .header("server", SERVER); + for (k, v) in r.headers { + b = b.header(k, v); + } + b.body(Full::new(Bytes::from(r.body))) + .expect("static header names are valid") +} + +/// Serve until `shutdown` resolves, then wait up to `drain` for in-flight +/// connections. A connection beyond `max_connections` is closed at accept. +pub async fn serve( + listener: TcpListener, + app: Arc, + limits: Limits, + shutdown: impl std::future::Future, + drain: Duration, +) { + let slots = Arc::new(Semaphore::new(limits.max_connections)); + let mut conns = JoinSet::new(); + tokio::pin!(shutdown); + loop { + tokio::select! { + () = &mut shutdown => break, + Some(_) = conns.join_next(), if !conns.is_empty() => {} + accepted = listener.accept() => { + let Ok((stream, peer)) = accepted else { + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + }; + let Ok(permit) = slots.clone().try_acquire_owned() else { continue }; + let (app, limits) = (app.clone(), limits.clone()); + let header_timeout = limits.header_timeout; + conns.spawn(async move { + let svc = service_fn(move |req| handle(app.clone(), limits.clone(), peer.ip(), req)); + let mut http = hyper::server::conn::http1::Builder::new(); + http.timer(TokioTimer::new()).header_read_timeout(header_timeout); + let _ = http.serve_connection(TokioIo::new(stream), svc).await; + drop(permit); + }); + } + } + } + let _ = tokio::time::timeout(drain, async { while conns.join_next().await.is_some() {} }).await; +} + +/// Build the shared state from CLI inputs. +pub fn make_app( + data_dir: std::path::PathBuf, + public_url: &str, + proxies: Proxies, + argon_slots: usize, +) -> Arc { + Arc::new(App::new(data_dir, public_url, proxies, argon_slots)) +} diff --git a/crates/forge/src/store.rs b/crates/forge/src/store.rs new file mode 100644 index 00000000..be5ce928 --- /dev/null +++ b/crates/forge/src/store.rs @@ -0,0 +1,367 @@ +//! Users, organizations and tokens: the queries every route and the admin CLI +//! share. No caching: every call reads the database, so an `admin` change is +//! visible to a running `serve` at once. + +use crate::scope; +use rusqlite::{params, Connection, OptionalExtension, Row}; +use scl_crypto::{generate_token, hash_token}; + +/// A user or an organization. +#[derive(Clone, Debug)] +pub struct User { + pub id: i64, + pub login: String, + pub is_org: bool, + pub login_name: String, + pub full_name: String, + pub email: String, + pub description: String, + pub website: String, + pub location: String, + pub password_hash: String, + pub must_change_password: bool, + pub is_admin: bool, + pub visibility: String, + pub created: i64, +} + +const USER_COLS: &str = "id, login, kind, login_name, full_name, email, description, website, \ + location, password_hash, must_change_password, is_admin, visibility, created_unix"; + +fn user_row(r: &Row) -> rusqlite::Result { + Ok(User { + id: r.get(0)?, + login: r.get(1)?, + is_org: r.get::<_, String>(2)? == "org", + login_name: r.get(3)?, + full_name: r.get(4)?, + email: r.get(5)?, + description: r.get(6)?, + website: r.get(7)?, + location: r.get(8)?, + password_hash: r.get(9)?, + must_change_password: r.get(10)?, + is_admin: r.get(11)?, + visibility: r.get(12)?, + created: r.get(13)?, + }) +} + +/// Fields for a new user or organization. +#[derive(Default)] +pub struct NewAccount { + pub login: String, + pub is_org: bool, + pub full_name: String, + pub email: String, + pub description: String, + pub website: String, + pub location: String, + pub password_hash: String, + pub must_change_password: bool, + pub is_admin: bool, + pub visibility: String, +} + +/// Why a store operation failed. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("exists")] + Exists, + #[error("not found")] + NotFound, + #[error("{0}")] + Db(#[from] rusqlite::Error), +} + +fn is_unique(e: &rusqlite::Error) -> bool { + matches!(e, rusqlite::Error::SqliteFailure(f, _) + if f.code == rusqlite::ErrorCode::ConstraintViolation) +} + +/// Insert a user or organization. `Exists` when the lower-cased login is taken. +pub fn create_account(conn: &Connection, a: &NewAccount, now: i64) -> Result { + let r = conn.execute( + "INSERT INTO users (login, lower_login, kind, full_name, email, description, website, \ + location, password_hash, must_change_password, is_admin, visibility, created_unix) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + a.login, + a.login.to_lowercase(), + if a.is_org { "org" } else { "user" }, + a.full_name, + a.email, + a.description, + a.website, + a.location, + a.password_hash, + a.must_change_password, + a.is_admin, + a.visibility, + now + ], + ); + match r { + Ok(_) => find_by_id(conn, conn.last_insert_rowid())?.ok_or(StoreError::NotFound), + Err(e) if is_unique(&e) => Err(StoreError::Exists), + Err(e) => Err(e.into()), + } +} + +/// Look up by login, case-insensitively. +pub fn find_by_login(conn: &Connection, login: &str) -> rusqlite::Result> { + conn.query_row( + &format!("SELECT {USER_COLS} FROM users WHERE lower_login = ?1"), + [login.to_lowercase()], + user_row, + ) + .optional() +} + +/// Look up by id. +pub fn find_by_id(conn: &Connection, id: i64) -> rusqlite::Result> { + conn.query_row( + &format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), + [id], + user_row, + ) + .optional() +} + +/// Page of users (not organizations) and the total, ordered by id. +pub fn list_users(conn: &Connection, page: i64, limit: i64) -> rusqlite::Result<(Vec, i64)> { + let total = conn.query_row("SELECT COUNT(*) FROM users WHERE kind = 'user'", [], |r| { + r.get(0) + })?; + let mut st = conn.prepare(&format!( + "SELECT {USER_COLS} FROM users WHERE kind = 'user' ORDER BY id LIMIT ?1 OFFSET ?2" + ))?; + let rows = st.query_map(params![limit, (page - 1) * limit], user_row)?; + Ok((rows.collect::>()?, total)) +} + +/// Partial update of a user; `None` leaves a field alone. +#[derive(Default)] +pub struct UserPatch { + pub login_name: Option, + pub full_name: Option, + pub email: Option, + pub password_hash: Option, + pub must_change_password: Option, + pub is_admin: Option, + pub visibility: Option, + pub description: Option, + pub website: Option, + pub location: Option, +} + +/// Apply `p` to user `id`. +pub fn update_user(conn: &Connection, id: i64, p: &UserPatch) -> Result { + let cur = find_by_id(conn, id)?.ok_or(StoreError::NotFound)?; + conn.execute( + "UPDATE users SET login_name=?1, full_name=?2, email=?3, password_hash=?4, \ + must_change_password=?5, is_admin=?6, visibility=?7, description=?8, website=?9, \ + location=?10 WHERE id=?11", + params![ + p.login_name.clone().unwrap_or(cur.login_name), + p.full_name.clone().unwrap_or(cur.full_name), + p.email.clone().unwrap_or(cur.email), + p.password_hash.clone().unwrap_or(cur.password_hash), + p.must_change_password.unwrap_or(cur.must_change_password), + p.is_admin.unwrap_or(cur.is_admin), + p.visibility.clone().unwrap_or(cur.visibility), + p.description.clone().unwrap_or(cur.description), + p.website.clone().unwrap_or(cur.website), + p.location.clone().unwrap_or(cur.location), + id + ], + )?; + find_by_id(conn, id)?.ok_or(StoreError::NotFound) +} + +/// Create an organization and make `owner` its (only kind of) member. +/// shortcut: F1 has owners only, no teams or member roles; extend `org_members` +/// with a role column if teams ever arrive. +pub fn create_org( + conn: &mut Connection, + a: &NewAccount, + owner: i64, + now: i64, +) -> Result { + let tx = conn.transaction()?; + let org = create_account(&tx, a, now)?; + tx.execute( + "INSERT INTO org_members (org_id, user_id) VALUES (?1, ?2)", + [org.id, owner], + )?; + tx.commit()?; + Ok(org) +} + +/// Is `user` an owner of `org`? +pub fn is_org_owner(conn: &Connection, org: i64, user: i64) -> rusqlite::Result { + conn.query_row( + "SELECT 1 FROM org_members WHERE org_id = ?1 AND user_id = ?2", + [org, user], + |_| Ok(()), + ) + .optional() + .map(|o| o.is_some()) +} + +/// A stored access token (never the secret itself). +#[derive(Clone, Debug)] +pub struct Token { + pub id: i64, + pub name: String, + pub last_eight: String, + pub scopes: Vec, + pub expires: Option, +} + +fn token_row(r: &Row) -> rusqlite::Result { + let scopes: String = r.get(3)?; + Ok(Token { + id: r.get(0)?, + name: r.get(1)?, + last_eight: r.get(2)?, + scopes: scopes + .split(',') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(), + expires: r.get(4)?, + }) +} + +/// Mint a token for `user`. Returns the stored row and the one-time secret. +/// `Exists` when the user already has a token of that name. +pub fn create_token( + conn: &Connection, + user: i64, + name: &str, + scopes: &[String], + now: i64, +) -> Result<(Token, String), StoreError> { + let secret = generate_token(); + let r = conn.execute( + "INSERT INTO tokens (user_id, name, hash, last_eight, scopes, created_unix) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + user, + name, + hash_token(&secret), + &secret[secret.len() - 8..], + scopes.join(","), + now + ], + ); + match r { + Ok(_) => { + let t = conn.query_row( + "SELECT id, name, last_eight, scopes, expires_unix FROM tokens WHERE id = ?1", + [conn.last_insert_rowid()], + token_row, + )?; + Ok((t, secret)) + } + Err(e) if is_unique(&e) => Err(StoreError::Exists), + Err(e) => Err(e.into()), + } +} + +/// Page of `user`'s tokens and the total, ordered by id. +pub fn list_tokens( + conn: &Connection, + user: i64, + page: i64, + limit: i64, +) -> rusqlite::Result<(Vec, i64)> { + let total = conn.query_row( + "SELECT COUNT(*) FROM tokens WHERE user_id = ?1", + [user], + |r| r.get(0), + )?; + let mut st = conn.prepare( + "SELECT id, name, last_eight, scopes, expires_unix FROM tokens WHERE user_id = ?1 \ + ORDER BY id LIMIT ?2 OFFSET ?3", + )?; + let rows = st.query_map(params![user, limit, (page - 1) * limit], token_row)?; + Ok((rows.collect::>()?, total)) +} + +/// Delete `user`'s token by numeric id or by name. Returns whether one existed. +pub fn delete_token(conn: &Connection, user: i64, id_or_name: &str) -> rusqlite::Result { + let n = match id_or_name.parse::() { + Ok(id) => conn.execute( + "DELETE FROM tokens WHERE user_id = ?1 AND id = ?2", + params![user, id], + )?, + Err(_) => 0, + }; + if n > 0 { + return Ok(true); + } + Ok(conn.execute( + "DELETE FROM tokens WHERE user_id = ?1 AND name = ?2", + params![user, id_or_name], + )? > 0) +} + +/// Resolve a presented token secret to its owner, token row and expiry state. +/// An expired token resolves to `None`, same as an unknown one. +pub fn resolve_token( + conn: &Connection, + secret: &str, + now: i64, +) -> rusqlite::Result> { + let hash = hash_token(secret); + let row: Option<(i64, Token)> = conn + .query_row( + "SELECT user_id, id, name, last_eight, scopes, expires_unix FROM tokens WHERE hash = ?1", + [&hash], + |r| { + let uid: i64 = r.get(0)?; + let scopes: String = r.get(4)?; + Ok(( + uid, + Token { + id: r.get(1)?, + name: r.get(2)?, + last_eight: r.get(3)?, + scopes: scopes.split(',').filter(|s| !s.is_empty()).map(String::from).collect(), + expires: r.get(5)?, + }, + )) + }, + ) + .optional()?; + let Some((uid, tok)) = row else { + return Ok(None); + }; + if tok.expires.is_some_and(|e| e <= now) { + return Ok(None); + } + Ok(find_by_id(conn, uid)? + .filter(|u| !u.is_org) + .map(|u| (u, tok))) +} + +/// Normalize-then-store convenience used by the CLI and the API. +pub fn mint( + conn: &Connection, + user: i64, + name: &str, + requested: &[String], + now: i64, +) -> Result<(Token, String), MintError> { + let scopes = scope::normalize(requested).map_err(MintError::BadScope)?; + create_token(conn, user, name, &scopes, now).map_err(MintError::Store) +} + +/// Why `mint` failed. +#[derive(Debug)] +pub enum MintError { + BadScope(String), + Store(StoreError), +} diff --git a/crates/forge/src/timefmt.rs b/crates/forge/src/timefmt.rs new file mode 100644 index 00000000..cdc4c421 --- /dev/null +++ b/crates/forge/src/timefmt.rs @@ -0,0 +1,45 @@ +//! Unix-seconds helpers: the current time and RFC 3339 (UTC) rendering. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Seconds since the Unix epoch. +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Render unix seconds as `YYYY-MM-DDTHH:MM:SSZ`. +pub fn rfc3339(secs: i64) -> String { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + // Howard Hinnant's civil-from-days. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + rem % 3600 / 60, + rem % 60 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_known_instants() { + assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(rfc3339(1_700_000_000), "2023-11-14T22:13:20Z"); + } +} diff --git a/crates/forge/tests/common/mod.rs b/crates/forge/tests/common/mod.rs new file mode 100644 index 00000000..9f9ea949 --- /dev/null +++ b/crates/forge/tests/common/mod.rs @@ -0,0 +1,222 @@ +//! Test harness: an in-process `sc-forge` on a loopback port, driven over raw +//! HTTP/1.1 on `std::net::TcpStream` (no HTTP client dependency, and the same +//! wire path a real client takes). +#![allow(dead_code)] + +use scl_forge::limits::Proxies; +use scl_forge::server::{make_app, serve, Limits}; +use scl_forge::{admin, db}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +static N: AtomicUsize = AtomicUsize::new(0); + +pub struct Srv { + pub addr: SocketAddr, + pub dir: PathBuf, + pub base: String, + /// All-scope token of the site admin `siteadmin`. + pub admin: String, + stop: Option>, + thread: Option>, +} + +pub struct Resp { + pub status: u16, + pub headers: HashMap, + pub raw: Vec, +} + +impl Resp { + pub fn json(&self) -> Value { + serde_json::from_slice(&self.raw) + .unwrap_or_else(|e| panic!("not JSON ({e}): {:?}", String::from_utf8_lossy(&self.raw))) + } + pub fn msg(&self) -> String { + self.json()["message"].as_str().unwrap_or("").to_string() + } +} + +pub fn b64(s: &str) -> String { + const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in s.as_bytes().chunks(3) { + let n = (u32::from(c[0]) << 16) + | (u32::from(*c.get(1).unwrap_or(&0)) << 8) + | u32::from(*c.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= c.len() { + out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +pub fn basic(user: &str, pass: &str) -> String { + format!("Basic {}", b64(&format!("{user}:{pass}"))) +} + +pub fn token(t: &str) -> String { + format!("token {t}") +} + +impl Srv { + pub fn start() -> Srv { + Srv::start_with(Limits::default()) + } + + pub fn start_with(limits: Limits) -> Srv { + let dir = std::env::temp_dir().join(format!( + "scl-forge-e2e-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::SeqCst) + )); + let _ = std::fs::remove_dir_all(&dir); + // Bootstrap exactly as Engine does: admin CLI on an empty data dir. + admin::user_create( + &dir, + "siteadmin", + "admin-pw-1", + "admin@example.invalid", + true, + false, + ) + .unwrap(); + let admin_token = + admin::generate_access_token(&dir, "siteadmin", "boot", &["all".to_string()]).unwrap(); + db::open_for_serve(&dir).unwrap(); + let std_l = TcpListener::bind("127.0.0.1:0").unwrap(); + std_l.set_nonblocking(true).unwrap(); + let addr = std_l.local_addr().unwrap(); + let base = format!("http://{addr}"); + let app = make_app(dir.clone(), &base, Proxies::default(), 4); + let (tx, rx) = tokio::sync::oneshot::channel(); + let thread = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let l = tokio::net::TcpListener::from_std(std_l).unwrap(); + serve( + l, + app, + limits, + async { + let _ = rx.await; + }, + Duration::from_secs(5), + ) + .await; + }); + }); + Srv { + addr, + dir, + base, + admin: admin_token, + stop: Some(tx), + thread: Some(thread), + } + } + + pub fn send(&self, method: &str, path: &str, auth: Option<&str>, body: Option<&Value>) -> Resp { + let b = body.map(|v| v.to_string()).unwrap_or_default(); + let mut req = format!( + "{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n", + self.addr + ); + if let Some(a) = auth { + req += &format!("Authorization: {a}\r\n"); + } + if body.is_some() { + req += &format!( + "Content-Type: application/json\r\nContent-Length: {}\r\n", + b.len() + ); + } + req += "\r\n"; + self.raw(req.as_bytes(), b.as_bytes()) + } + + pub fn raw(&self, head: &[u8], body: &[u8]) -> Resp { + let mut s = TcpStream::connect(self.addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + s.write_all(head).unwrap(); + s.write_all(body).unwrap(); + let mut buf = Vec::new(); + let _ = s.read_to_end(&mut buf); + let split = buf + .windows(4) + .position(|w| w == b"\r\n\r\n") + .expect("response head"); + let head = String::from_utf8_lossy(&buf[..split]).to_string(); + let mut lines = head.split("\r\n"); + let status = lines + .next() + .unwrap() + .split(' ') + .nth(1) + .unwrap() + .parse() + .unwrap(); + let headers = lines + .filter_map(|l| l.split_once(": ")) + .map(|(k, v)| (k.to_lowercase(), v.to_string())) + .collect(); + Resp { + status, + headers, + raw: buf[split + 4..].to_vec(), + } + } + + pub fn get(&self, path: &str, auth: &str) -> Resp { + self.send("GET", path, Some(auth), None) + } + pub fn post(&self, path: &str, auth: &str, body: Value) -> Resp { + self.send("POST", path, Some(auth), Some(&body)) + } + + /// Create a user through the admin API, returning (login, password). + pub fn mk_user(&self, login: &str) -> String { + let pw = format!("pw-{login}-0123456789"); + let r = self.post("/api/v1/admin/users", &token(&self.admin), serde_json::json!({ + "username": login, "email": format!("{login}@example.invalid"), "password": pw, "must_change_password": false + })); + assert_eq!(r.status, 201, "{}", r.msg()); + pw + } + + /// Mint a token through the password route (what Portal does). + pub fn mk_token(&self, login: &str, pw: &str, name: &str, scopes: &[&str]) -> String { + let r = self.post( + &format!("/api/v1/users/{login}/tokens"), + &basic(login, pw), + serde_json::json!({"name": name, "scopes": scopes}), + ); + assert_eq!(r.status, 201, "{}", r.msg()); + r.json()["sha1"].as_str().unwrap().to_string() + } +} + +impl Drop for Srv { + fn drop(&mut self) { + if let Some(t) = self.stop.take() { + let _ = t.send(()); + } + if let Some(h) = self.thread.take() { + let _ = h.join(); + } + let _ = std::fs::remove_dir_all(&self.dir); + } +} diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs new file mode 100644 index 00000000..39586fad --- /dev/null +++ b/crates/forge/tests/e2e_a.rs @@ -0,0 +1,727 @@ +//! Scenario A/B/R/X of the recorded contract, F1 slice: meta routes, users, +//! organizations, tokens and the credential rules. Texts and statuses are the +//! ones recorded from Gitea 1.22.6 unless `departures.json` says otherwise. +#![cfg(unix)] + +mod common; +use common::*; +use serde_json::json; + +const USER_KEYS: &[&str] = &[ + "id", + "login", + "login_name", + "source_id", + "full_name", + "email", + "avatar_url", + "html_url", + "language", + "is_admin", + "last_login", + "created", + "restricted", + "active", + "prohibit_login", + "location", + "website", + "description", + "visibility", + "followers_count", + "following_count", + "starred_repos_count", + "username", +]; + +fn has_keys(v: &serde_json::Value, keys: &[&str]) { + for k in keys { + assert!(v.get(*k).is_some(), "missing key {k} in {v}"); + } +} + +#[test] +fn version_healthz_and_anonymous_access() { + let s = Srv::start(); + let r = s.get("/api/v1/version", &token(&s.admin)); + assert_eq!((r.status, r.json()), (200, json!({"version": "1.22.6"}))); + assert_eq!(r.headers["content-type"], "application/json;charset=utf-8"); + assert!( + r.headers["server"].starts_with("sc-forge/"), + "identity lives in the Server header" + ); + + let anon = s.send("GET", "/api/v1/version", None, None); + assert_eq!(anon.status, 403); + assert_eq!( + anon.json(), + json!({"message": "Only signed in user is allowed to call APIs."}), + "no url key" + ); + + let h = s.send("GET", "/api/healthz", None, None); + assert_eq!(h.status, 200); + assert_eq!(h.headers["content-type"], "application/json"); + assert_eq!(h.json()["status"], "pass"); +} + +#[test] +fn current_user_and_bad_credentials() { + let s = Srv::start(); + let r = s.get("/api/v1/user", &token(&s.admin)); + assert_eq!(r.status, 200); + has_keys(&r.json(), USER_KEYS); + assert_eq!(r.json()["login"], "siteadmin"); + assert_eq!(r.json()["is_admin"], true); + assert_eq!(r.json()["html_url"], format!("{}/siteadmin", s.base)); + + let bad = s.get("/api/v1/user", &token("0".repeat(40).as_str())); + assert_eq!(bad.status, 401); + assert_eq!(bad.msg(), "user does not exist [uid: 0, name: ]"); + assert_eq!(bad.json()["url"], format!("{}/api/swagger", s.base)); + + let anon = s.send("GET", "/api/v1/user", None, None); + assert_eq!( + (anon.status, anon.msg().as_str()), + (403, "Only signed in user is allowed to call APIs.") + ); +} + +#[test] +fn users_orgs_and_visibility() { + let s = Srv::start(); + let adm = token(&s.admin); + let body = json!({"username": "alice", "email": "alice@example.invalid", "password": "pw-alice-1234", "must_change_password": false}); + let r = s.post("/api/v1/admin/users", &adm, body.clone()); + assert_eq!(r.status, 201); + has_keys(&r.json(), USER_KEYS); + assert_eq!(r.json()["login_name"], ""); + + let dup = s.post("/api/v1/admin/users", &adm, body); + assert_eq!( + (dup.status, dup.msg().as_str()), + (422, "user already exists [name: alice]") + ); + let case = s.post( + "/api/v1/admin/users", + &adm, + json!({"username": "Alice", "email": "a@example.invalid", "password": "x"}), + ); + assert_eq!( + (case.status, case.msg().as_str()), + (422, "user already exists [name: Alice]"), + "names are case-insensitive" + ); + + let patch = s.send("PATCH", "/api/v1/admin/users/alice", Some(&adm), Some(&json!({"login_name": "alice", "source_id": 0, "visibility": "private", "must_change_password": false}))); + assert_eq!(patch.status, 200); + assert_eq!( + ( + patch.json()["login_name"].as_str(), + patch.json()["visibility"].as_str() + ), + (Some("alice"), Some("private")) + ); + assert_eq!(s.get("/api/v1/users/alice", &adm).status, 200); + let miss = s.get("/api/v1/users/nobody-here", &adm); + assert_eq!( + (miss.status, miss.msg().as_str()), + (404, "user redirect does not exist [name: nobody-here]") + ); + + // A private user is invisible to another user. + let bob_pw = s.mk_user("bob"); + let bob = s.mk_token("bob", &bob_pw, "t", &["read:user", "read:organization"]); + assert_eq!(s.get("/api/v1/users/alice", &token(&bob)).status, 404); + + // Organizations. + let org = json!({"username": "acme", "full_name": "Acme", "visibility": "private"}); + let o = s.post("/api/v1/orgs", &adm, org.clone()); + assert_eq!(o.status, 201); + assert_eq!(o.json()["name"], "acme"); + assert_eq!(o.json()["repo_admin_change_team_access"], false); + let od = s.post("/api/v1/orgs", &adm, org); + assert_eq!( + (od.status, od.msg().as_str()), + (422, "user already exists [name: acme]") + ); + let nf = s.get("/api/v1/orgs/nobody-org", &adm); + assert_eq!(nf.status, 404); + assert_eq!(nf.json()["message"], "GetOrgByName"); + assert_eq!( + nf.json()["errors"], + json!(["user redirect does not exist [name: nobody-org]"]) + ); + // Private org: owner and admin see it, others get the recorded 404. + let hidden = s.get("/api/v1/orgs/acme", &token(&bob)); + assert_eq!( + (hidden.status, hidden.msg().as_str()), + (404, "HasOrgOrUserVisible") + ); + assert_eq!(hidden.json()["errors"], serde_json::Value::Null); + let perms = s.get("/api/v1/users/siteadmin/orgs/acme/permissions", &adm); + assert_eq!( + perms.json(), + json!({"is_owner": true, "is_admin": true, "can_write": true, "can_read": true, "can_create_repository": true}) + ); + assert_eq!( + s.get("/api/v1/users/alice/orgs/acme/permissions", &adm) + .msg(), + "HasOrgOrUserVisible" + ); +} + +#[test] +fn name_rules_match_the_recordings() { + let s = Srv::start(); + let adm = token(&s.admin); + for (name, status, msg) in [ + ("api", 422, "name is reserved [name: api]"), + ("user", 422, "name is reserved [name: user]"), + (".dot", 422, "[Username]: invalid username"), + ("-dash", 422, "[Username]: invalid username"), + ("bad name", 422, "[Username]: invalid username"), + ("trail.git", 201, ""), + ] { + let r = s.post( + "/api/v1/admin/users", + &adm, + json!({"username": name, "email": "x@example.invalid", "password": "pw"}), + ); + assert_eq!(r.status, status, "{name}"); + if status != 201 { + assert_eq!(r.msg(), msg, "{name}"); + } + } + let org = s.post("/api/v1/orgs", &adm, json!({"username": "x".repeat(41)})); + assert_eq!( + (org.status, org.msg().as_str()), + (422, "[UserName]: MaxSize") + ); + let org = s.post("/api/v1/orgs", &adm, json!({"username": "-dash"})); + assert_eq!(org.msg(), "[UserName]: invalid username"); +} + +#[test] +fn token_lifecycle_with_the_users_password() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let basic_alice = basic("alice", &pw); + let a = s.post( + "/api/v1/users/alice/tokens", + &basic_alice, + json!({"name": "ci", "scopes": ["read:repository"]}), + ); + assert_eq!(a.status, 201); + let a = a.json(); + assert_eq!(a["sha1"].as_str().unwrap().len(), 40); + assert_eq!(a["token_last_eight"], a["sha1"].as_str().unwrap()[32..]); + assert_eq!(a["scopes"], json!(["read:repository"])); + // Scopes are normalized: write shadows read. + let b = s.post( + "/api/v1/users/alice/tokens", + &basic_alice, + json!({"name": "tmp", "scopes": ["read:repository", "write:repository"]}), + ); + assert_eq!(b.json()["scopes"], json!(["write:repository"])); + let c = s.post( + "/api/v1/users/alice/tokens", + &basic_alice, + json!({"name": "ord", "scopes": ["read:user", "read:organization", "write:repository"]}), + ); + assert_eq!( + c.json()["scopes"], + json!(["read:organization", "write:repository", "read:user"]) + ); + + let l = s.get("/api/v1/users/alice/tokens", &basic_alice); + assert_eq!((l.status, l.headers["x-total-count"].as_str()), (200, "3")); + assert_eq!(l.json()[0]["name"], "ci"); + assert_eq!( + l.json()[0]["sha1"], + "", + "the secret is shown once, at creation" + ); + + let dup = s.post( + "/api/v1/users/alice/tokens", + &basic_alice, + json!({"name": "ci", "scopes": ["all"]}), + ); + assert_eq!( + (dup.status, dup.msg().as_str()), + (400, "access token name has been used already") + ); + let del = s.send( + "DELETE", + &format!("/api/v1/users/alice/tokens/{}", b.json()["id"]), + Some(&basic_alice), + None, + ); + assert_eq!((del.status, del.raw.len()), (204, 0)); + assert_eq!( + s.get("/api/v1/users/alice/tokens", &basic_alice).headers["x-total-count"], + "2" + ); + // A minted token works at once, and a deleted one stops working. + assert_eq!( + s.get("/api/v1/user", &token(a["sha1"].as_str().unwrap())) + .status, + 403, + "read:repository lacks read:user" + ); + let user_tok = s.mk_token("alice", &pw, "u", &["read:user"]); + assert_eq!(s.get("/api/v1/user", &token(&user_tok)).status, 200); + s.send( + "DELETE", + "/api/v1/users/alice/tokens/u", + Some(&basic_alice), + None, + ); + assert_eq!(s.get("/api/v1/user", &token(&user_tok)).status, 401); +} + +#[test] +fn token_request_validation_texts() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let a = basic("alice", &pw); + let t = |b| s.post("/api/v1/users/alice/tokens", &a, b); + let r = t(json!({"name": "x", "scopes": ["nonsense"]})); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 400, + "invalid access token scope provided: invalid access token scope: nonsense" + ) + ); + let r = t(json!({"name": "x"})); + assert_eq!( + (r.status, r.msg().as_str()), + (400, "access token must have a scope") + ); + let r = t(json!({"name": "", "scopes": ["all"]})); + assert_eq!((r.status, r.msg().as_str()), (422, "[Name]: Required")); +} + +#[test] +fn scope_enforcement_names_the_missing_scope() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let ro = s.mk_token("alice", &pw, "ro", &["read:repository"]); + for (method, path, want) in [ + ("GET", "/api/v1/user", "[read:user]"), + ("POST", "/api/v1/orgs/acme/repos", "[write:organization]"), + ("GET", "/api/v1/admin/users", "[read:admin]"), + ("POST", "/api/v1/admin/users", "[write:admin]"), + ] { + let r = s.send(method, path, Some(&token(&ro)), Some(&json!({}))); + assert_eq!(r.status, 403, "{method} {path}"); + assert_eq!( + r.msg(), + format!("token does not have at least one of required scope(s): {want}") + ); + } + // An all-scope token of a non-admin still cannot use admin routes. + let all = s.mk_token("alice", &pw, "all", &["all"]); + assert_eq!(s.get("/api/v1/admin/users", &token(&all)).status, 403); + // A write scope satisfies a read requirement. + let wr = s.mk_token("alice", &pw, "wr", &["write:user"]); + assert_eq!(s.get("/api/v1/user", &token(&wr)).status, 200); +} + +#[test] +fn every_token_credential_form_authenticates_on_ordinary_routes() { + let s = Srv::start(); + let t = &s.admin; + for (label, auth) in [ + ("token header", token(t)), + ("Bearer", format!("Bearer {t}")), + ("basic, token as password", basic("siteadmin", t)), + ("basic, any username", basic("someone", t)), + ("basic, oauth2", basic("oauth2", t)), + ("basic, token as username", basic(t, "")), + ] { + let r = s.get("/api/v1/user", &auth); + assert_eq!( + (r.status, r.json()["login"].as_str()), + (200, Some("siteadmin")), + "{label}" + ); + } + let bad = "f".repeat(40); + let r = s.get("/api/v1/user", &format!("Bearer {bad}")); + assert_eq!( + (r.status, r.msg().as_str()), + (401, "user does not exist [uid: 0, name: ]") + ); + let r = s.get("/api/v1/user", &basic(&bad, "")); + assert_eq!( + (r.status, r.msg()), + (401, format!("user does not exist [uid: 0, name: {bad}]")) + ); +} + +#[test] +fn a_users_password_is_never_a_credential_on_ordinary_routes() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let r = s.get("/api/v1/user", &basic("alice", &pw)); + assert_eq!(r.status, 401); + assert!(r.msg().to_lowercase().contains("auth"), "{}", r.msg()); + // ...including the site admin's own password. + let r = s.get("/api/v1/user", &basic("siteadmin", "admin-pw-1")); + assert_eq!(r.status, 401); +} + +#[test] +fn a_token_can_never_mint_or_list_tokens() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let all = s.mk_token("alice", &pw, "all", &["all"]); + let body = json!({"name": "via-all-token", "scopes": ["all"]}); + for (label, auth) in [ + ("token header", token(&all)), + ("Bearer", format!("Bearer {all}")), + ("token as Basic password", basic("alice", &all)), + ("token as Basic username", basic(&all, "")), + ] { + let r = s.post("/api/v1/users/alice/tokens", &auth, body.clone()); + assert!(r.status == 401 || r.status == 403, "{label}: {}", r.status); + assert!( + r.msg().to_lowercase().contains("password"), + "{label}: {}", + r.msg() + ); + assert_eq!( + s.get("/api/v1/users/alice/tokens", &auth).status, + r.status, + "{label} list" + ); + } + // A read-only token as the password cannot widen itself either. + let ro = s.mk_token("alice", &pw, "ro", &["read:repository"]); + let r = s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &ro), + json!({"name": "minted", "scopes": ["all"]}), + ); + assert!(r.status == 401 || r.status == 403); + assert!(s + .get("/api/v1/users/alice/tokens", &basic("alice", &pw)) + .json() + .as_array() + .unwrap() + .iter() + .all(|t| t["name"] != "minted")); + // Anonymous is refused with a password-class message, not served. + let r = s.send("POST", "/api/v1/users/alice/tokens", None, Some(&body)); + assert_eq!(r.status, 401); + assert!(r.msg().contains("password")); +} + +#[test] +fn alices_password_cannot_mint_bobs_tokens_but_a_site_admins_can() { + let s = Srv::start(); + let alice_pw = s.mk_user("alice"); + s.mk_user("bot"); + let body = json!({"name": "stolen", "scopes": ["all"]}); + let r = s.post( + "/api/v1/users/bot/tokens", + &basic("alice", &alice_pw), + body.clone(), + ); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 403, + "doer should be the site admin or be same as the contextUser" + ) + ); + let r = s.get("/api/v1/users/bot/tokens", &basic("alice", &alice_pw)); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 403, + "doer should be the site admin or be same as the contextUser" + ) + ); + // Even when bot does not exist, a non-admin learns nothing. + assert_eq!( + s.post( + "/api/v1/users/ghost/tokens", + &basic("alice", &alice_pw), + body.clone() + ) + .status, + 403 + ); + + // A site admin authenticating with a password may; with a token may not. + s.post("/api/v1/admin/users", &token(&s.admin), json!({"username": "siteowner", "email": "o@example.invalid", "password": "owner-pw-1", "must_change_password": false})); + s.send( + "PATCH", + "/api/v1/admin/users/siteowner", + Some(&token(&s.admin)), + Some(&json!({"admin": true})), + ); + let r = s.post( + "/api/v1/users/bot/tokens", + &basic("siteowner", "owner-pw-1"), + json!({"name": "by-admin", "scopes": ["read:repository"]}), + ); + assert_eq!(r.status, 201); + let r = s.post( + "/api/v1/users/bot/tokens", + &token(&s.admin), + json!({"name": "by-admin-token", "scopes": ["read:repository"]}), + ); + assert_eq!(r.status, 401); + let r = s.post( + "/api/v1/users/ghost/tokens", + &basic("siteowner", "owner-pw-1"), + body, + ); + assert_eq!( + (r.status, r.msg().as_str()), + (404, "user redirect does not exist [name: ghost]") + ); +} + +#[test] +fn wrong_passwords_are_indistinguishable_and_unknown_users_cost_the_same() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let wrong = s.post( + "/api/v1/users/alice/tokens", + &basic("alice", "nope"), + json!({"name": "a", "scopes": ["all"]}), + ); + let unknown = s.post( + "/api/v1/users/nobody/tokens", + &basic("nobody", "nope"), + json!({"name": "a", "scopes": ["all"]}), + ); + assert_eq!((wrong.status, unknown.status), (401, 401)); + assert_eq!(wrong.raw, unknown.raw, "same body: no account enumeration"); + // The right password still works straight after failures (backoff is not a lockout). + assert_eq!( + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &pw), + json!({"name": "ok", "scopes": ["all"]}) + ) + .status, + 201 + ); +} + +#[test] +fn must_change_password_blocks_password_authentication() { + let s = Srv::start(); + s.post( + "/api/v1/admin/users", + &token(&s.admin), + json!({"username": "newbie", "email": "n@example.invalid", "password": "temp-pw-1"}), + ); + let r = s.post( + "/api/v1/users/newbie/tokens", + &basic("newbie", "temp-pw-1"), + json!({"name": "a", "scopes": ["all"]}), + ); + assert_eq!( + r.status, 403, + "the default is must_change_password=true, as in Gitea" + ); +} + +#[test] +fn admin_cli_changes_are_visible_to_the_running_server() { + let s = Srv::start(); + scl_forge::admin::user_create( + &s.dir, + "carol", + "carol-pw-1", + "c@example.invalid", + false, + false, + ) + .unwrap(); + let t = scl_forge::admin::generate_access_token(&s.dir, "carol", "cli", &["read:user".into()]) + .unwrap(); + assert_eq!(s.get("/api/v1/user", &token(&t)).json()["login"], "carol"); + scl_forge::admin::user_change_password(&s.dir, "carol", "carol-pw-2", false).unwrap(); + assert_eq!( + s.post( + "/api/v1/users/carol/tokens", + &basic("carol", "carol-pw-1"), + json!({"name": "o", "scopes": ["all"]}) + ) + .status, + 401 + ); + assert_eq!( + s.post( + "/api/v1/users/carol/tokens", + &basic("carol", "carol-pw-2"), + json!({"name": "n", "scopes": ["all"]}) + ) + .status, + 201 + ); + scl_forge::admin::org_create(&s.dir, "cliorg", "carol").unwrap(); + assert_eq!(s.get("/api/v1/orgs/cliorg", &token(&s.admin)).status, 200); + assert!( + scl_forge::admin::user_create(&s.dir, "carol", "x", "c@example.invalid", false, false) + .is_err() + ); + assert!( + scl_forge::admin::generate_access_token(&s.dir, "carol", "cli", &["all".into()]).is_err(), + "duplicate token name" + ); +} + +#[test] +fn expired_tokens_are_refused_like_unknown_ones() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let t = s.mk_token("alice", &pw, "short", &["read:user"]); + let conn = scl_forge::db::connect(&s.dir).unwrap(); + conn.execute( + "UPDATE tokens SET expires_unix = 1 WHERE name = 'short'", + [], + ) + .unwrap(); + let r = s.get("/api/v1/user", &token(&t)); + assert_eq!( + (r.status, r.msg().as_str()), + (401, "user does not exist [uid: 0, name: ]") + ); +} + +#[test] +fn listener_limits() { + let s = Srv::start_with(scl_forge::server::Limits { + max_body: 64, + ..Default::default() + }); + let big = json!({"name": "x".repeat(200), "scopes": ["all"]}); + let r = s.post("/api/v1/orgs", &token(&s.admin), big); + assert_eq!(r.status, 413); + assert_eq!(s.send("GET", "/nope", None, None).status, 404); + assert_eq!( + s.get("/api/v1/user", "garbage").status, + 403, + "an unparsable header is anonymous" + ); + let m = s.send("PUT", "/api/v1/user", Some(&token(&s.admin)), None); + assert_eq!(m.status, 405); +} + +#[test] +fn bind_gate_refuses_public_plaintext() { + use scl_forge::server::bind_gate; + let lo = "127.0.0.1:3000".parse().unwrap(); + let any = "0.0.0.0:3000".parse().unwrap(); + assert!(bind_gate(&lo, false, false).is_ok()); + assert!(bind_gate(&any, false, false).is_err()); + assert!(bind_gate(&any, true, false).is_ok()); + assert!(bind_gate(&any, false, true).is_ok()); +} + +#[test] +fn unknown_routes_authenticate_first_and_teams_are_not_served() { + let s = Srv::start(); + assert_eq!( + s.send("GET", "/api/v1/repos/x/y", None, None).status, + 403, + "anonymous learns nothing about the surface" + ); + assert_eq!( + s.get("/api/v1/repos/x/y", &token(&"e".repeat(40))).status, + 401 + ); + let r = s.post( + "/api/v1/orgs/acme/teams", + &token(&s.admin), + json!({"name": "devs"}), + ); + assert_eq!(r.status, 404); + assert!(r.msg().contains("not"), "{}", r.msg()); // departures.json: teams are out of scope +} + +#[test] +fn credentials_cli_forms_parse() { + let dir = std::env::temp_dir().join(format!("scl-forge-cli-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let bin = env!("CARGO_BIN_EXE_sc-forge"); + let run = |args: &[&str]| { + let o = std::process::Command::new(bin) + .args(args) + .arg("--data-dir") + .arg(&dir) + .output() + .unwrap(); + ( + o.status.success(), + String::from_utf8_lossy(&o.stdout).to_string(), + String::from_utf8_lossy(&o.stderr).to_string(), + ) + }; + // Engine's bootstrap passes the flag with `=`. + let (ok, _, err) = run(&[ + "admin", + "user", + "create", + "--username", + "eng", + "--password", + "pw-eng-1", + "--email", + "e@example.invalid", + "--admin", + "--must-change-password=false", + ]); + assert!(ok, "{err}"); + let (ok, out, err) = run(&[ + "admin", + "user", + "generate-access-token", + "--username", + "eng", + "--token-name", + "t", + "--scopes", + "all", + "--raw", + ]); + assert!(ok, "{err}"); + assert_eq!(out.trim().len(), 40, "--raw prints only the token"); + let (ok, _, _) = run(&[ + "admin", + "user", + "change-password", + "--username", + "eng", + "--password", + "pw-eng-2", + "--must-change-password", + "false", + ]); + assert!(ok); + assert!( + !run(&[ + "admin", + "user", + "create", + "--username", + "eng", + "--password", + "x", + "--email", + "e@example.invalid" + ]) + .0, + "duplicate refuses" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/forge/tests/route_table.rs b/crates/forge/tests/route_table.rs new file mode 100644 index 00000000..a5330180 --- /dev/null +++ b/crates/forge/tests/route_table.rs @@ -0,0 +1,102 @@ +//! Every route the recorded fixtures exercise inside the F1 surface must resolve +//! to a declared row, and the declared rows carry a policy. A new fixture route +//! that is neither declared nor listed as a later slice fails here. +#![cfg(unix)] + +use scl_forge::routes::{resolve, Class, ROUTES}; +use serde_json::Value; + +/// Paths a later slice owns (repos, git, teams are out of scope entirely). +fn later(path: &str) -> bool { + path.starts_with("/repos/") + || path.starts_with("/user/repos") + || path.starts_with("/teams/") + || path.ends_with("/teams") + || (path.starts_with("/orgs/") && path.ends_with("/repos") && !path.starts_with("/orgs/{")) +} + +fn concretize(path: &str) -> String { + // Fixture placeholders -> plain segments; the router only looks at shape. + let mut out = String::new(); + let mut rest = path; + while let Some(i) = rest.find('<') { + out.push_str(&rest[..i]); + let j = rest[i..].find('>').map(|k| i + k + 1).unwrap_or(rest.len()); + out.push('x'); + rest = &rest[j..]; + } + out.push_str(rest); + out +} + +#[test] +fn fixture_routes_in_the_f1_surface_are_declared() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("conformance/fixtures/1.22.6"); + let mut checked = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.extension().is_none_or(|e| e != "json") || p.file_name().unwrap() == "meta.json" { + continue; + } + let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap(); + for ex in v["exchanges"].as_array().unwrap() { + if ex["kind"] != "http" || ex["method"].is_null() { + continue; + } + let full = concretize(ex["path"].as_str().unwrap()); + let Some(path) = full.strip_prefix("/api/v1") else { + continue; + }; + let path = path.split('?').next().unwrap(); + if later(path) || path == "/healthz" || path.starts_with("/repos") { + continue; + } + let method = ex["method"].as_str().unwrap(); + assert!( + resolve(method, path).is_ok(), + "{method} {path} ({}) has no route row", + p.display() + ); + checked.insert(format!("{method} {path}")); + } + } + assert!( + checked.len() >= 10, + "the fixtures should exercise many F1 routes, saw {checked:?}" + ); +} + +#[test] +fn token_routes_are_exactly_the_password_routes() { + let mut pw: Vec<_> = ROUTES + .iter() + .filter(|r| r.class == Class::Password) + .map(|r| r.pattern) + .collect(); + pw.sort(); + assert_eq!( + pw, + [ + "/users/{u}/tokens", + "/users/{u}/tokens", + "/users/{u}/tokens/{id}" + ] + ); + for r in ROUTES.iter().filter(|r| r.class == Class::Password) { + assert!( + r.need.is_none(), + "password auth carries full scope; a scope rule here would be dead code" + ); + } + for r in ROUTES + .iter() + .filter(|r| r.class == Class::Token && r.pattern != "/version") + { + assert!( + r.need.is_some(), + "{} {} must declare a scope", + r.method, + r.pattern + ); + } +} diff --git a/docs/superpowers/specs/2026-10-22-p38-forge-design.md b/docs/superpowers/specs/2026-10-22-p38-forge-design.md index f614ca3e..c9b3858b 100644 --- a/docs/superpowers/specs/2026-10-22-p38-forge-design.md +++ b/docs/superpowers/specs/2026-10-22-p38-forge-design.md @@ -631,6 +631,37 @@ header. Decisions drawn from it, beyond those stated in the sections above: The pre-receive refusal for a ref outside `refs/heads/*` and `refs/tags/*` reads `refusing to update : only refs/heads/* and refs/tags/* are writable`. +### F1 decisions (as built) + +- **Failed credential on a token route.** `/users/{u}/tokens*` answers 401 + `invalid username or password` for every failed password (wrong password, unknown + login, a token sent as the password, an organization name), after one full Argon2 + verification (a dummy hash for the unknown or malformed case) and a delay that + doubles from 50 ms to 2 s per recent failure of that login. It never reports + whether the password was a valid token. No Basic or a token header answers 401 + `Basic authentication with the user's password is required`. A source with 30 + failures in a minute is refused 429 until the window passes; a stranger's + failures slow only wrong guesses and never block the right password. The + readonly-token-as-password note (X#32) is a departure: 401, not Gitea's 403. +- **Ordinary routes.** A Basic password that is not a token gets 401 `auth required: + this route accepts access tokens only`; a bad `token`/`Bearer` or token-as-username + gets the recorded `user does not exist [uid: 0, name: ...]` (name capped at 80 + characters). Every `/api/v1/*` path, declared or not, authenticates before the + route lookup, so an unauthenticated caller learns nothing about the API surface. +- **`must_change_password`** (the default for `admin/users` POST, as in Gitea) refuses + password authentication on token routes with 403; tokens are unaffected. +- **Site-admin tokens.** A token with `write:admin` can `PATCH /admin/users/{u}` + a password and then mint that user's tokens with it: admin tokens are + impersonation-equivalent by design, and the rule "a token cannot mint tokens" + covers every non-admin token. +- **Organization existence.** A missing organization (404 `GetOrgByName`) and an + invisible one (404 `HasOrgOrUserVisible`) answer differently, as recorded; the + difference reveals that an organization name exists. Kept for client parity. +- **Not yet in F1:** the F1 slice of Appendix A is complete, except `orgs/{o}/repos` + and `user/repos`, whose scope checks are in the table and whose handlers answer 501 + until F2. `GET /api/healthz` and `GET /healthz` both answer. Only owners exist in + `org_members` (no member roles, no teams). + ## Open questions 1. The merge of a PR whose head is already in base (recorded: 405 `try again later`, From d4c0a117a3ebfb8f868fd09c9067a2e51b8e1cfd Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:05:21 +0300 Subject: [PATCH 02/11] fix(forge): review fixes for F1 Sol and Opus reviews of the F1 diff: update_user writes only supplied columns (a stale read-modify-write could restore a revoked admin flag or password), request bodies time out, the per-source password budget is reserved before verification and the failure delay no longer occupies a blocking thread, token lists are newest first, org permissions respect caller visibility, org repo creation requires an owner, /user/repos is declared, data dir and database are owner-only, pending stop text is pinned in the harness. --- crates/forge/conformance/README.md | 6 +- crates/forge/conformance/run.py | 9 +- crates/forge/conformance/slice-pending.json | 4 +- crates/forge/src/api.rs | 27 +- crates/forge/src/auth.rs | 8 +- crates/forge/src/cli.rs | 6 + crates/forge/src/db.rs | 38 +- crates/forge/src/http.rs | 11 + crates/forge/src/limits.rs | 150 ++++--- crates/forge/src/routes.rs | 54 ++- crates/forge/src/server.rs | 29 +- crates/forge/src/store.rs | 63 ++- crates/forge/tests/common/mod.rs | 17 +- crates/forge/tests/e2e_a.rs | 407 +++++++++++++++++- .../specs/2026-10-22-p38-forge-design.md | 17 +- 15 files changed, 714 insertions(+), 132 deletions(-) diff --git a/crates/forge/conformance/README.md b/crates/forge/conformance/README.md index 7cbc4297..ddbd2c35 100644 --- a/crates/forge/conformance/README.md +++ b/crates/forge/conformance/README.md @@ -47,9 +47,9 @@ python3 -I run.py show fixtures/1.26.2 --only G --body # what was recorded Against the in-tree server use `./forge-check.sh` (it bootstraps an empty data directory with the `admin` CLI, starts `sc-forge serve` on a free loopback port, runs `check` with the departures and `slice-pending.json`, and -stops the server; CI runs it). `check --pending FILE` takes a JSON map `{scenario: N}`: the server is expected to -answer the first N exchanges of that scenario, the scenario must then stop at its next setup call (a route from a -later slice), and the rest are reported as pending. A server that answers more than N, or a scenario that +stops the server; CI runs it). `check --pending FILE` takes a JSON map `{scenario: [N, "METHOD /path"]}` (a bare `N` is also accepted): the +server is expected to answer the first N exchanges of that scenario, the scenario must then stop at the named setup +call (a route from a later slice), and the rest are reported as pending. A server that answers more than N, or a scenario that completes, fails the run, so the file shrinks as slices land and is empty when the contract is whole. It is for the in-tree build only; never pass it when checking real Gitea. diff --git a/crates/forge/conformance/run.py b/crates/forge/conformance/run.py index 4abbd202..9ee4f0ba 100644 --- a/crates/forge/conformance/run.py +++ b/crates/forge/conformance/run.py @@ -914,7 +914,8 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): """Run every scenario in order (they read earlier state); `only` filters what is reported/written. `pending` (check only) maps a scenario id to the number of leading exchanges the server under test is expected - to answer; later exchanges need routes from a slice that is not built yet. The run must stop exactly there.""" + to answer, or to [number, text]; later exchanges need routes from a slice that is not built yet. The run must stop + exactly there, and with a text the stop error must contain it (so an F1 step failing at the same index is not hidden).""" summary = [] pending = pending or {} for sid, slug, needs_base, fn in SCENARIOS: @@ -922,7 +923,7 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): fname = f"{sid}-{slug}.json" c.ex, c.cursor, c.diffs = [], 0, [] c.reset_scenario() - c.expected, skip, stale, deferred, ndeferred = [], None, [], None, 0 + c.expected, skip, stale, deferred, ndeferred, stop = [], None, [], None, 0, None c.overrides, c.web_skipped = {}, 0 if c.mode == "check" and selected: fp = os.path.join(fixtures, fname) @@ -959,7 +960,7 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): continue c.overrides[i] = dict(dep, match=matches[k] or got) if sid in pending: - deferred = pending[sid] + deferred, stop = pending[sid] if isinstance(pending[sid], list) else (pending[sid], None) ndeferred = max(0, len(c.expected) - deferred) c.expected = c.expected[:deferred] t0 = time.time() @@ -991,7 +992,7 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): lines += [f" {'expected difference honoured' if lv == 'honoured' else lv}: {t}" for lv, t in ds] # unpaired exchanges and errors: each counts as a failed exchange missing = len(c.expected) - len(c.ex) - if err and deferred is not None and len(c.ex) == deferred: + if err and deferred is not None and len(c.ex) == deferred and (stop is None or stop in err): lines.append(f" (pending: {ndeferred} later exchange(s) need a later slice; the scenario stopped at #{deferred}: {err[:100]})") elif err: if skip and norm_msg(skip) == norm_msg(err): diff --git a/crates/forge/conformance/slice-pending.json b/crates/forge/conformance/slice-pending.json index f54000ae..27778705 100644 --- a/crates/forge/conformance/slice-pending.json +++ b/crates/forge/conformance/slice-pending.json @@ -1,4 +1,4 @@ { - "A": 6, - "B": 25 + "A": [6, "POST /api/v1/user/repos"], + "B": [25, "POST /api/v1/user/repos"] } diff --git a/crates/forge/src/api.rs b/crates/forge/src/api.rs index 9d3d60bc..2547177c 100644 --- a/crates/forge/src/api.rs +++ b/crates/forge/src/api.rs @@ -68,14 +68,19 @@ fn no_such_user(ctx: &Ctx, name: &str) -> Resp { ) } -fn no_such_org(ctx: &Ctx, name: &str) -> Resp { +/// The recorded 404 for an organization that does not exist. +pub fn org_missing(app: &crate::routes::App, name: &str) -> Resp { not_found( - &ctx.app.base, + &app.base, "GetOrgByName", Some(&format!("user redirect does not exist [name: {name}]")), ) } +fn no_such_org(ctx: &Ctx, name: &str) -> Resp { + org_missing(ctx.app, name) +} + /// A JSON object body; an empty body is `{}`. fn body(ctx: &Ctx) -> Result { if ctx.req.body.is_empty() { @@ -93,7 +98,11 @@ fn str_field(v: &Value, key: &str) -> Option { fn page_params(ctx: &Ctx) -> (i64, i64) { let num = |k: &str| ctx.req.param(k).and_then(|v| v.parse::().ok()); - let page = num("page").filter(|p| *p >= 1).unwrap_or(1); + // Page is clamped so offset arithmetic cannot overflow. + let page = num("page") + .filter(|p| *p >= 1) + .unwrap_or(1) + .min(1_000_000_000); let limit = num("limit") .filter(|l| *l >= 1) .unwrap_or(DEFAULT_PAGE) @@ -186,6 +195,18 @@ pub fn org_permissions(ctx: &mut Ctx) -> Resp { Ok(_) => return no_such_org(ctx, &oname), Err(e) => return ctx.app.internal(&e), }; + // The caller must be able to see the organization at all (a private one is + // hidden from outsiders, whoever the subject is), and a private subject is + // hidden like on `GET /users/{u}`. + match org_visible(ctx, &org) { + Ok(true) => {} + Ok(false) => return not_found(&ctx.app.base, "HasOrgOrUserVisible", None), + Err(e) => return ctx.app.internal(&e), + } + let me = &ctx.who.user; + if subject.visibility == "private" && subject.id != me.id && !me.is_admin { + return no_such_user(ctx, &uname); + } let full = match store::is_org_owner(ctx.conn, org.id, subject.id) { Ok(owner) => owner || subject.is_admin, Err(e) => return ctx.app.internal(&e), diff --git a/crates/forge/src/auth.rs b/crates/forge/src/auth.rs index ce73262d..efede736 100644 --- a/crates/forge/src/auth.rs +++ b/crates/forge/src/auth.rs @@ -139,7 +139,7 @@ pub fn by_password(app: &App, conn: &Connection, req: &Req, cred: &Cred) -> Resu ) .with("www-authenticate", "Basic realm=\"sc-forge\"".into())); }; - if app.backoff.source_blocked(req.source) { + if !app.backoff.begin(req.source) { return Err(error( &app.base, 429, @@ -157,7 +157,7 @@ pub fn by_password(app: &App, conn: &Connection, req: &Req, cred: &Cred) -> Resu }; match (ok, found) { (true, Some(user)) => { - app.backoff.record_success(login); + app.backoff.success(login, req.source); if user.must_change_password { return Err(error( &app.base, @@ -168,8 +168,8 @@ pub fn by_password(app: &App, conn: &Connection, req: &Req, cred: &Cred) -> Resu Ok(Authed { user, scopes: None }) } _ => { - std::thread::sleep(app.backoff.record_failure(login, req.source)); - Err(error(&app.base, 401, "invalid username or password")) + let delay = app.backoff.failure(login, req.source); + Err(error(&app.base, 401, "invalid username or password").delayed(delay)) } } } diff --git a/crates/forge/src/cli.rs b/crates/forge/src/cli.rs index 12a15172..81f2bf99 100644 --- a/crates/forge/src/cli.rs +++ b/crates/forge/src/cli.rs @@ -209,6 +209,12 @@ fn run_serve(a: ServeArgs) -> Result<()> { if a.trusted_proxy_header.is_some() != !cidrs.is_empty() { bail!("--trusted-proxy-header and --trusted-proxies must be given together"); } + if a.behind_proxy && cidrs.is_empty() { + eprintln!( + "sc-forge: warning: --behind-proxy without --trusted-proxy-header/--trusted-proxies: every client shares the proxy's address, \ + so the per-source password-failure budget (30/min) applies to all of them together" + ); + } let proxies = Proxies { header: a.trusted_proxy_header.map(|h| h.to_ascii_lowercase()), cidrs, diff --git a/crates/forge/src/db.rs b/crates/forge/src/db.rs index bb7d2d91..4b93ba6b 100644 --- a/crates/forge/src/db.rs +++ b/crates/forge/src/db.rs @@ -70,7 +70,13 @@ pub fn db_path(data_dir: &Path) -> PathBuf { /// Open a connection with the durability and integrity settings every /// connection needs (WAL, `synchronous=FULL`, foreign keys, a busy timeout). pub fn connect(data_dir: &Path) -> Result { - let conn = Connection::open(db_path(data_dir))?; + let path = db_path(data_dir); + let fresh = !path.exists(); + let conn = Connection::open(&path)?; + if fresh { + // Owner-only before WAL creates its siblings, which copy this mode. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } conn.busy_timeout(std::time::Duration::from_secs(5))?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "FULL")?; @@ -93,12 +99,11 @@ fn migrate(conn: &mut Connection) -> Result<(), DbError> { Ok(()) } -/// Create the data directory (owner-only: the database holds password hashes). +/// Create the data directory and make it owner-only: the database holds +/// password and token hashes, so a pre-existing directory is tightened too. fn ensure_dir(data_dir: &Path) -> Result<(), DbError> { - if !data_dir.exists() { - std::fs::create_dir_all(data_dir)?; - std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?; - } + std::fs::create_dir_all(data_dir)?; + std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?; Ok(()) } @@ -147,6 +152,8 @@ mod tests { assert_eq!(version(&conn).unwrap(), SCHEMA_VERSION); let mode = std::fs::metadata(&d).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "data dir holds password hashes"); + let dbmode = std::fs::metadata(db_path(&d)).unwrap().permissions().mode() & 0o777; + assert_eq!(dbmode, 0o600); open_for_serve(&d).unwrap(); let _ = std::fs::remove_dir_all(&d); assert!(!d.exists()); @@ -183,3 +190,22 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod perm_tests { + use super::*; + + #[test] + fn an_existing_world_readable_dir_is_tightened() { + let d = std::env::temp_dir().join(format!("scl-forge-db-perm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + drop(open_for_admin(&d).unwrap()); + assert_eq!( + std::fs::metadata(&d).unwrap().permissions().mode() & 0o777, + 0o700 + ); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/forge/src/http.rs b/crates/forge/src/http.rs index d5a68dc8..1f41d70d 100644 --- a/crates/forge/src/http.rs +++ b/crates/forge/src/http.rs @@ -3,6 +3,7 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::net::IpAddr; +use std::time::Duration; /// A fully read request, handed to the synchronous router. pub struct Req { @@ -37,6 +38,9 @@ pub struct Resp { pub status: u16, pub headers: Vec<(&'static str, String)>, pub body: Vec, + /// Held back by the server before writing (failed-login backoff), on the + /// async runtime so it occupies no blocking thread. + pub delay: Duration, } const JSON_CT: &str = "application/json;charset=utf-8"; @@ -47,6 +51,7 @@ impl Resp { status, headers: vec![("content-type", JSON_CT.into())], body: serde_json::to_vec(v).expect("serializing a Value cannot fail"), + delay: Duration::ZERO, } } @@ -55,9 +60,15 @@ impl Resp { status: 204, headers: vec![], body: vec![], + delay: Duration::ZERO, } } + pub fn delayed(mut self, d: Duration) -> Resp { + self.delay = d; + self + } + pub fn with(mut self, name: &'static str, value: String) -> Resp { self.headers.push((name, value)); self diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index 07fc2c77..8fefc21d 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -43,73 +43,100 @@ impl Drop for Permit<'_> { const WINDOW: Duration = Duration::from_secs(600); const SOURCE_WINDOW: Duration = Duration::from_secs(60); -/// Failed attempts one source may make per minute before it is refused. -const SOURCE_CAP: usize = 30; -const MAX_DELAY: Duration = Duration::from_secs(2); const MAX_ENTRIES: usize = 10_000; +const MAX_KEY: usize = 128; /// Failure bookkeeping for password logins. Not a lockout: a stranger failing /// against `alice` slows other *wrong* guesses but never blocks alice's right /// one, and a flooding source is refused alone. -#[derive(Default)] pub struct Backoff { + source_cap: usize, + max_delay: Duration, by_login: Mutex>>, by_source: Mutex>>, } +impl Default for Backoff { + /// 30 attempts per source per minute, delays doubling from 50 ms to 2 s. + fn default() -> Backoff { + Backoff::new(30, Duration::from_secs(2)) + } +} + fn prune(v: &mut Vec, window: Duration) { let now = Instant::now(); v.retain(|t| now.duration_since(*t) < window); } +/// Keep a map bounded: drop expired entries, and if it is still over budget +/// start over. shortcut: a distributed flood of more than MAX_ENTRIES live +/// sources resets everyone's counters; move to an LRU if that is ever seen. +fn bound(m: &mut HashMap>, window: Duration) { + if m.len() > MAX_ENTRIES { + m.retain(|_, v| { + prune(v, window); + !v.is_empty() + }); + if m.len() > MAX_ENTRIES { + m.clear(); + } + } +} + impl Backoff { - /// Has this source exhausted its per-minute failure budget? - pub fn source_blocked(&self, ip: IpAddr) -> bool { + pub fn new(source_cap: usize, max_delay: Duration) -> Backoff { + Backoff { + source_cap, + max_delay, + by_login: Mutex::default(), + by_source: Mutex::default(), + } + } + + /// Reserve one password attempt for `ip`. `false` when the source has used + /// its per-minute budget; the check and the reservation are one step, so + /// concurrent requests cannot all slip under the cap. A failure keeps the + /// reservation as its record; [`Backoff::success`] gives it back. + pub fn begin(&self, ip: IpAddr) -> bool { let mut m = self.by_source.lock().unwrap_or_else(|e| e.into_inner()); - match m.get_mut(&ip) { - Some(v) => { - prune(v, SOURCE_WINDOW); - v.len() >= SOURCE_CAP - } - None => false, + bound(&mut m, SOURCE_WINDOW); + let v = m.entry(ip).or_default(); + prune(v, SOURCE_WINDOW); + if v.len() >= self.source_cap { + return false; } + v.push(Instant::now()); + true } /// Record a failed attempt; returns how long to delay the failure response - /// (doubling from 50 ms, capped at 2 s). - pub fn record_failure(&self, login: &str, ip: IpAddr) -> Duration { - let n = { - let mut m = self.by_login.lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > MAX_ENTRIES { - m.retain(|_, v| { - prune(v, WINDOW); - !v.is_empty() - }); - } - let v = m.entry(login.to_lowercase()).or_default(); - prune(v, WINDOW); - v.push(Instant::now()); - v.len() as u32 - }; - { - let mut m = self.by_source.lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > MAX_ENTRIES { - m.retain(|_, v| { - prune(v, SOURCE_WINDOW); - !v.is_empty() - }); - } - m.entry(ip).or_default().push(Instant::now()); - } - Duration::from_millis(50u64.saturating_mul(1u64 << (n - 1).min(16))).min(MAX_DELAY) + /// (doubling from 50 ms, capped). + pub fn failure(&self, login: &str, _ip: IpAddr) -> Duration { + let mut m = self.by_login.lock().unwrap_or_else(|e| e.into_inner()); + bound(&mut m, WINDOW); + let key: String = login.to_lowercase().chars().take(MAX_KEY).collect(); + let v = m.entry(key).or_default(); + prune(v, WINDOW); + v.push(Instant::now()); + let n = v.len() as u32; + Duration::from_millis(50u64.saturating_mul(1u64 << (n - 1).min(16))).min(self.max_delay) } - /// A success clears the login's failures. - pub fn record_success(&self, login: &str) { + /// A success clears the login's failures and returns the source's reservation. + pub fn success(&self, login: &str, ip: IpAddr) { + let key: String = login.to_lowercase().chars().take(MAX_KEY).collect(); self.by_login .lock() .unwrap_or_else(|e| e.into_inner()) - .remove(&login.to_lowercase()); + .remove(&key); + if let Some(v) = self + .by_source + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_mut(&ip) + { + v.pop(); + } } } @@ -193,22 +220,45 @@ mod tests { fn delay_doubles_and_caps_and_success_resets() { let b = Backoff::default(); let d: Vec = (0..8) - .map(|_| b.record_failure("Alice", ip("1.1.1.1")).as_millis()) + .map(|_| b.failure("Alice", ip("1.1.1.1")).as_millis()) .collect(); assert_eq!(d, vec![50, 100, 200, 400, 800, 1600, 2000, 2000]); - b.record_success("alice"); // case-insensitive key - assert_eq!(b.record_failure("alice", ip("1.1.1.1")).as_millis(), 50); + b.success("alice", ip("1.1.1.1")); // case-insensitive key + assert_eq!(b.failure("alice", ip("1.1.1.1")).as_millis(), 50); } #[test] - fn a_flooding_source_is_blocked_but_others_are_not() { + fn the_source_budget_is_reserved_atomically_and_per_source() { + let b = Backoff::new(3, Duration::ZERO); + assert!([ + b.begin(ip("9.9.9.9")), + b.begin(ip("9.9.9.9")), + b.begin(ip("9.9.9.9")) + ] + .iter() + .all(|x| *x)); + assert!( + !b.begin(ip("9.9.9.9")), + "the fourth attempt is refused before any verification ran" + ); + assert!(b.begin(ip("8.8.8.8")), "other sources are unaffected"); + b.success("u", ip("9.9.9.9")); // a success gives its reservation back + assert!(b.begin(ip("9.9.9.9"))); + } + + #[test] + fn maps_stay_bounded_under_a_flood_of_distinct_keys() { let b = Backoff::default(); - for i in 0..SOURCE_CAP { - assert!(!b.source_blocked(ip("9.9.9.9")), "attempt {i}"); - b.record_failure(&format!("u{i}"), ip("9.9.9.9")); + for i in 0..(MAX_ENTRIES + 50) { + b.failure(&format!("login-{i}-{}", "x".repeat(500)), ip("1.1.1.1")); } - assert!(b.source_blocked(ip("9.9.9.9"))); - assert!(!b.source_blocked(ip("8.8.8.8"))); + assert!(b.by_login.lock().unwrap().len() <= MAX_ENTRIES + 1); + assert!(b + .by_login + .lock() + .unwrap() + .keys() + .all(|k| k.len() <= MAX_KEY)); } #[test] diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs index 14c25bdf..37a68287 100644 --- a/crates/forge/src/routes.rs +++ b/crates/forge/src/routes.rs @@ -7,7 +7,7 @@ use crate::auth::{self, Authed}; use crate::http::{error, not_found, Req, Resp}; use crate::limits::{Backoff, Gate, Proxies}; use crate::scope::{self, Cat, Level, Need}; -use crate::{api, db}; +use crate::{api, db, store}; use rusqlite::Connection; use std::path::PathBuf; @@ -55,6 +55,9 @@ pub enum Actor { SiteAdmin, /// The caller must be the user named by this path parameter, or a site admin. SelfOrSiteAdmin(&'static str), + /// The caller must own the organization named by this path parameter, or be a + /// site admin; a missing organization is a 404. + OrgOwnerOrSiteAdmin(&'static str), } /// What a handler receives; authentication and policy already passed. @@ -111,7 +114,7 @@ const fn n(cat: Cat, level: Level) -> Option { Some(Need(cat, level)) } -use Actor::{Any, SelfOrSiteAdmin, SiteAdmin}; +use Actor::{Any, OrgOwnerOrSiteAdmin, SelfOrSiteAdmin, SiteAdmin}; use Class::{Password, Token}; /// The route table. F2 onward append rows; a row cannot omit its class, @@ -206,12 +209,20 @@ pub static ROUTES: &[Route] = &[ Any, api::get_org, ), - // Declared now so the scope check applies; the handler arrives with F2. + // Declared now so scope and actor checks apply; the handlers arrive with F2. r( "POST", "/orgs/{o}/repos", Token, n(Cat::Organization, Level::Write), + OrgOwnerOrSiteAdmin("o"), + api::not_implemented, + ), + r( + "POST", + "/user/repos", + Token, + n(Cat::User, Level::Write), Any, api::not_implemented, ), @@ -327,23 +338,32 @@ pub fn dispatch(app: &App, req: &Req) -> Resp { ); } } + let param = |p: &str| { + params + .iter() + .find(|(k, _)| *k == p) + .map(|(_, v)| v.as_str()) + .unwrap_or("") + }; let denied = match route.actor { - Actor::Any => false, - Actor::SiteAdmin => !who.user.is_admin, - Actor::SelfOrSiteAdmin(p) => { - let target = params - .iter() - .find(|(k, _)| *k == p) - .map(|(_, v)| v.as_str()) - .unwrap_or(""); - !who.user.is_admin && !who.user.login.eq_ignore_ascii_case(target) + Actor::Any => None, + Actor::SiteAdmin => { + (!who.user.is_admin).then_some("You must be a site admin to use this route") } + Actor::SelfOrSiteAdmin(p) => (!who.user.is_admin + && !who.user.login.eq_ignore_ascii_case(param(p))) + .then_some("doer should be the site admin or be same as the contextUser"), + Actor::OrgOwnerOrSiteAdmin(p) => match store::find_by_login(&conn, param(p)) { + Ok(Some(org)) if org.is_org => match store::is_org_owner(&conn, org.id, who.user.id) { + Ok(owner) => (!owner && !who.user.is_admin) + .then_some("Given user is not allowed to create repository in organization."), + Err(e) => return app.internal(&e), + }, + Ok(_) => return api::org_missing(app, param(p)), + Err(e) => return app.internal(&e), + }, }; - if denied { - let msg = match route.actor { - Actor::SiteAdmin => "You must be a site admin to use this route", - _ => "doer should be the site admin or be same as the contextUser", - }; + if let Some(msg) = denied { return error(&app.base, 403, msg); } (route.handler)(&mut Ctx { diff --git a/crates/forge/src/server.rs b/crates/forge/src/server.rs index 31bc39fe..3d7b37c5 100644 --- a/crates/forge/src/server.rs +++ b/crates/forge/src/server.rs @@ -27,6 +27,8 @@ pub struct Limits { pub max_body: usize, pub max_connections: usize, pub header_timeout: Duration, + /// Time allowed to receive a request body once its headers arrived. + pub body_timeout: Duration, } impl Default for Limits { @@ -35,6 +37,7 @@ impl Default for Limits { max_body: 1 << 20, max_connections: 512, header_timeout: Duration::from_secs(10), + body_timeout: Duration::from_secs(30), } } } @@ -67,11 +70,26 @@ async fn handle( .iter() .filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string()))) .collect::>(); - let body = match Limited::new(body, limits.max_body).collect().await { - Ok(b) => b.to_bytes().to_vec(), + let body = match tokio::time::timeout( + limits.body_timeout, + Limited::new(body, limits.max_body).collect(), + ) + .await + { + Ok(Ok(b)) => b.to_bytes().to_vec(), + Ok(Err(_)) => { + return Ok(to_response(crate::http::error( + &app.base, + 413, + "request body too large", + ))) + } Err(_) => { - let r = crate::http::error(&app.base, 413, "request body too large"); - return Ok(to_response(r)); + return Ok(to_response(crate::http::error( + &app.base, + 408, + "request body timed out", + ))) } }; let source = app.proxies.resolve( @@ -96,6 +114,9 @@ async fn handle( &serde_json::json!({"message": "internal server error"}), ) }); + if !resp.delay.is_zero() { + tokio::time::sleep(resp.delay).await; + } Ok(to_response(resp)) } diff --git a/crates/forge/src/store.rs b/crates/forge/src/store.rs index be5ce928..78e44992 100644 --- a/crates/forge/src/store.rs +++ b/crates/forge/src/store.rs @@ -155,27 +155,46 @@ pub struct UserPatch { pub location: Option, } -/// Apply `p` to user `id`. +/// Apply `p` to user `id`. Only the supplied columns are written, in one +/// statement, so two concurrent patches of different fields cannot undo each +/// other (a stale read-modify-write could restore a revoked admin flag or an +/// old password hash). pub fn update_user(conn: &Connection, id: i64, p: &UserPatch) -> Result { - let cur = find_by_id(conn, id)?.ok_or(StoreError::NotFound)?; - conn.execute( - "UPDATE users SET login_name=?1, full_name=?2, email=?3, password_hash=?4, \ - must_change_password=?5, is_admin=?6, visibility=?7, description=?8, website=?9, \ - location=?10 WHERE id=?11", - params![ - p.login_name.clone().unwrap_or(cur.login_name), - p.full_name.clone().unwrap_or(cur.full_name), - p.email.clone().unwrap_or(cur.email), - p.password_hash.clone().unwrap_or(cur.password_hash), - p.must_change_password.unwrap_or(cur.must_change_password), - p.is_admin.unwrap_or(cur.is_admin), - p.visibility.clone().unwrap_or(cur.visibility), - p.description.clone().unwrap_or(cur.description), - p.website.clone().unwrap_or(cur.website), - p.location.clone().unwrap_or(cur.location), - id - ], - )?; + use rusqlite::types::Value; + let text = |v: &Option| v.clone().map(Value::Text); + let flag = |v: Option| v.map(|b| Value::Integer(i64::from(b))); + let cols: Vec<(&str, Value)> = [ + ("login_name", text(&p.login_name)), + ("full_name", text(&p.full_name)), + ("email", text(&p.email)), + ("password_hash", text(&p.password_hash)), + ("must_change_password", flag(p.must_change_password)), + ("is_admin", flag(p.is_admin)), + ("visibility", text(&p.visibility)), + ("description", text(&p.description)), + ("website", text(&p.website)), + ("location", text(&p.location)), + ] + .into_iter() + .filter_map(|(c, v)| v.map(|v| (c, v))) + .collect(); + if !cols.is_empty() { + let set = cols + .iter() + .map(|(c, _)| format!("{c} = ?")) + .collect::>() + .join(", "); + let mut args: Vec = cols.into_iter().map(|(_, v)| v).collect(); + args.push(Value::Integer(id)); + // Column names above are literals; only values are bound. + if conn.execute( + &format!("UPDATE users SET {set} WHERE id = ?"), + rusqlite::params_from_iter(args), + )? == 0 + { + return Err(StoreError::NotFound); + } + } find_by_id(conn, id)?.ok_or(StoreError::NotFound) } @@ -270,7 +289,7 @@ pub fn create_token( } } -/// Page of `user`'s tokens and the total, ordered by id. +/// Page of `user`'s tokens and the total, newest first (Gitea's order; ties by id). pub fn list_tokens( conn: &Connection, user: i64, @@ -284,7 +303,7 @@ pub fn list_tokens( )?; let mut st = conn.prepare( "SELECT id, name, last_eight, scopes, expires_unix FROM tokens WHERE user_id = ?1 \ - ORDER BY id LIMIT ?2 OFFSET ?3", + ORDER BY created_unix DESC, id ASC LIMIT ?2 OFFSET ?3", )?; let rows = st.query_map(params![user, limit, (page - 1) * limit], token_row)?; Ok((rows.collect::>()?, total)) diff --git a/crates/forge/tests/common/mod.rs b/crates/forge/tests/common/mod.rs index 9f9ea949..5fa01682 100644 --- a/crates/forge/tests/common/mod.rs +++ b/crates/forge/tests/common/mod.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] use scl_forge::limits::Proxies; -use scl_forge::server::{make_app, serve, Limits}; +use scl_forge::server::{serve, Limits}; use scl_forge::{admin, db}; use serde_json::Value; use std::collections::HashMap; @@ -74,6 +74,15 @@ impl Srv { } pub fn start_with(limits: Limits) -> Srv { + Srv::start_cfg(limits, None) + } + + /// Start with a custom password-failure policy. + pub fn start_backoff(b: scl_forge::limits::Backoff) -> Srv { + Srv::start_cfg(Limits::default(), Some(b)) + } + + fn start_cfg(limits: Limits, backoff: Option) -> Srv { let dir = std::env::temp_dir().join(format!( "scl-forge-e2e-{}-{}", std::process::id(), @@ -97,7 +106,11 @@ impl Srv { std_l.set_nonblocking(true).unwrap(); let addr = std_l.local_addr().unwrap(); let base = format!("http://{addr}"); - let app = make_app(dir.clone(), &base, Proxies::default(), 4); + let mut app = scl_forge::routes::App::new(dir.clone(), &base, Proxies::default(), 4); + if let Some(b) = backoff { + app.backoff = b; + } + let app = std::sync::Arc::new(app); let (tx, rx) = tokio::sync::oneshot::channel(); let thread = std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs index 39586fad..460f9012 100644 --- a/crates/forge/tests/e2e_a.rs +++ b/crates/forge/tests/e2e_a.rs @@ -488,31 +488,111 @@ fn alices_password_cannot_mint_bobs_tokens_but_a_site_admins_can() { } #[test] -fn wrong_passwords_are_indistinguishable_and_unknown_users_cost_the_same() { +fn wrong_passwords_are_indistinguishable_and_never_reveal_what_the_secret_was() { let s = Srv::start(); let pw = s.mk_user("alice"); - let wrong = s.post( - "/api/v1/users/alice/tokens", - &basic("alice", "nope"), - json!({"name": "a", "scopes": ["all"]}), - ); - let unknown = s.post( - "/api/v1/users/nobody/tokens", - &basic("nobody", "nope"), - json!({"name": "a", "scopes": ["all"]}), + let org = s.post( + "/api/v1/orgs", + &token(&s.admin), + json!({"username": "acme"}), ); - assert_eq!((wrong.status, unknown.status), (401, 401)); - assert_eq!(wrong.raw, unknown.raw, "same body: no account enumeration"); + assert_eq!(org.status, 201); + let ro = s.mk_token("alice", &pw, "ro", &["read:repository"]); + let body = json!({"name": "a", "scopes": ["all"]}); + let attempts = [ + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", "nope"), + body.clone(), + ), + s.post( + "/api/v1/users/nobody/tokens", + &basic("nobody", "nope"), + body.clone(), + ), + s.post( + "/api/v1/users/acme/tokens", + &basic("acme", "nope"), + body.clone(), + ), // an organization name + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &ro), + body.clone(), + ), // a valid token as the password + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &s.admin), + body.clone(), + ), // someone else's token + ]; + for a in &attempts { + assert_eq!(a.status, 401); + assert_eq!( + a.raw, attempts[0].raw, + "same body whatever the credential was: no account or token oracle" + ); + } // The right password still works straight after failures (backoff is not a lockout). + assert_eq!( + s.post("/api/v1/users/alice/tokens", &basic("alice", &pw), body) + .status, + 201 + ); +} + +#[test] +fn failed_passwords_are_delayed_and_a_flooding_source_is_refused() { + use std::time::{Duration, Instant}; + let s = Srv::start_backoff(scl_forge::limits::Backoff::new( + 4, + Duration::from_millis(400), + )); + let pw = s.mk_user("alice"); + let body = json!({"name": "a", "scopes": ["all"]}); + let t = Instant::now(); + for _ in 0..3 { + assert_eq!( + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", "bad"), + body.clone() + ) + .status, + 401 + ); + } + assert!( + t.elapsed() >= Duration::from_millis(50 + 100 + 200), + "delays double per failure: {:?}", + t.elapsed() + ); + // The fourth attempt uses the last unit of the budget; the fifth is refused before any verification. assert_eq!( s.post( "/api/v1/users/alice/tokens", &basic("alice", &pw), - json!({"name": "ok", "scopes": ["all"]}) + body.clone() ) .status, 201 ); + for _ in 0..3 { + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", "bad"), + body.clone(), + ); + } + let r = s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &pw), + body.clone(), + ); + assert_eq!(r.status, 429); + assert_eq!(r.headers["retry-after"], "60"); + // Token routes are the only place passwords are tried; ordinary routes stay open to tokens. + assert_eq!(s.get("/api/v1/user", &token(&s.admin)).status, 200); } #[test] @@ -725,3 +805,304 @@ fn credentials_cli_forms_parse() { ); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_stalled_request_body_is_cut_off() { + let s = Srv::start_with(scl_forge::server::Limits { + body_timeout: std::time::Duration::from_millis(300), + ..Default::default() + }); + // Promise 10 bytes, send none: the server must answer 408 instead of holding the connection. + let head = format!("POST /api/v1/orgs HTTP/1.1\r\nHost: x\r\nConnection: close\r\nAuthorization: token {}\r\nContent-Length: 10\r\n\r\n", s.admin); + let r = s.raw(head.as_bytes(), b""); + assert_eq!(r.status, 408); +} + +#[test] +fn token_lists_are_newest_first() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + for n in ["first", "second", "third"] { + s.mk_token("alice", &pw, n, &["read:user"]); + } + let conn = scl_forge::db::connect(&s.dir).unwrap(); + for (n, t) in [("first", 100), ("second", 200), ("third", 300)] { + conn.execute( + "UPDATE tokens SET created_unix = ?1 WHERE name = ?2", + rusqlite::params![t, n], + ) + .unwrap(); + } + let names: Vec<_> = s + .get("/api/v1/users/alice/tokens", &basic("alice", &pw)) + .json() + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + names, + ["third", "second", "first"], + "recorded X#29/R#07 order" + ); +} + +#[test] +fn user_and_org_repo_creation_routes_apply_scope_and_actor_rules() { + let s = Srv::start(); + let adm = token(&s.admin); + let alice_pw = s.mk_user("alice"); + let bot_pw = s.mk_user("bot"); + let alice_all = s.mk_token("alice", &alice_pw, "all", &["all"]); + let alice_ro = s.mk_token("alice", &alice_pw, "ro", &["read:repository"]); + let bot_all = s.mk_token("bot", &bot_pw, "all", &["all"]); + // R#14: a non-admin creates an organization and becomes its owner. + let bo = s.post( + "/api/v1/orgs", + &token(&bot_all), + json!({"username": "botorg"}), + ); + assert_eq!(bo.status, 201); + // B#26/B#34: scope is checked before the route is served. + let r = s.post( + "/api/v1/user/repos", + &token(&alice_ro), + json!({"name": "nope"}), + ); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 403, + "token does not have at least one of required scope(s): [write:user]" + ) + ); + // R#15/R#16: a non-owner is refused whatever the token's scope. + let wo = s.mk_token("alice", &alice_pw, "wo", &["write:organization"]); + for t in [&wo, &alice_all] { + let r = s.post( + "/api/v1/orgs/botorg/repos", + &token(t), + json!({"name": "intruder"}), + ); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 403, + "Given user is not allowed to create repository in organization." + ) + ); + } + // The owner and a site admin pass the policy (the handler itself arrives with F2). + assert_eq!( + s.post( + "/api/v1/orgs/botorg/repos", + &token(&bot_all), + json!({"name": "legit"}) + ) + .status, + 501 + ); + assert_eq!( + s.post("/api/v1/orgs/botorg/repos", &adm, json!({"name": "legit"})) + .status, + 501 + ); + let r = s.post("/api/v1/orgs/ghost/repos", &adm, json!({"name": "x"})); + assert_eq!( + (r.status, r.json()["message"].as_str()), + (404, Some("GetOrgByName")) + ); + // R#18: a non-owner with a write:organization-only token reads a public org. + assert_eq!(s.get("/api/v1/orgs/botorg", &token(&wo)).status, 200); + // R#02: public-only tokens still need the scope. + let po = s.mk_token( + "alice", + &alice_pw, + "po", + &["public-only", "read:repository"], + ); + let r = s.get("/api/v1/user", &token(&po)); + assert_eq!( + (r.status, r.msg().as_str()), + ( + 403, + "token does not have at least one of required scope(s): [read:user]" + ) + ); +} + +#[test] +fn private_org_permissions_are_hidden_from_outsiders() { + let s = Srv::start(); + let adm = token(&s.admin); + let pw = s.mk_user("eve"); + let eve = s.mk_token("eve", &pw, "t", &["read:organization", "read:user"]); + s.post( + "/api/v1/orgs", + &adm, + json!({"username": "secret", "visibility": "private"}), + ); + let r = s.get( + "/api/v1/users/siteadmin/orgs/secret/permissions", + &token(&eve), + ); + assert_eq!( + (r.status, r.msg().as_str()), + (404, "HasOrgOrUserVisible"), + "no membership oracle for outsiders" + ); + assert_eq!( + s.get("/api/v1/users/siteadmin/orgs/secret/permissions", &adm) + .status, + 200 + ); +} + +#[test] +fn admin_lists_another_users_tokens_with_a_password_and_cannot_delete_across_users() { + let s = Srv::start(); + let alice_pw = s.mk_user("alice"); + let bob_pw = s.mk_user("bob"); + let bob_tok = s.mk_token("bob", &bob_pw, "bobs", &["read:user"]); + let bobs_id = s + .get("/api/v1/users/bob/tokens", &basic("bob", &bob_pw)) + .json()[0]["id"] + .clone(); + // alice cannot reach bob's token through her own path: ids are scoped to the path user. + let r = s.send( + "DELETE", + &format!("/api/v1/users/alice/tokens/{bobs_id}"), + Some(&basic("alice", &alice_pw)), + None, + ); + assert_eq!(r.status, 404); + assert_eq!( + s.get("/api/v1/user", &token(&bob_tok)).status, + 200, + "bob's token survived" + ); + // X#29: a site admin with a password lists another user's tokens. + s.post("/api/v1/admin/users", &token(&s.admin), json!({"username": "boss", "email": "b@example.invalid", "password": "boss-pw-1", "must_change_password": false})); + s.send( + "PATCH", + "/api/v1/admin/users/boss", + Some(&token(&s.admin)), + Some(&json!({"admin": true})), + ); + let r = s.get("/api/v1/users/bob/tokens", &basic("boss", "boss-pw-1")); + assert_eq!((r.status, r.headers["x-total-count"].as_str()), (200, "1")); +} + +#[test] +fn password_change_flag_blocks_the_password_but_not_issued_tokens() { + let s = Srv::start(); + let pw = s.mk_user("alice"); + let t = s.mk_token("alice", &pw, "t", &["read:user"]); + s.send( + "PATCH", + "/api/v1/admin/users/alice", + Some(&token(&s.admin)), + Some(&json!({"must_change_password": true})), + ); + assert_eq!( + s.post( + "/api/v1/users/alice/tokens", + &basic("alice", &pw), + json!({"name": "n", "scopes": ["all"]}) + ) + .status, + 403 + ); + assert_eq!(s.get("/api/v1/user", &token(&t)).status, 200); +} + +#[test] +fn patching_one_field_never_rewrites_another() { + let s = Srv::start(); + s.mk_user("alice"); + let conn = scl_forge::db::connect(&s.dir).unwrap(); + let alice = scl_forge::store::find_by_login(&conn, "alice") + .unwrap() + .unwrap(); + // A stale snapshot of alice, then an admin grant and a password reset land... + let hash = scl_crypto::hash_password("fresh"); + scl_forge::store::update_user( + &conn, + alice.id, + &scl_forge::store::UserPatch { + is_admin: Some(true), + password_hash: Some(hash.clone()), + ..Default::default() + }, + ) + .unwrap(); + // ...and a patch that only carries a description must not write the stale flag or hash back. + scl_forge::store::update_user( + &conn, + alice.id, + &scl_forge::store::UserPatch { + description: Some("hi".into()), + ..Default::default() + }, + ) + .unwrap(); + let now = scl_forge::store::find_by_id(&conn, alice.id) + .unwrap() + .unwrap(); + assert_eq!( + (now.is_admin, now.password_hash, now.description), + (true, hash, "hi".to_string()) + ); +} + +#[test] +fn huge_page_numbers_do_not_overflow() { + let s = Srv::start(); + let r = s.get( + "/api/v1/admin/users?page=9223372036854775807&limit=50", + &token(&s.admin), + ); + assert_eq!(r.status, 200); + assert_eq!(r.json(), json!([])); +} + +#[test] +fn concurrent_patches_of_different_fields_both_land() { + let s = Srv::start(); + s.mk_user("alice"); + let id = scl_forge::store::find_by_login(&scl_forge::db::connect(&s.dir).unwrap(), "alice") + .unwrap() + .unwrap() + .id; + let dir = s.dir.clone(); + let writer = std::thread::spawn(move || { + let c = scl_forge::db::connect(&dir).unwrap(); + for i in 0..300 { + let p = scl_forge::store::UserPatch { + description: Some(format!("d{i}")), + ..Default::default() + }; + scl_forge::store::update_user(&c, id, &p).unwrap(); + } + }); + let c = scl_forge::db::connect(&s.dir).unwrap(); + for _ in 0..300 { + scl_forge::store::update_user( + &c, + id, + &scl_forge::store::UserPatch { + is_admin: Some(true), + ..Default::default() + }, + ) + .unwrap(); + } + writer.join().unwrap(); + let u = scl_forge::store::find_by_id(&c, id).unwrap().unwrap(); + assert!( + u.is_admin, + "a concurrent description patch must not write a stale is_admin back" + ); + assert_eq!(u.description, "d299"); +} diff --git a/docs/superpowers/specs/2026-10-22-p38-forge-design.md b/docs/superpowers/specs/2026-10-22-p38-forge-design.md index c9b3858b..0185cacb 100644 --- a/docs/superpowers/specs/2026-10-22-p38-forge-design.md +++ b/docs/superpowers/specs/2026-10-22-p38-forge-design.md @@ -645,9 +645,22 @@ header. Decisions drawn from it, beyond those stated in the sections above: readonly-token-as-password note (X#32) is a departure: 401, not Gitea's 403. - **Ordinary routes.** A Basic password that is not a token gets 401 `auth required: this route accepts access tokens only`; a bad `token`/`Bearer` or token-as-username - gets the recorded `user does not exist [uid: 0, name: ...]` (name capped at 80 - characters). Every `/api/v1/*` path, declared or not, authenticates before the + gets the recorded `user does not exist [uid: 0, name: ...]` (the `[uid: 0, name: ...]` + suffix is Gitea's own and kept; name capped at 80 characters). Every `/api/v1/*` path, declared or not, authenticates before the route lookup, so an unauthenticated caller learns nothing about the API surface. +- **Budget and limits.** The per-source budget (30 password attempts a minute) is + reserved before verification, so concurrent requests cannot slip under it, and a + success returns its unit. The failure delay is applied on the async runtime, not on a + blocking thread. A request body must arrive within 30 s of its headers (408), headers + within 10 s, at most 1 MiB (413), at most 512 connections. The data directory is made + `0700` and `forge.db` `0600` at creation, also for a pre-existing directory. Token + lists are newest first (Gitea's order), ties by id. `PATCH /admin/users/{u}` writes + only the supplied columns, so concurrent patches cannot restore an old admin flag or + password hash. `--behind-proxy` without trusted proxies warns that all clients then + share one failure budget. +- **Organization permissions.** `GET /users/{u}/orgs/{o}/permissions` requires that the + caller can see the organization (a private one answers `HasOrgOrUserVisible` to + outsiders whoever the subject is) and hides a private subject like `GET /users/{u}`. - **`must_change_password`** (the default for `admin/users` POST, as in Gitea) refuses password authentication on token routes with 403; tokens are unaffected. - **Site-admin tokens.** A token with `write:admin` can `PATCH /admin/users/{u}` From 033491e065652f3f5a29ee89f0bae393695de355 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:06:58 +0300 Subject: [PATCH 03/11] fix(forge): close two org existence oracles, key IPv6 sources by /64 A hidden user and a missing user now answer alike before the organization is resolved, and a non-owner creating a repo in a private org gets the missing-org answer instead of 403. --- crates/forge/src/api.rs | 10 ++++++---- crates/forge/src/limits.rs | 19 +++++++++++++++++++ crates/forge/src/routes.rs | 9 +++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/forge/src/api.rs b/crates/forge/src/api.rs index 2547177c..e04e26b7 100644 --- a/crates/forge/src/api.rs +++ b/crates/forge/src/api.rs @@ -190,6 +190,12 @@ pub fn org_permissions(ctx: &mut Ctx) -> Resp { Ok(_) => return no_such_user(ctx, &uname), Err(e) => return ctx.app.internal(&e), }; + // A hidden subject answers like a missing one, before the organization is + // resolved, so the two cannot be told apart. + let me = &ctx.who.user; + if subject.visibility == "private" && subject.id != me.id && !me.is_admin { + return no_such_user(ctx, &uname); + } let org = match store::find_by_login(ctx.conn, &oname) { Ok(Some(o)) if o.is_org => o, Ok(_) => return no_such_org(ctx, &oname), @@ -203,10 +209,6 @@ pub fn org_permissions(ctx: &mut Ctx) -> Resp { Ok(false) => return not_found(&ctx.app.base, "HasOrgOrUserVisible", None), Err(e) => return ctx.app.internal(&e), } - let me = &ctx.who.user; - if subject.visibility == "private" && subject.id != me.id && !me.is_admin { - return no_such_user(ctx, &uname); - } let full = match store::is_org_owner(ctx.conn, org.id, subject.id) { Ok(owner) => owner || subject.is_admin, Err(e) => return ctx.app.internal(&e), diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index 8fefc21d..20e02e50 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -83,6 +83,15 @@ fn bound(m: &mut HashMap>, window: Duration) { } } +/// IPv6 sources are keyed by their /64, since one host usually holds a whole +/// /64 and could otherwise mint endless distinct keys. +fn source_key(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(a) => IpAddr::V6((u128::from(a) & (u128::MAX << 64)).into()), + v4 => v4, + } +} + impl Backoff { pub fn new(source_cap: usize, max_delay: Duration) -> Backoff { Backoff { @@ -98,6 +107,7 @@ impl Backoff { /// concurrent requests cannot all slip under the cap. A failure keeps the /// reservation as its record; [`Backoff::success`] gives it back. pub fn begin(&self, ip: IpAddr) -> bool { + let ip = source_key(ip); let mut m = self.by_source.lock().unwrap_or_else(|e| e.into_inner()); bound(&mut m, SOURCE_WINDOW); let v = m.entry(ip).or_default(); @@ -124,6 +134,7 @@ impl Backoff { /// A success clears the login's failures and returns the source's reservation. pub fn success(&self, login: &str, ip: IpAddr) { + let ip = source_key(ip); let key: String = login.to_lowercase().chars().take(MAX_KEY).collect(); self.by_login .lock() @@ -246,6 +257,14 @@ mod tests { assert!(b.begin(ip("9.9.9.9"))); } + #[test] + fn one_ipv6_64_shares_a_budget() { + let b = Backoff::new(2, Duration::ZERO); + assert!(b.begin(ip("2001:db8::1")) && b.begin(ip("2001:db8::2"))); + assert!(!b.begin(ip("2001:db8::ffff:1")), "same /64"); + assert!(b.begin(ip("2001:db8:0:1::1")), "a different /64"); + } + #[test] fn maps_stay_bounded_under_a_flood_of_distinct_keys() { let b = Backoff::default(); diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs index 37a68287..7c722d70 100644 --- a/crates/forge/src/routes.rs +++ b/crates/forge/src/routes.rs @@ -355,8 +355,13 @@ pub fn dispatch(app: &App, req: &Req) -> Resp { .then_some("doer should be the site admin or be same as the contextUser"), Actor::OrgOwnerOrSiteAdmin(p) => match store::find_by_login(&conn, param(p)) { Ok(Some(org)) if org.is_org => match store::is_org_owner(&conn, org.id, who.user.id) { - Ok(owner) => (!owner && !who.user.is_admin) - .then_some("Given user is not allowed to create repository in organization."), + Ok(true) => None, + Ok(false) if who.user.is_admin => None, + // A private organization is invisible to a non-owner: same answer as a missing one. + Ok(false) if org.visibility == "private" => return api::org_missing(app, param(p)), + Ok(false) => { + Some("Given user is not allowed to create repository in organization.") + } Err(e) => return app.internal(&e), }, Ok(_) => return api::org_missing(app, param(p)), From e3eb55c75feb03985248c7bc0d5fcf8aeac639b0 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:08:10 +0300 Subject: [PATCH 04/11] fix(forge): say so when a pre-existing data dir is tightened to 0700 --- crates/forge/src/db.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/forge/src/db.rs b/crates/forge/src/db.rs index 4b93ba6b..e1d1532b 100644 --- a/crates/forge/src/db.rs +++ b/crates/forge/src/db.rs @@ -103,7 +103,14 @@ fn migrate(conn: &mut Connection) -> Result<(), DbError> { /// password and token hashes, so a pre-existing directory is tightened too. fn ensure_dir(data_dir: &Path) -> Result<(), DbError> { std::fs::create_dir_all(data_dir)?; - std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?; + let mode = std::fs::metadata(data_dir)?.permissions().mode() & 0o777; + if mode != 0o700 { + eprintln!( + "sc-forge: tightening {} from {mode:o} to 700: it holds password and token hashes", + data_dir.display() + ); + std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?; + } Ok(()) } From c50d435c1ba1a039cd8ed90e3d789a4f1c8ca172 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:09:14 +0300 Subject: [PATCH 05/11] fix(forge): canonicalize IPv4-mapped sources before /64 keying --- crates/forge/src/limits.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index 20e02e50..5c89eaee 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -86,7 +86,9 @@ fn bound(m: &mut HashMap>, window: Duration) { /// IPv6 sources are keyed by their /64, since one host usually holds a whole /// /64 and could otherwise mint endless distinct keys. fn source_key(ip: IpAddr) -> IpAddr { - match ip { + // A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d; treat those as + // the IPv4 address they are, or every one of them would share one /64 key. + match ip.to_canonical() { IpAddr::V6(a) => IpAddr::V6((u128::from(a) & (u128::MAX << 64)).into()), v4 => v4, } @@ -265,6 +267,20 @@ mod tests { assert!(b.begin(ip("2001:db8:0:1::1")), "a different /64"); } + #[test] + fn ipv4_mapped_addresses_keep_their_own_budget() { + let b = Backoff::new(1, Duration::ZERO); + assert!(b.begin(ip("::ffff:1.2.3.4"))); + assert!( + b.begin(ip("::ffff:5.6.7.8")), + "a different IPv4 client is a different source" + ); + assert!( + !b.begin(ip("1.2.3.4")), + "mapped and plain forms of one client share a budget" + ); + } + #[test] fn maps_stay_bounded_under_a_flood_of_distinct_keys() { let b = Backoff::default(); From 71ecedbe6f9caa57f6ad27a95a26cc3e148d9780 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:12:52 +0300 Subject: [PATCH 06/11] fix(forge): satisfy clippy nonminimal_bool on the pinned 1.96 toolchain --- crates/forge/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge/src/cli.rs b/crates/forge/src/cli.rs index 81f2bf99..741ac31a 100644 --- a/crates/forge/src/cli.rs +++ b/crates/forge/src/cli.rs @@ -206,7 +206,7 @@ fn run_serve(a: ServeArgs) -> Result<()> { .map(|c| Cidr::parse(c)) .collect::, _>>() .map_err(anyhow::Error::msg)?; - if a.trusted_proxy_header.is_some() != !cidrs.is_empty() { + if a.trusted_proxy_header.is_some() == cidrs.is_empty() { bail!("--trusted-proxy-header and --trusted-proxies must be given together"); } if a.behind_proxy && cidrs.is_empty() { From f807124e4c3138b045056ff35195681455dea9ab Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:21:01 +0300 Subject: [PATCH 07/11] fix(forge): review fixes round 2 Reject pending entries that defer nothing, compute the dummy password hash at startup so the first unknown login is not slower, store login_name in the create itself instead of a swallowed follow-up update. --- crates/crypto/src/credentials.rs | 10 +++++++++- crates/crypto/src/lib.rs | 2 +- crates/forge/conformance/run.py | 8 +++++++- crates/forge/src/api.rs | 19 ++----------------- crates/forge/src/routes.rs | 1 + crates/forge/src/store.rs | 8 +++++--- crates/forge/tests/e2e_a.rs | 10 ++++++++++ 7 files changed, 35 insertions(+), 23 deletions(-) diff --git a/crates/crypto/src/credentials.rs b/crates/crypto/src/credentials.rs index 237afda0..8da309f3 100644 --- a/crates/crypto/src/credentials.rs +++ b/crates/crypto/src/credentials.rs @@ -18,11 +18,19 @@ pub fn hash_password(password: &str) -> String { .to_string() } +static DUMMY: OnceLock = OnceLock::new(); + +/// Compute the dummy hash now. Call once at startup so the first unknown-login +/// attempt does not pay for hashing on top of verifying, which would make it +/// measurably slower than a wrong password for a real account. +pub fn warm_up() { + DUMMY.get_or_init(|| hash_password("sc-forge-dummy")); +} + /// Verify `password` against a PHC `stored` hash. With `None` (no such account) /// it still spends one full verification against a fixed dummy hash and returns /// `false`, so response time does not reveal whether the login exists. pub fn verify_password(password: &str, stored: Option<&str>) -> bool { - static DUMMY: OnceLock = OnceLock::new(); // An empty or unparsable stored hash (an organization row, a damaged row) // takes the same dummy-verification path as a missing account, so it costs // the same time and cannot be told apart. diff --git a/crates/crypto/src/lib.rs b/crates/crypto/src/lib.rs index bb104d74..fbe9df6f 100644 --- a/crates/crypto/src/lib.rs +++ b/crates/crypto/src/lib.rs @@ -17,7 +17,7 @@ pub mod private; pub mod provider; pub mod signing; -pub use credentials::{ct_eq, generate_token, hash_password, hash_token, verify_password}; +pub use credentials::{ct_eq, generate_token, hash_password, hash_token, verify_password, warm_up}; pub use envelope::{ decrypt_path, encrypt_path, encrypt_path_randomized, encrypt_path_randomized_with_rng, open, revoke, rewrap_for, seal, unwrap_dek_with, wrap_dek_for, diff --git a/crates/forge/conformance/run.py b/crates/forge/conformance/run.py index 9ee4f0ba..a778fc1b 100644 --- a/crates/forge/conformance/run.py +++ b/crates/forge/conformance/run.py @@ -962,7 +962,13 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None): if sid in pending: deferred, stop = pending[sid] if isinstance(pending[sid], list) else (pending[sid], None) ndeferred = max(0, len(c.expected) - deferred) - c.expected = c.expected[:deferred] + if not 0 <= deferred < len(c.expected): + # nothing is deferred: the entry is stale (the scenario is whole) and must not hide an error + stale.append(f"stale pending entry {deferred}: {fname} has {len(c.expected)} exchanges, " + f"so nothing is deferred; remove the entry") + deferred = None + else: + c.expected = c.expected[:deferred] t0 = time.time() err = None try: diff --git a/crates/forge/src/api.rs b/crates/forge/src/api.rs index e04e26b7..f7af1970 100644 --- a/crates/forge/src/api.rs +++ b/crates/forge/src/api.rs @@ -318,6 +318,7 @@ pub fn admin_create_user(ctx: &mut Ctx) -> Resp { }; let acct = NewAccount { login: name.clone(), + login_name: str_field(&b, "login_name").unwrap_or_default(), full_name: str_field(&b, "full_name").unwrap_or_default(), email, password_hash: hash, @@ -329,23 +330,7 @@ pub fn admin_create_user(ctx: &mut Ctx) -> Resp { ..Default::default() }; match store::create_account(ctx.conn, &acct, now()) { - Ok(u) => { - if let Some(ln) = str_field(&b, "login_name").filter(|s| !s.is_empty()) { - let _ = store::update_user( - ctx.conn, - u.id, - &UserPatch { - login_name: Some(ln), - ..Default::default() - }, - ); - } - match store::find_by_id(ctx.conn, u.id) { - Ok(Some(u)) => Resp::json(201, &user_json(&base, &u)), - Ok(None) => ctx.app.internal(&"created user vanished"), - Err(e) => ctx.app.internal(&e), - } - } + Ok(u) => Resp::json(201, &user_json(&base, &u)), Err(StoreError::Exists) => { error(&base, 422, &format!("user already exists [name: {name}]")) } diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs index 7c722d70..91a57598 100644 --- a/crates/forge/src/routes.rs +++ b/crates/forge/src/routes.rs @@ -23,6 +23,7 @@ pub struct App { impl App { pub fn new(data_dir: PathBuf, public_url: &str, proxies: Proxies, argon_slots: usize) -> App { + scl_crypto::warm_up(); App { data_dir, base: public_url.trim_end_matches('/').to_string(), diff --git a/crates/forge/src/store.rs b/crates/forge/src/store.rs index 78e44992..aabcba32 100644 --- a/crates/forge/src/store.rs +++ b/crates/forge/src/store.rs @@ -52,6 +52,7 @@ fn user_row(r: &Row) -> rusqlite::Result { pub struct NewAccount { pub login: String, pub is_org: bool, + pub login_name: String, pub full_name: String, pub email: String, pub description: String, @@ -82,13 +83,14 @@ fn is_unique(e: &rusqlite::Error) -> bool { /// Insert a user or organization. `Exists` when the lower-cased login is taken. pub fn create_account(conn: &Connection, a: &NewAccount, now: i64) -> Result { let r = conn.execute( - "INSERT INTO users (login, lower_login, kind, full_name, email, description, website, \ - location, password_hash, must_change_password, is_admin, visibility, created_unix) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + "INSERT INTO users (login, lower_login, kind, login_name, full_name, email, description, \ + website, location, password_hash, must_change_password, is_admin, visibility, created_unix) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ a.login, a.login.to_lowercase(), if a.is_org { "org" } else { "user" }, + a.login_name, a.full_name, a.email, a.description, diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs index 460f9012..698ee2b8 100644 --- a/crates/forge/tests/e2e_a.rs +++ b/crates/forge/tests/e2e_a.rs @@ -1106,3 +1106,13 @@ fn concurrent_patches_of_different_fields_both_land() { ); assert_eq!(u.description, "d299"); } + +#[test] +fn login_name_is_stored_by_the_create_itself() { + let s = Srv::start(); + let r = s.post("/api/v1/admin/users", &token(&s.admin), json!({"username": "lena", "email": "l@example.invalid", "password": "pw", "login_name": "lena-ldap"})); + assert_eq!( + (r.status, r.json()["login_name"].as_str()), + (201, Some("lena-ldap")) + ); +} From bc7f5b76c06b39b2678e87a3c95e828d8216b3c5 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:22:21 +0300 Subject: [PATCH 08/11] fix(forge): canonicalize IPv4-mapped peers in trusted-proxy CIDR matching --- crates/forge/src/limits.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index 5c89eaee..a25da035 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -186,6 +186,8 @@ impl Cidr { IpAddr::V6(a) => u128::from(a), } } + // A dual-stack socket reports an IPv4 peer as ::ffff:a.b.c.d. + let ip = ip.to_canonical(); if self.net.is_ipv4() != ip.is_ipv4() { return false; } @@ -309,6 +311,21 @@ mod tests { assert!(Cidr::parse("10.0.0.0/33").is_err()); } + #[test] + fn mapped_ipv4_peers_match_ipv4_cidrs() { + let c = Cidr::parse("10.0.0.0/8").unwrap(); + assert!(c.contains(ip("::ffff:10.0.0.5"))); + assert!(!c.contains(ip("::ffff:11.0.0.5"))); + let p = Proxies { + header: Some("x-forwarded-for".into()), + cidrs: vec![c], + }; + assert_eq!( + p.resolve(ip("::ffff:10.0.0.5"), Some("203.0.113.9")), + ip("203.0.113.9") + ); + } + #[test] fn forwarded_for_is_trusted_only_from_a_trusted_peer() { let p = Proxies { From 032066c7b5d75b10da5245191bcd9aaf8ab4e452 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:35:46 +0300 Subject: [PATCH 09/11] fix(forge): review fixes round 3 Document every public item, report a missing or wrong-version database as 503 on /healthz, percent-decode path parameters (token names with spaces), add sc-forge to the SBOM artifact and the third-party inventory, assert test data dirs are removed, and make the token-list test order-independent. --- .github/workflows/sbom.yml | 1 + crates/forge/src/api.rs | 13 ++++++++ crates/forge/src/cli.rs | 1 + crates/forge/src/http.rs | 5 +++ crates/forge/src/limits.rs | 4 +++ crates/forge/src/routes.rs | 38 ++++++++++++++++++++--- crates/forge/tests/common/mod.rs | 8 ++++- crates/forge/tests/e2e_a.rs | 42 +++++++++++++++++++++++++- docs/security/third-party-inventory.md | 3 +- 9 files changed, 108 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f0280623..51572cb3 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -49,6 +49,7 @@ jobs: run: | mkdir -p sbom-out cp crates/cli/sc_bin.cdx.json sbom-out/ + cp crates/forge/sc-forge_bin.cdx.json sbom-out/ cp apps/desktop/src-tauri/scl-desktop_bin.cdx.json sbom-out/ cp apps/desktop/desktop-frontend.cdx.json sbom-out/ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/crates/forge/src/api.rs b/crates/forge/src/api.rs index f7af1970..f35ccc5c 100644 --- a/crates/forge/src/api.rs +++ b/crates/forge/src/api.rs @@ -135,18 +135,22 @@ fn paged(ctx: &Ctx, path: &str, total: i64, page: i64, limit: i64, items: Vec Resp { Resp::json(200, &json!({"version": GITEA_VERSION})) } +/// Handler for routes whose policy is declared but whose slice has not landed: 501 after authentication, scope and actor checks. pub fn not_implemented(ctx: &mut Ctx) -> Resp { error(&ctx.app.base, 501, "not implemented yet") } +/// `GET /user`: the authenticated caller. pub fn current_user(ctx: &mut Ctx) -> Resp { Resp::json(200, &user_json(&ctx.app.base, &ctx.who.user)) } +/// `GET /users/{u}`: a user (or organization) by login; private users are visible to themselves and site admins only. pub fn get_user(ctx: &mut Ctx) -> Resp { let name = ctx.param("u").to_string(); match store::find_by_login(ctx.conn, &name) { @@ -169,6 +173,7 @@ fn org_visible(ctx: &Ctx, org: &User) -> rusqlite::Result { Ok(org.visibility != "private" || me.is_admin || store::is_org_owner(ctx.conn, org.id, me.id)?) } +/// `GET /orgs/{o}`: an organization, subject to its visibility. pub fn get_org(ctx: &mut Ctx) -> Resp { let name = ctx.param("o").to_string(); let org = match store::find_by_login(ctx.conn, &name) { @@ -183,6 +188,7 @@ pub fn get_org(ctx: &mut Ctx) -> Resp { } } +/// `GET /users/{u}/orgs/{o}/permissions`: what `{u}` may do in `{o}`; hides private organizations and users from callers who cannot see them. pub fn org_permissions(ctx: &mut Ctx) -> Resp { let (uname, oname) = (ctx.param("u").to_string(), ctx.param("o").to_string()); let subject = match store::find_by_login(ctx.conn, &uname) { @@ -234,6 +240,7 @@ fn valid_visibility(v: &str) -> bool { matches!(v, "public" | "limited" | "private") } +/// `POST /orgs`: create an organization owned by the caller. pub fn create_org(ctx: &mut Ctx) -> Resp { let b = match body(ctx) { Ok(b) => b, @@ -273,6 +280,7 @@ pub fn create_org(ctx: &mut Ctx) -> Resp { } } +/// `GET /admin/users`: every user (not organizations), paged. pub fn admin_list_users(ctx: &mut Ctx) -> Resp { let (page, limit) = page_params(ctx); match store::list_users(ctx.conn, page, limit) { @@ -284,6 +292,7 @@ pub fn admin_list_users(ctx: &mut Ctx) -> Resp { } } +/// `POST /admin/users`: create a user; `must_change_password` defaults to true as in Gitea, and `admin` is ignored (set it with PATCH). pub fn admin_create_user(ctx: &mut Ctx) -> Resp { let b = match body(ctx) { Ok(b) => b, @@ -338,6 +347,7 @@ pub fn admin_create_user(ctx: &mut Ctx) -> Resp { } } +/// `PATCH /admin/users/{u}`: update only the supplied fields of a user. pub fn admin_patch_user(ctx: &mut Ctx) -> Resp { let b = match body(ctx) { Ok(b) => b, @@ -405,6 +415,7 @@ fn token_owner(ctx: &Ctx) -> Result { } } +/// `GET /users/{u}/tokens`: the user's tokens, newest first, without their secrets. pub fn list_tokens(ctx: &mut Ctx) -> Resp { let owner = match token_owner(ctx) { Ok(o) => o, @@ -421,6 +432,7 @@ pub fn list_tokens(ctx: &mut Ctx) -> Resp { } } +/// `POST /users/{u}/tokens`: mint a token; the secret is returned once, in `sha1`. pub fn create_token(ctx: &mut Ctx) -> Resp { let owner = match token_owner(ctx) { Ok(o) => o, @@ -461,6 +473,7 @@ pub fn create_token(ctx: &mut Ctx) -> Resp { } } +/// `DELETE /users/{u}/tokens/{id}`: delete one of the user's tokens by id or name. pub fn delete_token(ctx: &mut Ctx) -> Resp { let owner = match token_owner(ctx) { Ok(o) => o, diff --git a/crates/forge/src/cli.rs b/crates/forge/src/cli.rs index 741ac31a..df74c6ff 100644 --- a/crates/forge/src/cli.rs +++ b/crates/forge/src/cli.rs @@ -124,6 +124,7 @@ enum OrgCmd { }, } +/// Parse the command line and run `serve` or an `admin` command. pub fn run() -> Result<()> { match Cli::parse().cmd { Cmd::Serve(a) => run_serve(a), diff --git a/crates/forge/src/http.rs b/crates/forge/src/http.rs index 1f41d70d..bb19c405 100644 --- a/crates/forge/src/http.rs +++ b/crates/forge/src/http.rs @@ -18,6 +18,7 @@ pub struct Req { } impl Req { + /// The request header `name` (lower case), if present. pub fn header(&self, name: &str) -> Option<&str> { self.headers.get(name).map(String::as_str) } @@ -46,6 +47,7 @@ pub struct Resp { const JSON_CT: &str = "application/json;charset=utf-8"; impl Resp { + /// A JSON response with Gitea's content type. pub fn json(status: u16, v: &Value) -> Resp { Resp { status, @@ -55,6 +57,7 @@ impl Resp { } } + /// An empty 204. pub fn no_content() -> Resp { Resp { status: 204, @@ -64,11 +67,13 @@ impl Resp { } } + /// Hold the response back by `d` before it is written. pub fn delayed(mut self, d: Duration) -> Resp { self.delay = d; self } + /// Add a response header. pub fn with(mut self, name: &'static str, value: String) -> Resp { self.headers.push((name, value)); self diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index a25da035..5534fca4 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -16,6 +16,7 @@ pub struct Gate { pub struct Permit<'a>(&'a Gate); impl Gate { + /// A gate with `slots` concurrent permits. pub fn new(slots: usize) -> Gate { Gate { free: Mutex::new(slots.max(1)), @@ -95,6 +96,7 @@ fn source_key(ip: IpAddr) -> IpAddr { } impl Backoff { + /// A policy allowing `source_cap` attempts per source per minute, with failure delays capped at `max_delay`. pub fn new(source_cap: usize, max_delay: Duration) -> Backoff { Backoff { source_cap, @@ -161,6 +163,7 @@ pub struct Cidr { } impl Cidr { + /// Parse `addr` or `addr/bits`. pub fn parse(s: &str) -> Result { let (addr, bits) = s.split_once('/').unwrap_or((s, "")); let net: IpAddr = addr @@ -179,6 +182,7 @@ impl Cidr { Ok(Cidr { net, bits }) } + /// Is `ip` inside this block? IPv4-mapped IPv6 addresses are matched as the IPv4 they are. pub fn contains(&self, ip: IpAddr) -> bool { fn v(ip: IpAddr) -> u128 { match ip { diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs index 91a57598..c51659f3 100644 --- a/crates/forge/src/routes.rs +++ b/crates/forge/src/routes.rs @@ -22,6 +22,7 @@ pub struct App { } impl App { + /// Build the shared state; also computes the dummy password hash so the first unknown login is not slower. pub fn new(data_dir: PathBuf, public_url: &str, proxies: Proxies, argon_slots: usize) -> App { scl_crypto::warm_up(); App { @@ -71,6 +72,7 @@ pub struct Ctx<'a> { } impl Ctx<'_> { + /// The captured path parameter `name` (percent-decoded), or empty if the route has none by that name. pub fn param(&self, name: &str) -> &str { self.params .iter() @@ -232,6 +234,24 @@ pub static ROUTES: &[Route] = &[ /// Captured path parameters, in pattern order. type Params = Vec<(&'static str, String)>; +/// Decode `%XX` escapes in a path segment; `None` when malformed or not UTF-8. +fn percent_decode(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(h, 16).ok()?); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + fn match_pattern(pattern: &'static str, path: &str) -> Option { let (mut ps, mut xs) = (pattern.split('/'), path.split('/')); let mut caps = Vec::new(); @@ -243,7 +263,9 @@ fn match_pattern(pattern: &'static str, path: &str) -> Option { if x.is_empty() { return None; } - caps.push((name, x.to_string())); + // Captures are percent-decoded (a token named `release bot` is + // addressed as `release%20bot`); undecodable input matches nothing. + caps.push((name, percent_decode(x)?)); } else if p != x { return None; } @@ -268,10 +290,18 @@ pub fn resolve(method: &str, path: &str) -> Result<(&'static Route, Params), boo Err(path_known) } +/// Ready only when the database file exists at this binary's schema version: a +/// deleted or replaced `forge.db` (which `connect` would recreate empty) is +/// reported as down rather than as a healthy server whose routes all fail. +fn database_ready(app: &App) -> bool { + db::db_path(&app.data_dir).exists() + && db::connect(&app.data_dir) + .and_then(|c| Ok(c.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))?)) + .is_ok_and(|v| v == db::SCHEMA_VERSION) +} + fn healthz(app: &App) -> Resp { - let ok = db::connect(&app.data_dir) - .and_then(|c| Ok(c.query_row("SELECT 1", [], |_| Ok(()))?)) - .is_ok(); + let ok = database_ready(app); let now = crate::timefmt::rfc3339(crate::timefmt::now()); let mut r = Resp::json( if ok { 200 } else { 503 }, diff --git a/crates/forge/tests/common/mod.rs b/crates/forge/tests/common/mod.rs index 5fa01682..d0281464 100644 --- a/crates/forge/tests/common/mod.rs +++ b/crates/forge/tests/common/mod.rs @@ -230,6 +230,12 @@ impl Drop for Srv { if let Some(h) = self.thread.take() { let _ = h.join(); } - let _ = std::fs::remove_dir_all(&self.dir); + std::fs::remove_dir_all(&self.dir).expect("test data dir must be removable"); + if !std::thread::panicking() { + assert!( + !self.dir.exists(), + "test data dir must be gone after teardown" + ); + } } } diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs index 698ee2b8..c623da50 100644 --- a/crates/forge/tests/e2e_a.rs +++ b/crates/forge/tests/e2e_a.rs @@ -235,7 +235,15 @@ fn token_lifecycle_with_the_users_password() { let l = s.get("/api/v1/users/alice/tokens", &basic_alice); assert_eq!((l.status, l.headers["x-total-count"].as_str()), (200, "3")); - assert_eq!(l.json()[0]["name"], "ci"); + let mut names: Vec = l + .json() + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect(); + names.sort(); // order is covered by token_lists_are_newest_first; same-second creations tie + assert_eq!(names, ["ci", "ord", "tmp"]); assert_eq!( l.json()[0]["sha1"], "", @@ -1116,3 +1124,35 @@ fn login_name_is_stored_by_the_create_itself() { (201, Some("lena-ldap")) ); } + +#[test] +fn healthz_reports_a_missing_database_and_token_names_may_need_escaping() { + let s = Srv::start(); + assert_eq!(s.send("GET", "/api/healthz", None, None).status, 200); + let pw = s.mk_user("alice"); + let basic_alice = basic("alice", &pw); + s.mk_token("alice", &pw, "release bot", &["read:user"]); + let del = s.send( + "DELETE", + "/api/v1/users/alice/tokens/release%20bot", + Some(&basic_alice), + None, + ); + assert_eq!( + del.status, 204, + "an escaped name is decoded before the lookup" + ); + assert_eq!( + s.get("/api/v1/users/al%69ce/tokens", &basic_alice).status, + 200, + "escaped path parameters are decoded" + ); + assert_eq!( + s.get("/api/v1/users/%zz/tokens", &token(&s.admin)).status, + 404, + "malformed escapes match no route" + ); + // A deleted database must not read as healthy (connect would silently recreate it empty). + std::fs::remove_file(scl_forge::db::db_path(&s.dir)).unwrap(); + assert_eq!(s.send("GET", "/api/healthz", None, None).status, 503); +} diff --git a/docs/security/third-party-inventory.md b/docs/security/third-party-inventory.md index d0922282..977bdf42 100644 --- a/docs/security/third-party-inventory.md +++ b/docs/security/third-party-inventory.md @@ -31,11 +31,12 @@ Architectural quarantine rules (which crate may use what) are in | `scl-core` | blake3, hex, thiserror, zstd, windows-sys (Windows only) | Content addressing, object model; `windows-sys` gives secret files an owner-only DACL (`winacl.rs`, `owner_only`, #214, #281) | | `scl-vfs` | thiserror | In-RAM worktrees | | `scl-gitio` | gix, anyhow, thiserror | Git interop (gix quarantined here) | -| `scl-crypto` | chacha20poly1305, x25519-dalek, ed25519-dalek, sha2, hkdf, rand_core, rand_chacha, zeroize, blake3, hex, thiserror | All cryptography (RustCrypto quarantined here) | +| `scl-crypto` | chacha20poly1305, x25519-dalek, ed25519-dalek, sha2, hkdf, argon2, rand_core, rand_chacha, zeroize, blake3, hex, thiserror | All cryptography (RustCrypto quarantined here) | | `scl-repo` | blake3, hex, libc, regex, serde, thiserror, toml | Persistent store, merge, transports, policy | | `scl-tlsio` | rustls, rustls-native-certs, rcgen, ring, rustls-pki-types, hex, thiserror | TLS (quarantined leaf); `rustls-native-certs` loads the platform roots for `SC_HTTPS_CA=system` (ADR-0054) | | `scl-objio` | aws-config, aws-sdk-s3, tokio, blake3, hex, thiserror | Object-store buckets for `sc+wal://` / `sc+s3://` (quarantined leaf; S3 SDK and its async runtime live only here, ADR-0046) | | `scl-cli` | clap, anyhow, serde, serde_json, toml, hex | The `sc` binary | +| `scl-forge` | hyper, hyper-util, http-body-util, tokio, rusqlite (`bundled`: compiles SQLite from C, the workspace's first C dependency), serde_json, clap, anyhow, thiserror | `sc-forge` Git forge (P38, unix-only): HTTP, SQLite and process supervision are confined here; password hashing lives in `scl-crypto` | | `scl-desktop` | tauri, tauri-build, tauri-plugin-dialog, serde, serde_json, toml | Desktop backend (read-only adapter) | Transitive tree: ~`Cargo.lock` (includes the Tauri/GTK3 Linux stack, source of From d542002809439bca0917834536135ced9ff22c13 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:37:08 +0300 Subject: [PATCH 10/11] fix(crypto,forge): no literal passwords in the dummy hash or tests The dummy hash for absent accounts is now computed from a random value, and the tests that flowed fixed strings into hash_password/user_create use generated ones, which is what CodeQL's hard-coded-cryptographic-value rule flagged. --- crates/crypto/src/credentials.rs | 33 +++++++++++++++++++++++--------- crates/forge/tests/common/mod.rs | 2 +- crates/forge/tests/e2e_a.rs | 33 ++++++++++++++++---------------- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/crates/crypto/src/credentials.rs b/crates/crypto/src/credentials.rs index 8da309f3..735353be 100644 --- a/crates/crypto/src/credentials.rs +++ b/crates/crypto/src/credentials.rs @@ -24,7 +24,7 @@ static DUMMY: OnceLock = OnceLock::new(); /// attempt does not pay for hashing on top of verifying, which would make it /// measurably slower than a wrong password for a real account. pub fn warm_up() { - DUMMY.get_or_init(|| hash_password("sc-forge-dummy")); + DUMMY.get_or_init(|| hash_password(&crate::random_hex(16))); } /// Verify `password` against a PHC `stored` hash. With `None` (no such account) @@ -39,7 +39,7 @@ pub fn verify_password(password: &str, stored: Option<&str>) -> bool { let (parsed, real_account) = match real { Some(p) => (p, true), None => { - dummy = DUMMY.get_or_init(|| hash_password("sc-forge-dummy")); + dummy = DUMMY.get_or_init(|| hash_password(&crate::random_hex(16))); ( PasswordHash::new(dummy).expect("our own hash parses"), false, @@ -81,24 +81,39 @@ pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; + fn pw() -> String { + crate::random_hex(12) + } + #[test] fn password_round_trip_and_rejection() { - let h = hash_password("correct horse"); + let (right, wrong) = (pw(), pw()); + let h = hash_password(&right); assert!(h.starts_with("$argon2id$")); - assert!(verify_password("correct horse", Some(&h))); - assert!(!verify_password("correct horsf", Some(&h))); - assert!(!verify_password("", Some(&h))); + assert!(verify_password(&right, Some(&h))); + assert!(!verify_password(&wrong, Some(&h))); + assert!( + !verify_password(&right[..right.len() - 1], Some(&h)), + "a prefix is not the password" + ); + assert!( + !verify_password(&right[..0], Some(&h)), + "empty is not the password" + ); } #[test] fn same_password_hashes_differently() { - assert_ne!(hash_password("pw"), hash_password("pw")); + let p = pw(); + assert_ne!(hash_password(&p), hash_password(&p)); } #[test] fn absent_account_and_garbage_hash_never_verify() { - assert!(!verify_password("anything", None)); - assert!(!verify_password("anything", Some("not-a-phc-string"))); + let p = pw(); + assert!(!verify_password(&p, None)); + assert!(!verify_password(&p, Some("not-a-phc-string"))); + assert!(!verify_password(&p, Some(""))); } #[test] diff --git a/crates/forge/tests/common/mod.rs b/crates/forge/tests/common/mod.rs index d0281464..d9b401ae 100644 --- a/crates/forge/tests/common/mod.rs +++ b/crates/forge/tests/common/mod.rs @@ -93,7 +93,7 @@ impl Srv { admin::user_create( &dir, "siteadmin", - "admin-pw-1", + &scl_crypto::generate_token(), "admin@example.invalid", true, false, diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs index c623da50..04d57c0d 100644 --- a/crates/forge/tests/e2e_a.rs +++ b/crates/forge/tests/e2e_a.rs @@ -625,23 +625,17 @@ fn must_change_password_blocks_password_authentication() { #[test] fn admin_cli_changes_are_visible_to_the_running_server() { let s = Srv::start(); - scl_forge::admin::user_create( - &s.dir, - "carol", - "carol-pw-1", - "c@example.invalid", - false, - false, - ) - .unwrap(); + let (pw1, pw2) = (scl_crypto::generate_token(), scl_crypto::generate_token()); + scl_forge::admin::user_create(&s.dir, "carol", &pw1, "c@example.invalid", false, false) + .unwrap(); let t = scl_forge::admin::generate_access_token(&s.dir, "carol", "cli", &["read:user".into()]) .unwrap(); assert_eq!(s.get("/api/v1/user", &token(&t)).json()["login"], "carol"); - scl_forge::admin::user_change_password(&s.dir, "carol", "carol-pw-2", false).unwrap(); + scl_forge::admin::user_change_password(&s.dir, "carol", &pw2, false).unwrap(); assert_eq!( s.post( "/api/v1/users/carol/tokens", - &basic("carol", "carol-pw-1"), + &basic("carol", &pw1), json!({"name": "o", "scopes": ["all"]}) ) .status, @@ -650,7 +644,7 @@ fn admin_cli_changes_are_visible_to_the_running_server() { assert_eq!( s.post( "/api/v1/users/carol/tokens", - &basic("carol", "carol-pw-2"), + &basic("carol", &pw2), json!({"name": "n", "scopes": ["all"]}) ) .status, @@ -658,10 +652,15 @@ fn admin_cli_changes_are_visible_to_the_running_server() { ); scl_forge::admin::org_create(&s.dir, "cliorg", "carol").unwrap(); assert_eq!(s.get("/api/v1/orgs/cliorg", &token(&s.admin)).status, 200); - assert!( - scl_forge::admin::user_create(&s.dir, "carol", "x", "c@example.invalid", false, false) - .is_err() - ); + assert!(scl_forge::admin::user_create( + &s.dir, + "carol", + &pw1, + "c@example.invalid", + false, + false + ) + .is_err()); assert!( scl_forge::admin::generate_access_token(&s.dir, "carol", "cli", &["all".into()]).is_err(), "duplicate token name" @@ -1034,7 +1033,7 @@ fn patching_one_field_never_rewrites_another() { .unwrap() .unwrap(); // A stale snapshot of alice, then an admin grant and a password reset land... - let hash = scl_crypto::hash_password("fresh"); + let hash = scl_crypto::hash_password(&scl_crypto::generate_token()); scl_forge::store::update_user( &conn, alice.id, From a290588bdfb7368e691ae3b8536cde25b7e267a3 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Fri, 23 Oct 2026 09:49:24 +0300 Subject: [PATCH 11/11] fix(forge): review fixes round 4 Readiness checks the tables as well as the schema version, --max-connections 0 and an unusable --public-url are refused at startup, a database error returns the source's password-attempt reservation, and runtime shutdown no longer waits for queued Argon2 work past the drain deadline. --- crates/forge/src/auth.rs | 5 ++++- crates/forge/src/cli.rs | 12 ++++++++-- crates/forge/src/limits.rs | 23 +++++++++++++++++++ crates/forge/src/routes.rs | 20 +++++++++++++---- crates/forge/src/server.rs | 45 +++++++++++++++++++++++++++++++++++-- crates/forge/tests/e2e_a.rs | 42 ++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 9 deletions(-) diff --git a/crates/forge/src/auth.rs b/crates/forge/src/auth.rs index efede736..2ea56dd1 100644 --- a/crates/forge/src/auth.rs +++ b/crates/forge/src/auth.rs @@ -149,7 +149,10 @@ pub fn by_password(app: &App, conn: &Connection, req: &Req, cred: &Cred) -> Resu } let found = match store::find_by_login(conn, login) { Ok(u) => u.filter(|u| !u.is_org), - Err(e) => return Err(app.internal(&e)), + Err(e) => { + app.backoff.cancel(req.source); + return Err(app.internal(&e)); + } }; let ok = { let _slot = app.gate.acquire(); diff --git a/crates/forge/src/cli.rs b/crates/forge/src/cli.rs index df74c6ff..96a15037 100644 --- a/crates/forge/src/cli.rs +++ b/crates/forge/src/cli.rs @@ -200,6 +200,10 @@ fn run_admin(cmd: AdminCmd) -> Result<()> { } fn run_serve(a: ServeArgs) -> Result<()> { + scl_forge::server::validate_public_url(&a.public_url).map_err(anyhow::Error::msg)?; + if a.max_connections == 0 { + bail!("--max-connections must be at least 1"); + } bind_gate(&a.listen, a.behind_proxy, a.allow_plaintext).map_err(anyhow::Error::msg)?; let cidrs = a .trusted_proxies @@ -230,7 +234,7 @@ fn run_serve(a: ServeArgs) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - rt.block_on(async { + let result: Result<()> = rt.block_on(async { let listener = tokio::net::TcpListener::bind(a.listen) .await .with_context(|| format!("bind {}", a.listen))?; @@ -245,5 +249,9 @@ fn run_serve(a: ServeArgs) -> Result<()> { }; serve(listener, app, limits, stop, Duration::from_secs(30)).await; Ok(()) - }) + }); + // Queued password verifications run on blocking threads that the drain deadline + // cannot cancel; do not let dropping the runtime wait for them. + rt.shutdown_timeout(Duration::from_secs(2)); + result } diff --git a/crates/forge/src/limits.rs b/crates/forge/src/limits.rs index 5534fca4..8368cb6d 100644 --- a/crates/forge/src/limits.rs +++ b/crates/forge/src/limits.rs @@ -136,6 +136,20 @@ impl Backoff { Duration::from_millis(50u64.saturating_mul(1u64 << (n - 1).min(16))).min(self.max_delay) } + /// Give back a reservation for an attempt that failed on our side (a database + /// error), which says nothing about the credential and must not count against + /// the source. + pub fn cancel(&self, ip: IpAddr) { + if let Some(v) = self + .by_source + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_mut(&source_key(ip)) + { + v.pop(); + } + } + /// A success clears the login's failures and returns the source's reservation. pub fn success(&self, login: &str, ip: IpAddr) { let ip = source_key(ip); @@ -265,6 +279,15 @@ mod tests { assert!(b.begin(ip("9.9.9.9"))); } + #[test] + fn a_cancelled_reservation_is_returned() { + let b = Backoff::new(1, Duration::ZERO); + assert!(b.begin(ip("7.7.7.7"))); + assert!(!b.begin(ip("7.7.7.7"))); + b.cancel(ip("7.7.7.7")); + assert!(b.begin(ip("7.7.7.7"))); + } + #[test] fn one_ipv6_64_shares_a_budget() { let b = Backoff::new(2, Duration::ZERO); diff --git a/crates/forge/src/routes.rs b/crates/forge/src/routes.rs index c51659f3..0db4079f 100644 --- a/crates/forge/src/routes.rs +++ b/crates/forge/src/routes.rs @@ -294,10 +294,22 @@ pub fn resolve(method: &str, path: &str) -> Result<(&'static Route, Params), boo /// deleted or replaced `forge.db` (which `connect` would recreate empty) is /// reported as down rather than as a healthy server whose routes all fail. fn database_ready(app: &App) -> bool { - db::db_path(&app.data_dir).exists() - && db::connect(&app.data_dir) - .and_then(|c| Ok(c.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))?)) - .is_ok_and(|v| v == db::SCHEMA_VERSION) + if !db::db_path(&app.data_dir).exists() { + return false; + } + let Ok(conn) = db::connect(&app.data_dir) else { + return false; + }; + let version = conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0)); + // The version alone can be forged by a replacement file; the tables must answer too. + version.is_ok_and(|v| v == db::SCHEMA_VERSION) + && ["users", "org_members", "tokens"].iter().all(|t| { + conn.query_row(&format!("SELECT 1 FROM {t} LIMIT 1"), [], |_| Ok(())) + .map_or_else( + |e| matches!(e, rusqlite::Error::QueryReturnedNoRows), + |()| true, + ) + }) } fn healthz(app: &App) -> Resp { diff --git a/crates/forge/src/server.rs b/crates/forge/src/server.rs index 3d7b37c5..efb4031c 100644 --- a/crates/forge/src/server.rs +++ b/crates/forge/src/server.rs @@ -127,8 +127,28 @@ fn to_response(r: Resp) -> Response> { for (k, v) in r.headers { b = b.header(k, v); } - b.body(Full::new(Bytes::from(r.body))) - .expect("static header names are valid") + // A header value hyper refuses must not panic the connection task: answer 500. + b.body(Full::new(Bytes::from(r.body))).unwrap_or_else(|_| { + let mut e = Response::new(Full::new(Bytes::from_static( + b"{\"message\":\"internal server error\"}", + ))); + *e.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR; + e + }) +} + +/// Check `--public-url` at startup: an `http(s)://` URL of visible ASCII only, so +/// it can be placed in `Link` headers and bodies without surprises. +pub fn validate_public_url(url: &str) -> Result<(), String> { + let rest = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")); + match rest { + Some(r) if !r.is_empty() && r.bytes().all(|b| b.is_ascii_graphic()) => Ok(()), + _ => Err(format!( + "--public-url {url:?} must be an http:// or https:// URL of visible ASCII characters (percent-encode anything else)" + )), + } } /// Serve until `shutdown` resolves, then wait up to `drain` for in-flight @@ -177,3 +197,24 @@ pub fn make_app( ) -> Arc { Arc::new(App::new(data_dir, public_url, proxies, argon_slots)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_url_must_be_header_safe() { + assert!(validate_public_url("http://127.0.0.1:3000").is_ok()); + assert!(validate_public_url("https://git.example.com/forge").is_ok()); + for bad in [ + "", + "ftp://x", + "http://", + "http://ex ample", + "http://exämple.com", + "http://x\r\nSet-Cookie: a", + ] { + assert!(validate_public_url(bad).is_err(), "{bad:?}"); + } + } +} diff --git a/crates/forge/tests/e2e_a.rs b/crates/forge/tests/e2e_a.rs index 04d57c0d..619fe74c 100644 --- a/crates/forge/tests/e2e_a.rs +++ b/crates/forge/tests/e2e_a.rs @@ -1155,3 +1155,45 @@ fn healthz_reports_a_missing_database_and_token_names_may_need_escaping() { std::fs::remove_file(scl_forge::db::db_path(&s.dir)).unwrap(); assert_eq!(s.send("GET", "/api/healthz", None, None).status, 503); } + +#[test] +fn a_database_with_the_right_version_but_no_tables_is_not_ready() { + let s = Srv::start(); + assert_eq!(s.send("GET", "/api/healthz", None, None).status, 200); + std::fs::remove_file(scl_forge::db::db_path(&s.dir)).unwrap(); + let c = scl_forge::db::connect(&s.dir).unwrap(); + c.pragma_update(None, "user_version", scl_forge::db::SCHEMA_VERSION) + .unwrap(); + drop(c); + assert_eq!( + s.send("GET", "/api/healthz", None, None).status, + 503, + "version alone does not make a database ready" + ); +} + +#[test] +fn serve_refuses_unusable_settings_at_startup() { + let dir = std::env::temp_dir().join(format!("scl-forge-serve-args-{}", std::process::id())); + let bin = env!("CARGO_BIN_EXE_sc-forge"); + let run = |extra: &[&str]| { + std::process::Command::new(bin) + .args(["serve", "--listen", "127.0.0.1:0", "--data-dir"]) + .arg(&dir) + .args(extra) + .output() + .unwrap() + }; + let o = run(&[ + "--public-url", + "http://127.0.0.1:1", + "--max-connections", + "0", + ]); + assert!( + !o.status.success() && String::from_utf8_lossy(&o.stderr).contains("--max-connections") + ); + let o = run(&["--public-url", "http://exämple.com"]); + assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("--public-url")); + let _ = std::fs::remove_dir_all(&dir); +}