git-ents.gitmain
⌘K
foforge
commit 969cfab
roots: mount the hosted web UI and add git ents login

git ents serve --hosted (roots.single-node-hosted): the HostedRoot’s own seams behind ents-web with SignInRequired, the server’s enrolled member key as signing identity (boot fails closed with the enroll command when it is not a member), still loopback-only — nginx stays the sole external listener, routing *.git to git-http-backend and everything else to the web process; the bash entrypoint supervises both with wait -n. git ents login <url> <code> proves membership over ureq/rustls-ring (musl cross-build safe; ISC/BSD-3-Clause/CDLA licenses allowlisted), rebuilding the challenge payload locally so a server never chooses the signed bytes. members::find_by_key generalizes off LocalRoot so both roots share the one key-match loop. Fly suspends instead of stopping so memory-only sessions survive idle.

Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1467,6 +1467,7 @@ "pulldown-cmark", "rstest", "serde", + "ssh-key", "tempfile", "thiserror 2.0.18", "tokio", @@ -1917,6 +1918,7 @@ "thiserror 2.0.18", "tokio", "tower", + "ureq", ] [[package]] @@ -4100,6 +4102,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -4178,6 +4194,41 @@ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4981,6 +5032,40 @@ "rustc-hash", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4994,6 +5079,12 @@ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5101,6 +5192,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30261027ea4225cf7c8287aa70bd103c1e78508606c77caf24bb468326ce7b5f" +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5191,6 +5291,15 @@ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0"
Cargo.toml @@ -105,6 +105,10 @@ target-lexicon = "0.13" tempfile = "3" thiserror = "2" +# The CLI's one HTTP client (`git ents login`): synchronous, so no tokio +# runtime for two requests, and rustls with the ring provider, so the +# musl static release cross-build (`cargo zigbuild`) stays pure Rust. +ureq = { version = "3", default-features = false, features = ["rustls"] } tokio = { version = "1", features = [ "rt-multi-thread", "macros",
.config/deny.toml @@ -4,4 +4,10 @@ "MIT", "Apache-2.0", "Unicode-3.0", + # ureq's rustls/ring TLS stack (`git ents login`): ISC covers ring and + # untrusted; the others are the webpki verifier and Mozilla's root + # store. All permissive. + "ISC", + "BSD-3-Clause", + "CDLA-Permissive-2.0", ]
.config/fly.toml @@ -17,6 +17,9 @@ [env] PORT = '8080' + # The canonical host sign-in challenges bind to (`roots.web-signin`); + # the entrypoint passes it to `git ents serve --hosted --public-host`. + PUBLIC_HOST = 'git.ents.cloud' [mounts] source = 'git_ents_hosted' @@ -25,7 +28,10 @@ [http_service] internal_port = 8080 force_https = true - auto_stop_machines = 'stop' + # Suspend, not stop: web sessions and sign-in challenges are memory-only + # by spec (`roots.web-session`), and suspend preserves RAM across idle + # periods — a deploy or crash still drops them, which the spec accepts. + auto_stop_machines = 'suspend' auto_start_machines = true min_machines_running = 0 processes = ['app']
docker/entrypoint.sh @@ -1,10 +1,18 @@ -#!/bin/sh +#!/bin/bash # Bootstraps the single-node hosted root (`roots.single-node-hosted`) on -# first boot, then serves it over stock git's smart-HTTP transport. +# first boot, then serves it two ways behind one nginx: stock git's +# smart-HTTP transport on the *.git paths, and the hosted web UI +# (`git ents serve --hosted`) on everything else. +# +# bash, not sh: `wait -n` below is the whole process supervisor — first +# long-running process to exit takes the machine down nonzero, and Fly +# restarts it. Crude but honest for a single-node root; runit is the +# upgrade path if either process starts crash-looping. set -eu repo=/data/repo.git key=/data/hosted_signing_key +public_host="${PUBLIC_HOST:-git.ents.cloud}" if [ ! -d "$repo" ]; then git init --quiet --bare "$repo" @@ -19,4 +27,15 @@ mkdir -p /run spawn-fcgi -s /run/fcgiwrap.sock -M 766 -- /usr/sbin/fcgiwrap -exec nginx -c /etc/git-ents/nginx.conf -g "daemon off;" + +# The web UI refuses to boot until $key's public half is enrolled as a +# member (`roots.web-signing`) — on a fresh volume, enroll it before the +# first deploy that ships this entrypoint, or the machine crash-loops +# with the exact command to run in its logs. +git-ents serve --hosted --key "$key" --public-host "$public_host" --port 4880 "$repo" & +nginx -c /etc/git-ents/nginx.conf -g "daemon off;" & + +wait -n +code=$? +kill 0 2>/dev/null || true +exit "$code"
docker/nginx.conf @@ -12,7 +12,10 @@ server { listen 8080; - location / { + # git smart-HTTP: any *.git path goes to stock git's own + # http-backend (`roots.single-node-hosted`: stock git stays the + # one git transport; the web process adds none of its own). + location ~ ^/[^/]+\.git(/.*)?$ { # git push can be large; never buffer it into a temp file. client_max_body_size 0; gzip off; @@ -29,5 +32,15 @@ # minimal environment with no PATH at all. fastcgi_param PATH "/usr/local/bin:/usr/bin:/bin"; } + + # Everything else: the hosted web UI (`git ents serve --hosted`), + # loopback-only inside this machine — nginx is the sole external + # listener. + location / { + proxy_pass http://127.0.0.1:4880; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + } } }
crates/cli/git-ents/Cargo.toml @@ -35,6 +35,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +ureq = { workspace = true } [dev-dependencies] axum = { workspace = true }
crates/cli/git-ents/src/cli.rs @@ -129,13 +129,31 @@ #[facet(args::subcommand)] action: HookAction, }, + /// Prove membership to a hosted web session (`roots.web-signin`): + /// fetch the one-time challenge the hosted `/login` page displayed, + /// sign it with your member key under the `git-ents-login` SSHSIG + /// namespace — locally, the key never leaves this machine — and post + /// the signature back, signing that browser session in. + Login { + /// The hosted root's base URL, e.g. `https://git.ents.cloud`. + #[facet(args::positional)] + url: String, + /// The one-time code the `/login` page displays (`XXXX-XXXX`). + #[facet(args::positional)] + code: String, + /// Key to prove membership with; defaults to `user.signingkey`, + /// else `~/.ssh/id_ed25519`. + #[facet(args::named)] + key: Option<PathBuf>, + }, /// Start the local web UI (`roots.local`): reuses this repository's /// existing local composition root (the same loose-ref `RefStore`, /// odb, null `EventSink`, and advisory gate `git ents members`, /// `git ents comment`, and every other porcelain command already use) /// and adds only the `ents-web` HTTP frontend, bound to loopback — /// never git's own smart-HTTP transport, which this command does not - /// expose in any form. + /// expose in any form. With `--hosted`, serves the single-node hosted + /// root's web UI instead (`roots.single-node-hosted`). Serve { /// Port to bind on loopback (`127.0.0.1`); `0` picks any free /// port. Defaults to 4880. @@ -144,6 +162,22 @@ /// Key to sign web edits with; defaults to `user.signingkey`. #[facet(args::named)] key: Option<PathBuf>, + /// Serve the single-node hosted root's web UI instead + /// (`roots.single-node-hosted`): mandatory gate, sign-in + /// required, member-attributed edits, the server's own key as + /// signing identity. Still binds loopback — the front proxy is + /// the only external listener. + #[facet(args::named, default)] + hosted: bool, + /// The canonical public host bound into sign-in challenges with + /// `--hosted` (`roots.web-signin`), e.g. `git.ents.cloud`. + /// Required with `--hosted`; ignored without it. + #[facet(args::named)] + public_host: Option<String>, + /// The bare repository to serve with `--hosted`; defaults to the + /// current directory. Ignored without `--hosted`. + #[facet(args::positional, default)] + path: Option<PathBuf>, }, /// Serve the editor lens (`lens.serve`): a Language Server Protocol /// server over stdin/stdout that projects this repository's comments
crates/cli/git-ents/src/exe.rs @@ -54,10 +54,29 @@ Top::Inbox { action } => run_inbox(action, out), Top::Redact { action } => run_redact(action, out), Top::Hook { action } => run_hook(action, out), - Top::Serve { port, key } => { + Top::Login { url, code, key } => commands::login::run(&url, &code, key, out), + Top::Serve { + port, + key, + hosted: false, + .. + } => { let root = LocalRoot::discover(".")?; commands::serve::run(root, port, key, out) } + // Root choice off the flag belongs exactly here, in the + // composition root's dispatch (`arch.no-hosted-branch` bans it in + // library code, not in `exe`). + Top::Serve { + port, + key, + hosted: true, + public_host, + path, + } => { + let root = HostedRoot::open(path.unwrap_or_else(|| ".".into()))?; + commands::serve::run_hosted(root, port, key, public_host, out) + } Top::Lsp { key } => { // The lens speaks LSP over stdin/stdout, so nothing may be // written to `out` (the process's stdout) here — that stream is @@ -73,7 +92,7 @@ let root = LocalRoot::discover(".")?; match action { MembersAction::List => { - for (username, member) in commands::members::list(&root)? { + for (username, member) in commands::members::list(&root.refs, &root.objects)? { let _ = writeln!( out, "{username}\t{:?}\t{:?}",
crates/cli/git-ents/src/sign.rs @@ -99,13 +99,27 @@ /// payload cannot fail for the algorithms this module accepts. #[must_use] pub fn sign(&self, payload: &[u8]) -> String { + self.sign_in_namespace(GIT_SIGN_NAMESPACE, payload) + } + + /// Sign `payload` under an explicit SSHSIG `namespace` — what + /// `git ents login` uses with `ents_web::auth::LOGIN_NAMESPACE` + /// (`roots.web-signin`): a sign-in signature deliberately lives in a + /// namespace distinct from [`Self::sign`]'s `git`, so neither can + /// ever double as the other. + /// + /// # Panics + /// + /// Never for a well-formed loaded key; see [`Self::sign`]. + #[must_use] + pub fn sign_in_namespace(&self, namespace: &str, payload: &[u8]) -> String { #[expect( clippy::expect_used, reason = "signing and PEM-rendering an ed25519 signature over any byte payload is \ infallible; mirrors `ents_testutil::Keypair::sign`'s identical, unguarded call" )] self.private - .sign(GIT_SIGN_NAMESPACE, HashAlg::Sha512, payload) + .sign(namespace, HashAlg::Sha512, payload) .expect("signing is infallible for a loaded, unencrypted key") .to_pem(LineEnding::LF) .expect("an SSHSIG always renders as PEM")
crates/cli/git-ents/tests/hosted_root.rs @@ -167,6 +167,7 @@ offset: 0, }, }, + author: None, sign: &|payload| admin_signer.sign(payload), }; let config_ref: gix::refs::FullName = @@ -221,3 +222,49 @@ "a refused pre-receive push must leave no trace on the hosted root" ); } + +/// `serve --hosted` fails closed on an unenrolled server key +/// (`roots.web-signing`: the signing key must itself be an enrolled +/// member) and boots once the key is enrolled, with the enrolled +/// username as the serving identity's label. +// @relation(roots.web-signing, roots.single-node-hosted, scope=function, role=Verifies) +#[test] +fn hosted_serve_boots_only_with_an_enrolled_server_key() { + use ssh_key::private::{Ed25519Keypair, KeypairData}; + + let dir = tempfile::tempdir().expect("tempdir"); + let bare = dir.path().join("repo.git"); + let output = Command::new("git") + .args(["init", "--bare"]) + .arg(&bare) + .output() + .expect("git runs"); + assert!(output.status.success(), "{output:?}"); + + let key_path = dir.path().join("hosted_signing_key"); + let pair = Ed25519Keypair::from_seed(&[42; 32]); + let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "server").expect("well-formed"); + key.write_openssh_file(&key_path, ssh_key::LineEnding::LF) + .expect("writes"); + + let root = git_ents::root::HostedRoot::open(&bare).expect("opens"); + let refused = git_ents::commands::serve::build_hosted_state( + root, + key_path.clone(), + "ents.test".to_owned(), + ); + assert!( + refused.is_err(), + "an unenrolled server key must refuse to boot" + ); + + let local = LocalRoot::open(&bare).expect("opens"); + git_ents::commands::members::add(&local, "server", None, Some(key_path.clone())) + .expect("enrolls the server key"); + + let root = git_ents::root::HostedRoot::open(&bare).expect("opens"); + let state = + git_ents::commands::serve::build_hosted_state(root, key_path, "ents.test".to_owned()) + .expect("boots once enrolled"); + assert_eq!(state.identity.label(), "server"); +}
crates/cli/git-ents/tests/members.rs @@ -31,7 +31,7 @@ members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap admits it"); - let listed = members::list(&root).expect("lists"); + let listed = members::list(&root.refs, &root.objects).expect("lists"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].0, "jdc"); assert_eq!(listed[0].1.state, MemberState::Active); @@ -54,7 +54,7 @@ ) .expect("admin-registered add"); - let listed = members::list(&root).expect("lists"); + let listed = members::list(&root.refs, &root.objects).expect("lists"); assert_eq!(listed.len(), 2); assert!(listed.iter().any(|(name, _)| name == "bob")); } @@ -96,6 +96,6 @@ members::remove(&root, "jdc", Some(fixture.key_path.clone())).expect("removes"); - let listed = members::list(&root).expect("lists"); + let listed = members::list(&root.refs, &root.objects).expect("lists"); assert!(listed.is_empty()); }
crates/kernel/ents-gate-rules/tests/prop.rs @@ -55,7 +55,10 @@ /// actually holds for this sample. fn build(ref_name: &str, signer: Option<&str>, kind: Kind) -> (Facts, bool) { let mut facts = Facts { - member: vec![(ADMIN_KEY.to_string(), Role::Admin), (MEMBER_KEY.to_string(), Role::Member)], + member: vec![ + (ADMIN_KEY.to_string(), Role::Admin), + (MEMBER_KEY.to_string(), Role::Member), + ], ..Facts::default() }; facts.ref_update = vec![(ref_name.to_string(), None, "g2".to_string())];
crates/verify/ents-verify/src/durability.rs @@ -14,7 +14,10 @@ //! invariant under study is unchanged: no ref points outside the //! durable object set. -#![expect(clippy::todo, reason = "Phase 5 skeleton — filling this in is the human exercise, not this scaffold's job")] +#![expect( + clippy::todo, + reason = "Phase 5 skeleton — filling this in is the human exercise, not this scaffold's job" +)] use stateright::{Model, Property};
crates/verify/ents-verify/src/effects.rs @@ -13,7 +13,10 @@ //! cross-transaction dedup obligation as a deliberate gap — this model //! is where that obligation lives. -#![expect(clippy::todo, reason = "Phase 4 skeleton — filling this in is the human exercise, not this scaffold's job")] +#![expect( + clippy::todo, + reason = "Phase 4 skeleton — filling this in is the human exercise, not this scaffold's job" +)] use stateright::{Model, Property}; @@ -68,7 +71,9 @@ } fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) { - todo!("exercise: enumerate RefAdvance/TriggerEval/Enqueue/Execute/ResultPush per verify/exercise.md Phase 4") + todo!( + "exercise: enumerate RefAdvance/TriggerEval/Enqueue/Execute/ResultPush per verify/exercise.md Phase 4" + ) } fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> { @@ -80,16 +85,25 @@ // Obligation 1: the dedup key (effect, refname, new_oid) // yields result-ref idempotency under duplicate delivery // and executor crash-restart. - Property::always("exactly_once_observable_effect", |_, _| true /* TODO(exercise) */), + Property::always( + "exactly_once_observable_effect", + |_, _| true, /* TODO(exercise) */ + ), // Obligation 2: a ref deleted and re-pushed to the same oid // — does the commit re-enter the trigger set? Defines // whether triggers are monotone. - Property::always("trigger_set_monotone", |_, _| true /* TODO(exercise) */), + Property::always( + "trigger_set_monotone", + |_, _| true, /* TODO(exercise) */ + ), // Obligation 3: no sequence lets a non-admin cause execution // of content they authored as an effect (composes with // crate::search's binding_refname_recomputed property — // this was the original cross-ref replay scenario). - Property::always("authorization_asymmetry", |_, _| true /* TODO(exercise) */), + Property::always( + "authorization_asymmetry", + |_, _| true, /* TODO(exercise) */ + ), ] } }
crates/verify/ents-verify/src/lib.rs @@ -124,5 +124,8 @@ /// background, not a search dimension — Phase 3's `receive` skeleton is /// where membership *lifecycle* (enrollment, revocation) belongs. pub fn enroll_all(facts: &mut Facts) { - facts.member = KEYS.iter().map(|k| ((*k).to_string(), role_of(k))).collect(); + facts.member = KEYS + .iter() + .map(|k| ((*k).to_string(), role_of(k))) + .collect(); }
crates/verify/ents-verify/src/receive.rs @@ -13,7 +13,10 @@ //! invariant, adoption, revocation), §4 (anti-replay); //! `docs/spec/receive.adoc`; `docs/spec/gate.adoc` epoch bootstrap. -#![expect(clippy::todo, reason = "Phase 3 skeleton — filling this in is the human exercise, not this scaffold's job")] +#![expect( + clippy::todo, + reason = "Phase 3 skeleton — filling this in is the human exercise, not this scaffold's job" +)] use ents_gate_rules::{Facts, gate}; use stateright::{Model, Property}; @@ -85,11 +88,15 @@ } fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) { - todo!("exercise: enumerate Propose/GateCheck/Cas/AdoptMerge/SelfMerge per verify/exercise.md Phase 3") + todo!( + "exercise: enumerate Propose/GateCheck/Cas/AdoptMerge/SelfMerge per verify/exercise.md Phase 3" + ) } fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> { - todo!("exercise: Phase 3's transition relation, using gate_admits as GateCheck's enabling condition") + todo!( + "exercise: Phase 3's transition relation, using gate_admits as GateCheck's enabling condition" + ) } fn properties(&self) -> Vec<Property<Self>> { @@ -99,11 +106,17 @@ // action — pay attention to SelfMerge (is the merge commit // itself signed in the implementation? see ents-sync/src // and ents-receive/src/reconcile.rs). - Property::always("tip_invariant_inductive", |_, _| true /* TODO(exercise) */), + Property::always( + "tip_invariant_inductive", + |_, _| true, /* TODO(exercise) */ + ), // Obligation 2: adoption preserves the tip invariant, even // when the contributor's commit is itself a merge of // unauthorized commits. - Property::always("adoption_preserves_tip_invariant", |_, _| true /* TODO(exercise) */), + Property::always( + "adoption_preserves_tip_invariant", + |_, _| true, /* TODO(exercise) */ + ), // Obligation 3: a replayed genesis against a not-yet-created // ref is safe only in conjunction with Phase 2's binding // totality — state the exact conjunction.
crates/verify/ents-verify/src/search.rs @@ -61,7 +61,12 @@ } /// All four transaction shapes, in a fixed enumeration order. -pub const SHAPES: [Shape; 4] = [Shape::Genesis, Shape::FastForward, Shape::NonFf, Shape::SecondRoot]; +pub const SHAPES: [Shape; 4] = [ + Shape::Genesis, + Shape::FastForward, + Shape::NonFf, + Shape::SecondRoot, +]; /// Who signs every commit the transaction introduces — one signer /// applies uniformly to keep the state small; per-commit signer @@ -95,8 +100,12 @@ } /// All four retention choices, in a fixed enumeration order. -pub const RETENTIONS: [Retention; 4] = - [Retention::Absent, Retention::Resolving, Retention::DanglingAnchor, Retention::DanglingContext]; +pub const RETENTIONS: [Retention; 4] = [ + Retention::Absent, + Retention::Resolving, + Retention::DanglingAnchor, + Retention::DanglingContext, +]; /// All three [`Kind`] choices, in a fixed enumeration order. pub const KINDS: [Kind; 3] = [Kind::Comment, Kind::Issue, Kind::Effect]; @@ -200,17 +209,29 @@ } } Shape::FastForward => { - f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "c1".to_string())]; + f.ref_update = vec![( + self.ref_name.to_string(), + Some("g".to_string()), + "c1".to_string(), + )]; f.parent = vec![("c1".to_string(), "g".to_string())]; sign(&mut f, "g"); sign(&mut f, "c1"); } Shape::NonFf => { - f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "x".to_string())]; + f.ref_update = vec![( + self.ref_name.to_string(), + Some("g".to_string()), + "x".to_string(), + )]; sign(&mut f, "x"); } Shape::SecondRoot => { - f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "m".to_string())]; + f.ref_update = vec![( + self.ref_name.to_string(), + Some("g".to_string()), + "m".to_string(), + )]; f.parent = vec![ ("c1".to_string(), "g".to_string()), ("m".to_string(), "c1".to_string()), @@ -258,7 +279,10 @@ if !matches!(self.shape, Shape::Genesis) { return true; } - !matches!(self.retention, Retention::DanglingAnchor | Retention::DanglingContext) + !matches!( + self.retention, + Retention::DanglingAnchor | Retention::DanglingContext + ) } /// abstractions.adoc §6 / effect.admin-only: an admitted write to @@ -327,12 +351,18 @@ let mut state = last_state.clone(); match action { Action::ChooseRef(r) if state.ref_name.is_none() => state.ref_name = Some(r), - Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => state.shape = Some(s), - Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => state.signer = Some(s), + Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => { + state.shape = Some(s) + } + Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => { + state.signer = Some(s) + } Action::ChooseRetention(r) if state.signer.is_some() && state.retention.is_none() => { state.retention = Some(r); } - Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => state.kind = Some(k), + Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => { + state.kind = Some(k) + } _ => return None, } Some(state) @@ -349,17 +379,23 @@ vec![ Property::always("ff_only_advance", |_, s| implication(s, Complete::ff_holds)), - Property::always("single_root_identity", |_, s| implication(s, Complete::single_root_holds)), + Property::always("single_root_identity", |_, s| { + implication(s, Complete::single_root_holds) + }), Property::always("introduced_commits_member_signed", |_, s| { implication(s, Complete::tip_signed_holds) }), - Property::always("anchor_retention_resolves", |_, s| implication(s, Complete::retention_holds)), + Property::always("anchor_retention_resolves", |_, s| { + implication(s, Complete::retention_holds) + }), Property::always("effects_writes_admin_signed", |_, s| { implication(s, Complete::effect_admin_holds) }), // The one property expected to have a discovery: the known, // ledger-recorded DIVERGED gap. See tests::rediscovers_cross_ref_replay_by_search. - Property::always("binding_refname_recomputed", |_, s| implication(s, Complete::binding_holds)), + Property::always("binding_refname_recomputed", |_, s| { + implication(s, Complete::binding_holds) + }), ] } } @@ -382,7 +418,10 @@ let path = checker.assert_any_discovery("binding_refname_recomputed"); // Printed so a run's witness can be copied into the PR // description as confirmation the harness has teeth. - println!("binding_refname_recomputed witness: {:?}", path.into_actions()); + println!( + "binding_refname_recomputed witness: {:?}", + path.into_actions() + ); checker.assert_no_discovery("ff_only_advance"); checker.assert_no_discovery("single_root_identity");
crates/cli/git-ents/src/commands/members.rs @@ -3,6 +3,7 @@ use ents_model::{Member, MemberId, MemberState, Provenance, namespace}; use ents_receive::{Identity, propose_delete, propose_entity}; +use gix_object::Find; use gix_ref_store::RefStoreRead; use super::{actor, signer}; @@ -12,18 +13,22 @@ /// `git ents members list`: every member ref and its current state. /// +/// Takes the ref and object seams rather than a concrete root so the same +/// read path serves both composition roots — the local CLI and the hosted +/// web mount both need "who are the members," never a root-specific rescan. +/// /// # Errors /// /// Propagates a ref-store or object read failure. -pub fn list(root: &LocalRoot) -> Result<Vec<(String, Member)>> { +pub fn list(refs: &impl RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Member)>> { let mut out = Vec::new(); - for entry in root.refs.iter_prefix("refs/meta/member/")? { + for entry in refs.iter_prefix("refs/meta/member/")? { let (name, tip) = entry?; let path = name.as_bstr().to_string(); let Some(username) = path.strip_prefix("refs/meta/member/") else { continue; }; - if let Some(member) = read_member(root, tip)? { + if let Some(member) = read_member(objects, tip)? { out.push((username.to_owned(), member)); } } @@ -49,6 +54,7 @@ let name = namespace::member_ref(&MemberId::new(username))?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = propose_entity( @@ -99,7 +105,7 @@ what: format!("member {username}"), }); }; - let mut member = read_member(root, tip)?.ok_or_else(|| Error::NotFound { + let mut member = read_member(&root.objects, tip)?.ok_or_else(|| Error::NotFound { what: format!("member {username}"), })?; member.state = if revoked { @@ -109,6 +115,7 @@ }; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let verb = if revoked { "Revoke" } else { "Unrevoke" }; @@ -137,20 +144,25 @@ key: Option<std::path::PathBuf>, ) -> Result<Option<(String, MemberState)>> { let signer = signer(root, key)?; - find_by_key(root, &signer.public_openssh()) + find_by_key(&root.refs, &root.objects, &signer.public_openssh()) } /// Resolve `pubkey` to the enrolled member whose stored key matches it, if -/// any -- the shared match loop behind [`check`] and `git ents serve`'s own +/// any -- the shared match loop behind [`check`], `git ents serve`'s own /// identity-chip label (`crate::commands::serve::build_state`, -/// `roots.web-signing`): both need "which member owns this key," never a -/// bespoke re-scan of `list`'s own rows. +/// `roots.web-signing`), and the hosted mount's server-key enrollment +/// check: all need "which member owns this key," never a bespoke re-scan +/// of `list`'s own rows. /// /// # Errors /// /// Propagates a ref-store or object read failure. -pub fn find_by_key(root: &LocalRoot, pubkey: &str) -> Result<Option<(String, MemberState)>> { - for (username, member) in list(root)? { +pub fn find_by_key( + refs: &impl RefStoreRead, + objects: &impl Find, + pubkey: &str, +) -> Result<Option<(String, MemberState)>> { + for (username, member) in list(refs, objects)? { if member.key == pubkey { return Ok(Some((username, member.state))); } @@ -158,7 +170,7 @@ Ok(None) } -fn read_member(root: &LocalRoot, tip: gix_hash::ObjectId) -> Result<Option<Member>> { - let tree = crate::commands::commit_tree(&root.objects, tip)?; - Ok(facet_git_tree::deserialize::<Member>(&tree, &root.objects).ok()) +fn read_member(objects: &impl Find, tip: gix_hash::ObjectId) -> Result<Option<Member>> { + let tree = crate::commands::commit_tree(objects, tip)?; + Ok(facet_git_tree::deserialize::<Member>(&tree, objects).ok()) }
crates/cli/git-ents/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod effect; pub mod inbox; pub mod issue; +pub mod login; pub mod lsp; pub mod members; pub mod redact;
crates/cli/git-ents/src/commands/serve.rs @@ -130,7 +130,7 @@ // than re-scanning `refs/meta/member/*` by hand, falling back to the // signer's own short fingerprint when no enrolled member's key matches // (an unenrolled local key, still allowed to browse and sign). - let label = super::members::find_by_key(&root, &pubkey)? + let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)? .map(|(username, _state)| username) .unwrap_or_else(|| super::short_fingerprint(&signer)); let identity = LocalIdentity { @@ -156,24 +156,114 @@ ))) } -/// Run `git ents serve`: bind loopback and block, serving the web UI -/// until the process is killed. +/// The hosted half of `roots.web-signing`'s indirection: the server's +/// own member key (persisted by `setup --hosted`) signs every web edit, +/// while the signed-in member is carried as the commit's author +/// (`receive.attributed-author`). Same shape as [`LocalIdentity`]; a +/// distinct type rather than a flag, per `arch.no-hosted-branch`'s +/// spirit. +// @relation(roots.web-signing, roots.single-node-hosted, scope=type) +struct HostedIdentity { + signer: Signer, + actor: gix::actor::Signature, + /// The server key's own enrolled member username — [`build_hosted_state`] + /// refuses to start when none matches, so this is never a fallback + /// fingerprint. + label: String, +} + +impl SigningIdentity for HostedIdentity { + fn actor(&self) -> gix::actor::Signature { + self.actor.clone() + } + + fn sign(&self, payload: &[u8]) -> String { + self.signer.sign(payload) + } + + fn public_openssh(&self) -> String { + self.signer.public_openssh() + } + + fn label(&self) -> String { + self.label.clone() + } +} + +/// Build the [`AppState`] `git ents serve --hosted` runs, from an +/// already-open [`crate::root::HostedRoot`]: the same seams the +/// `pre-receive`/`post-receive` hooks wire (`roots.single-node-hosted`), +/// plus the web UI's own policy — sign-in required against +/// `public_host` (`roots.web-signin`) and the server key as signing +/// identity. /// /// # Errors /// -/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding -/// the loopback socket or constructing the async runtime. -// @relation(roots.local, scope=function) -pub fn run( - root: LocalRoot, - port: Option<u16>, - key: Option<PathBuf>, - mut report: impl std::io::Write, -) -> Result<()> { - let label = host_label(&root.path); - let state = build_state(root, key)?; - let addr = loopback_addr(port.unwrap_or(4880)); +/// [`Error::BadSigningKey`] if `key` cannot be loaded; +/// [`Error::NotFound`] if the server key is not an enrolled member — +/// `roots.web-signing` requires the signing key itself be enrolled, so +/// an unenrolled key is a boot failure with a bootstrap instruction, not +/// a warning: nothing it signed would be admitted anyway. +// @relation(roots.single-node-hosted, roots.web-signin, roots.composition, scope=function) +pub fn build_hosted_state( + root: crate::root::HostedRoot, + key: PathBuf, + public_host: String, +) -> Result<Arc<AppState<crate::root::QuarantineObjects>>> { + let signer = Signer::load(&key)?; + let pubkey = signer.public_openssh(); + let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)? + .map(|(username, _state)| username) + .ok_or_else(|| Error::NotFound { + what: format!( + "an enrolled member holding the server key {} — enroll it first: \ + git ents members add <name> --pubkey \"{pubkey}\"", + key.display() + ), + })?; + let identity = HostedIdentity { + actor: actor(&signer), + label, + signer, + }; + let mode = root.mode(); + let crate::root::HostedRoot { + path, + refs, + objects, + events, + executor: _, + } = root; + Ok(Arc::new( + AppState::new( + Box::new(refs), + objects, + Box::new(events), + mode, + Box::new(identity), + path, + ) + .with_access(ents_web::state::AccessPolicy::SignInRequired( + ents_web::state::Realm { + host: public_host, + challenges: ents_web::auth::ChallengeStore::default(), + }, + )), + )) +} +/// Bind loopback, print `banner`, and serve `state` until killed — the +/// runtime/bind/serve tail [`run`] and [`run_hosted`] share, generic over +/// the object store exactly as `ents_web::serve_on` is. +fn serve_state<O>( + state: Arc<AppState<O>>, + addr: SocketAddr, + banner: impl Fn(SocketAddr) -> String, + mut report: impl std::io::Write, +) -> Result<()> +where + O: gix_object::Find + gix_object::Write + Send + 'static, +{ let runtime = tokio::runtime::Runtime::new().map_err(|source| Error::Io { path: PathBuf::from("<tokio runtime>"), source, @@ -187,11 +277,7 @@ path: PathBuf::from(addr.to_string()), source, })?; - let _ = writeln!( - report, - "listening on http://{label}.localhost:{port} (http://{bound})", - port = bound.port() - ); + let _ = writeln!(report, "{}", banner(bound)); ents_web::serve_on(listener, state) .await .map_err(|source| Error::Io { @@ -201,6 +287,74 @@ }) } +/// Run `git ents serve`: bind loopback and block, serving the web UI +/// until the process is killed. +/// +/// # Errors +/// +/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding +/// the loopback socket or constructing the async runtime. +// @relation(roots.local, scope=function) +pub fn run( + root: LocalRoot, + port: Option<u16>, + key: Option<PathBuf>, + report: impl std::io::Write, +) -> Result<()> { + let label = host_label(&root.path); + let state = build_state(root, key)?; + let addr = loopback_addr(port.unwrap_or(4880)); + serve_state( + state, + addr, + move |bound| { + format!( + "listening on http://{label}.localhost:{port} (http://{bound})", + port = bound.port() + ) + }, + report, + ) +} + +/// Run `git ents serve --hosted`: the single-node hosted root's web UI +/// (`roots.single-node-hosted`), still bound to loopback — inside the +/// hosted container the front proxy (nginx) is the only external +/// listener, so no `--host` flag exists here either and +/// [`loopback_addr`]'s guarantee stands unchanged. +/// +/// # Errors +/// +/// [`Error::NotFound`] when `--public-host` is absent, plus +/// [`build_hosted_state`]'s own errors and [`serve_state`]'s IO errors. +// @relation(roots.single-node-hosted, roots.web-signin, scope=function) +pub fn run_hosted( + root: crate::root::HostedRoot, + port: Option<u16>, + key: Option<PathBuf>, + public_host: Option<String>, + report: impl std::io::Write, +) -> Result<()> { + let key = key.ok_or_else(|| Error::NotFound { + what: "--key: the hosted root's persisted signing key (setup --hosted writes it)" + .to_owned(), + })?; + let public_host = public_host.ok_or_else(|| Error::NotFound { + what: "--public-host: the canonical host sign-in challenges bind to (roots.web-signin)" + .to_owned(), + })?; + let state = build_hosted_state(root, key, public_host.clone())?; + let addr = loopback_addr(port.unwrap_or(4880)); + serve_state( + state, + addr, + move |bound| { + format!("hosted web UI for https://{public_host} on http://{bound} (proxy-only)") + }, + report, + ) +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used, reason = "unit test")]
crates/cli/git-ents/src/commands/login.rs @@ -1,0 +1,195 @@ +//! `git ents login <url> <code>`: prove membership to a hosted web +//! session (`roots.web-signin`) — the automated replacement for the +//! pre-redo forge's paste-the-signature sign-in. +//! +//! The browser's `/login` page on the hosted root displays a one-time +//! code; this command fetches that code's challenge, **rebuilds the +//! signed payload locally** from the host the member typed — never from +//! bytes the server supplies, so a malicious or misconfigured server +//! cannot get an arbitrary blob signed — signs it with the member's own +//! key under `ents-web`'s login namespace (distinct from git's commit +//! namespace by construction), and posts the signature back. On success +//! the browser session that displayed the code is signed in; nothing +//! secret ever leaves this machine. +//! +//! The HTTP client is `ureq`: synchronous (two requests need no runtime) +//! and rustls/ring, so the hosted root's musl static release cross-build +//! keeps working. +// @relation(roots.web-signin, scope=file) + +use std::path::PathBuf; + +use ents_web::auth; + +use crate::error::{Error, Result}; +use crate::root::LocalRoot; +use crate::sign::Signer; + +/// Run the sign-in: resolve the member key exactly as every mutation +/// command does when run inside a repository (`--key`, else +/// `user.signingkey`, else `~/.ssh/id_ed25519`), falling back to the +/// same non-repository chain when run from anywhere else — a login +/// targets a *hosted* root, so requiring a local clone would be +/// arbitrary. +/// +/// # Errors +/// +/// [`Error::NoSigningKey`]/[`Error::BadSigningKey`] resolving the key; +/// [`Error::NotFound`] for an unknown or expired code, a host mismatch +/// between `url` and the server's own answer, or a server that refuses +/// the signature (each with the server's own explanation). +// @relation(roots.web-signin, scope=function) +pub fn run( + url: &str, + code: &str, + key: Option<PathBuf>, + mut out: impl std::io::Write, +) -> Result<()> { + let signer = resolve_signer(key)?; + let url = url.trim_end_matches('/'); + let host = host_of(url)?; + let code = auth::normalize_code(code); + + let agent = ureq::agent(); + let challenge = agent + .get(format!("{url}/login/challenge/{code}")) + .call() + .map_err(|source| http_error(url, &source))? + .body_mut() + .read_to_string() + .map_err(|source| http_error(url, &source))?; + let served_host = line_value(&challenge, "host"); + let nonce = line_value(&challenge, "nonce"); + let (Some(served_host), Some(nonce)) = (served_host, nonce) else { + return Err(Error::NotFound { + what: format!("a challenge in {url}'s answer — is this a git-ents hosted root?"), + }); + }; + // The typed URL is the trust anchor (`roots.web-signin`): a server + // answering for a different host gets nothing signed. + if served_host != host { + return Err(Error::NotFound { + what: format!( + "host agreement: you addressed {host}, the server answers for {served_host}" + ), + }); + } + + let payload = auth::challenge_payload(&host, &code, nonce); + let signature = signer.sign_in_namespace(auth::LOGIN_NAMESPACE, payload.as_bytes()); + let public_key = signer.public_openssh(); + let _ = writeln!(out, "proving membership to {url} as {public_key}"); + + let response = agent + .post(format!("{url}/login/challenge/{code}")) + .send_form([ + ("public_key", public_key.as_str()), + ("signature", signature.as_str()), + ]); + match response { + Ok(mut response) => { + let body = response.body_mut().read_to_string().unwrap_or_default(); + let member = line_value(&body, "member").unwrap_or("<unknown>"); + let _ = writeln!( + out, + "signed in: the browser session is now authenticated as {member}" + ); + Ok(()) + } + Err(ureq::Error::StatusCode(status)) => Err(Error::NotFound { + what: match status { + 401 => format!( + "membership: {url} refused the signature — is {public_key} enrolled and \ + active there?" + ), + 404 | 410 => format!( + "a live sign-in code: {code} is unknown, expired, or already used; reload \ + the sign-in page for a fresh one" + ), + other => format!("a sign-in answer from {url} (HTTP {other})"), + }, + }), + Err(source) => Err(http_error(url, &source)), + } +} + +/// Resolve the signing key with [`crate::commands::signer`]'s chain when +/// inside a repository, else the same chain minus the repository config. +fn resolve_signer(key: Option<PathBuf>) -> Result<Signer> { + match LocalRoot::discover(".") { + Ok(root) => crate::commands::signer(&root, key), + Err(_not_a_repo) => { + if let Some(path) = key { + return Signer::load(&path); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + let default = home + .map(|home| home.join(".ssh").join("id_ed25519")) + .filter(|path| path.exists()); + match default { + Some(path) => Signer::load(&path), + None => Err(Error::NoSigningKey), + } + } + } +} + +/// The host (and non-default port) component of an `https://` or +/// `http://` base URL — the exact string bound into the signed payload. +fn host_of(url: &str) -> Result<String> { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .ok_or_else(|| Error::NotFound { + what: format!("an http(s):// URL (got {url})"), + })?; + let host = rest.split('/').next().unwrap_or_default(); + if host.is_empty() { + return Err(Error::NotFound { + what: format!("a host in {url}"), + }); + } + Ok(host.to_owned()) +} + +/// The value of a `key=value` line in the server's plain-text answers. +fn line_value<'a>(body: &'a str, key: &str) -> Option<&'a str> { + body.lines() + .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) +} + +fn http_error(url: &str, source: &dyn std::fmt::Display) -> Error { + Error::NotFound { + what: format!("a reachable hosted root at {url}: {source}"), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::https("https://git.ents.cloud", "git.ents.cloud")] + #[case::trailing_path("https://git.ents.cloud/x", "git.ents.cloud")] + #[case::port("http://127.0.0.1:4880", "127.0.0.1:4880")] + fn host_of_extracts_the_authority(#[case] url: &str, #[case] expected: &str) { + assert_eq!(host_of(url).expect("parses"), expected); + } + + #[rstest] + fn host_of_refuses_a_bare_name() { + host_of("git.ents.cloud").unwrap_err(); + } + + #[rstest] + fn line_value_reads_the_servers_plain_answers() { + let body = "host=ents.test\ncode=ABCD2345\nnonce=00ff\n"; + assert_eq!(line_value(body, "host"), Some("ents.test")); + assert_eq!(line_value(body, "nonce"), Some("00ff")); + assert_eq!(line_value(body, "member"), None); + } +}
crates/cli/git-ents/tests/login.rs @@ -1,0 +1,117 @@ +//! End-to-end coverage of `git ents login` (`roots.web-signin`) against +//! the real hosted-shaped web state served on a real loopback socket: +//! the same `ents_web::router` the Fly deployment proxies to, the same +//! `commands::login::run` a member's own machine executes. This doubles +//! as the hosted-mount integration test — `build_hosted_state` wired and +//! served end to end, no container needed. +#![allow(clippy::expect_used, reason = "integration test")] + +use std::path::Path; +use std::process::Command; + +use git_ents::root::LocalRoot; + +/// A bare repository with `username`'s freshly-written key enrolled, +/// returning the key path. +fn enrolled_bare(dir: &Path, username: &str, seed: u8) -> std::path::PathBuf { + let bare = dir.join("repo.git"); + let output = Command::new("git") + .args(["init", "--bare"]) + .arg(&bare) + .output() + .expect("git runs"); + assert!(output.status.success(), "{output:?}"); + + use ssh_key::private::{Ed25519Keypair, KeypairData}; + let key_path = dir.join(format!("key_{username}")); + let pair = Ed25519Keypair::from_seed(&[seed; 32]); + let key = ssh_key::PrivateKey::new(KeypairData::from(pair), username).expect("well-formed"); + key.write_openssh_file(&key_path, ssh_key::LineEnding::LF) + .expect("writes"); + + let local = LocalRoot::open(&bare).expect("opens"); + git_ents::commands::members::add(&local, username, None, Some(key_path.clone())) + .expect("enrolls"); + key_path +} + +// @relation(roots.web-signin, roots.single-node-hosted, scope=function, role=Verifies) +#[tokio::test(flavor = "multi_thread")] +async fn git_ents_login_signs_a_hosted_browser_session_in() { + let dir = tempfile::tempdir().expect("tempdir"); + let server_key = enrolled_bare(dir.path(), "server", 42); + // Enroll the human member too, with their own distinct key. + use ssh_key::private::{Ed25519Keypair, KeypairData}; + let member_key = dir.path().join("key_joey"); + let pair = Ed25519Keypair::from_seed(&[7; 32]); + let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "joey").expect("well-formed"); + key.write_openssh_file(&member_key, ssh_key::LineEnding::LF) + .expect("writes"); + let bare = dir.path().join("repo.git"); + let local = LocalRoot::open(&bare).expect("opens"); + git_ents::commands::members::add(&local, "joey", None, Some(member_key.clone())) + .expect("enrolls"); + + // Bind first so the realm's host names the real ephemeral port — + // `git ents login` refuses a host disagreement by design. + let listener = ents_web::bind("127.0.0.1:0".parse().expect("addr")) + .await + .expect("binds"); + let host = listener.local_addr().expect("bound").to_string(); + let root = git_ents::root::HostedRoot::open(&bare).expect("opens"); + let state = git_ents::commands::serve::build_hosted_state(root, server_key, host.clone()) + .expect("boots"); + tokio::spawn(ents_web::serve_on(listener, state)); + + let url = format!("http://{host}"); + let outcome = tokio::task::spawn_blocking(move || { + // The browser half: GET /login mints a session and displays the + // one-time code. + let agent = ureq::agent(); + let mut page = agent + .get(format!("{url}/login")) + .call() + .expect("login page"); + let cookie = page + .headers() + .get("set-cookie") + .expect("a fresh session cookie") + .to_str() + .expect("ascii") + .split(';') + .next() + .expect("cookie pair") + .to_owned(); + let body = page.body_mut().read_to_string().expect("html"); + let code: String = body + .split(&format!("{host} ")) + .nth(1) + .expect("the page displays the login command") + .chars() + .take(9) + .collect(); + + // The CLI half: the real command, against the real socket. + let mut out = Vec::new(); + git_ents::commands::login::run(&url, &code, Some(member_key), &mut out).expect("signs in"); + let printed = String::from_utf8(out).expect("utf8"); + assert!( + printed.contains("authenticated as joey"), + "reports the member: {printed}" + ); + + // The browser half again: the same session now reads signed in. + let mut page = agent + .get(format!("{url}/login")) + .header("cookie", &cookie) + .call() + .expect("login page"); + let body = page.body_mut().read_to_string().expect("html"); + assert!( + body.contains("Signed in as") && body.contains("joey"), + "the browser session is authenticated: {body}" + ); + }) + .await; + outcome.expect("blocking half succeeds"); +}