diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 727c2aff2..0df6c3d54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,23 @@ on: env: CARGO_TERM_COLOR: always + # The GitHub App client id baked into every binary that links the `github` + # crate (crates/github/build.rs registers the rebuild trigger; + # crates/github/src/config.rs reads it via `option_env!`). Set at workflow + # level so it reaches every build job — including the minimald-linux-* + # binaries that build-release-initramfs repacks as the guest daemon, which + # boots with an empty environment and so could not be configured any other + # way. + # + # A repo VARIABLE, not a secret: a GitHub App client id is public (the + # device flow is a public-client flow with no client secret) and shipping it + # inside the binary discloses nothing. Never move this to `secrets` — a + # masked value would be unreadable in build logs for no benefit. + # + # Unset resolves to the empty string, which the crate treats as "no App + # configured": those builds fail closed with `Error::NotConfigured` exactly + # as they did before this seam existed. + MINIMAL_GITHUB_CLIENT_ID: ${{ vars.MINIMAL_GITHUB_CLIENT_ID }} # Serialize release runs so two manual dispatches can't race to cut a release # from different commits at the same time. diff --git a/AGENTS.md b/AGENTS.md index e5fa2fad4..fa63ef209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ The CLI reference overview is [docs/reference/cli.md](docs/reference/cli.md). ## Crate map -29 crates. One line each; the long-form map with plane assignments is in +30 crates. One line each; the long-form map with plane assignments is in [docs/architecture.md](docs/architecture.md) §3. | Crate | Role | @@ -38,6 +38,7 @@ The CLI reference overview is [docs/reference/cli.md](docs/reference/cli.md). | `common` | Common types and utilities (e.g. `SpecHash`) used across the codebase. | | `decode` | Evaluates a Nickel config layer into in-memory packages/profiles/stacks. | | `diagnostics` | App-agnostic machinery for diagnostic support bundles. | +| `github` | Daemon-held GitHub auth (device flow, grants, refresh) and leak-proof git/REST ops. | | `graph` | In-memory dependency graph; its `planner` module orders builds. | | `lcache` | Local cache of built artifacts, keyed by `SpecHash`. | | `mctx` | Top-level 'minimal context' API tying configuration, decoding, graph, and cache together. | diff --git a/Cargo.lock b/Cargo.lock index a8d0ba9ca..de4ab52a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,6 +2130,22 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "github" +version = "0.0.1" +dependencies = [ + "base64 0.23.0", + "chrono", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "url", + "zeroize", +] + [[package]] name = "globset" version = "0.4.19" @@ -3672,6 +3688,7 @@ version = "0.0.1" dependencies = [ "args", "common", + "github", "indoc", "paths", "serde", @@ -3742,6 +3759,7 @@ dependencies = [ "constcat", "diagnostics", "dirs", + "github", "indicatif", "inquire", "mctx", @@ -3796,6 +3814,7 @@ dependencies = [ "diagnostics", "fd-lock", "futures", + "github", "graph", "hakoniwa", "indoc", @@ -3832,6 +3851,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "uuid", "version", "vt100-ctt", diff --git a/Cargo.toml b/Cargo.toml index 7b5be6e9c..c8b92fba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/common", "crates/decode", "crates/diagnostics", + "crates/github", "crates/graph", "crates/mctx", "crates/mfile", @@ -164,6 +165,7 @@ thiserror = "2" url = "2" # keep in sync with reqwest uuid = { version = "1.23", features = ["v4", "v7", "serde"] } vt100-ctt = { version = "0.17", default-features = false } +zeroize = "1.9" zstd = "0.13" moka = { version = "0.12", default-features = false, features = ["sync"] } @@ -185,6 +187,7 @@ checkouts = { path = "crates/checkouts" } common = { path = "crates/common" } decode = { path = "crates/decode" } diagnostics = { path = "crates/diagnostics" } +github = { path = "crates/github" } mctx = { path = "crates/mctx" } minimald-rpc = { path = "crates/minimald-rpc" } mlog = { path = "crates/mlog" } diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml new file mode 100644 index 000000000..07c90a6d1 --- /dev/null +++ b/crates/github/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "github" +version = "0.0.1" +license = "MIT OR Apache-2.0" +edition.workspace = true +publish.workspace = true + +[features] +# DEFAULT features are pure domain types with no I/O and no reqwest, so cheap +# consumers (mfile, minimal) can depend on this crate without pulling an HTTP +# stack. +default = [] + +# CLIENT gates the GitHub REST client (`rest` module) and the OAuth +# device-flow client (`device_flow` module) behind an explicit opt-in, so +# `mfile`/`minimal` keep depending on this crate without pulling in reqwest. +# See the `reqwest` dependency note below for why no TLS backend feature is +# requested directly here. `dep:tokio` is `device_flow`'s poll-loop backoff +# sleep (`tokio::time::sleep`); it is the same optional dependency `test-support` +# already declares below. +client = ["dep:reqwest", "dep:tokio"] + +# TEST-SUPPORT ships a programmable, in-process mock GitHub (`testing` module) +# plus the `github-mock` binary: an OAuth device-flow + REST fake and an +# auth-enforcing git smart-HTTP endpoint (backed by `git http-backend`) used to +# exercise the daemon's auth/refresh/git paths without touching real GitHub. +# +# It is hand-rolled on the workspace tokio TCP stack, so it adds NO new external +# runtime dependency beyond crates already in the lockfile (tokio, serde_json, +# base64, tempfile). No production code depends on this feature. +test-support = ["dep:tokio", "dep:base64", "dep:tempfile"] + +[dependencies] +thiserror.workspace = true +url.workspace = true +zeroize.workspace = true + +# For the on-disk grant store (`store.rs`): JSON encoding plus timestamps for +# token expiry/refresh bookkeeping. Local filesystem I/O only — no network +# stack, so this stays compatible with the "no HTTP stack" promise above. +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true + +# Enabled only by `test-support` (see the feature note above). +base64 = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } + +# Enabled only by `client` (see the feature note above). Deliberately no TLS +# backend feature (`rustls`/`native-tls`) requested here: every rustls-backed +# provider in the workspace's dependency set (`ring`, `aws-lc-rs`) compiles a +# C/asm crypto core via `cc`, which this build environment cannot do. Instead +# this rides on Cargo's per-binary feature unification — whichever sibling +# crate already linked into the real daemon binary (e.g. `rcache`'s GCS +# client, which needs HTTPS regardless) enables a TLS feature on this same +# `reqwest`, and that feature applies here too since it's one dependency +# graph. In an isolated `cargo build -p github --features client`, this +# yields an HTTP-only client — sufficient for the mock-backed tests below, +# since none of them talk TLS. `form` adds the URL-encoded request bodies the +# OAuth device-flow endpoints expect (`login/device/code`, +# `login/oauth/access_token`); it is pure-Rust (`serde`/`serde_urlencoded`), +# so it adds no C/asm compile step. +reqwest = { workspace = true, optional = true, features = ["form"] } + +[dev-dependencies] +tempfile.workspace = true + +# The out-of-process mock used by scripts/session-e2e.sh. Compiled only when the +# `test-support` feature is on, so the default build (and its consumers) never +# builds a binary or pulls the tokio HTTP stack. +[[bin]] +name = "github-mock" +path = "src/bin/github-mock.rs" +required-features = ["test-support"] diff --git a/crates/github/build.rs b/crates/github/build.rs new file mode 100644 index 000000000..20c5a706e --- /dev/null +++ b/crates/github/build.rs @@ -0,0 +1,20 @@ +//! Bake the shipped GitHub App client id into the crate at build time. +//! +//! `config.rs` reads the value with `option_env!(CLIENT_ID_BUILD_ENV)`, which is +//! resolved when *this crate* is compiled. Cargo does not otherwise know that +//! the compilation depends on that variable, so a changed id would be served +//! from a stale build cache; the `rerun-if-env-changed` below is what makes the +//! injection reliable. +//! +//! Nothing here fails an unset build: a tree built without the variable ships an +//! unconfigured client id and every GitHub op fails closed with +//! `Error::NotConfigured`, exactly as it did before this seam existed. + +/// Build-time environment variable carrying the GitHub App client id. Kept in +/// step with `config::CLIENT_ID_BUILD_ENV`. +const CLIENT_ID_BUILD_ENV: &str = "MINIMAL_GITHUB_CLIENT_ID"; + +fn main() { + println!("cargo::rerun-if-env-changed={CLIENT_ID_BUILD_ENV}"); + println!("cargo::rerun-if-changed=build.rs"); +} diff --git a/crates/github/src/attrs.rs b/crates/github/src/attrs.rs new file mode 100644 index 000000000..524ba08df --- /dev/null +++ b/crates/github/src/attrs.rs @@ -0,0 +1,156 @@ +//! Codec between the GitHub domain types and `SessionConfig.attrs` (spec R7.1). +//! +//! Repo pre-priming and scope selection are carried first as free-form string +//! `attrs` (a `BTreeMap` on the session config/record) and +//! promotable to typed fields later. This module is the one place that knows the +//! key names and the string encodings, so encode/decode stay in lockstep. +//! +//! Keys: +//! * `github.grant_id` — the reused/minted grant id (spec R6.4). +//! * `github.repos` — comma-separated `owner/repo[@branch[:base]]` specs (spec R2.1). +//! * `github.scopes` — the compact [`ScopeSet`] encoding (spec R5). + +use std::collections::BTreeMap; +use std::str::FromStr; + +use crate::error::Error; +use crate::scopes::ScopeSet; +use crate::types::{GrantId, RepoSpec}; + +/// `attrs` key for the grant id. +pub const ATTR_GRANT_ID: &str = "github.grant_id"; +/// `attrs` key for the repo pre-priming list. +pub const ATTR_REPOS: &str = "github.repos"; +/// `attrs` key for the resolved scope set. +pub const ATTR_SCOPES: &str = "github.scopes"; + +/// The GitHub-relevant slice of a session's `attrs`, decoded into typed values. +/// +/// All fields are optional so a session with no GitHub involvement decodes to an +/// empty value and encodes to nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GithubAttrs { + /// The reused or minted grant id, if any. + pub grant_id: Option, + /// The repositories to pre-prime. + pub repos: Vec, + /// The resolved scope set, if one was recorded. + pub scopes: Option, +} + +impl GithubAttrs { + /// Whether this carries no GitHub configuration at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.grant_id.is_none() && self.repos.is_empty() && self.scopes.is_none() + } + + /// Writes the present fields into `attrs`. Absent fields are left untouched; + /// an empty repo list writes no key (so it round-trips with `decode`). + pub fn encode_into(&self, attrs: &mut BTreeMap) { + if let Some(grant_id) = &self.grant_id { + attrs.insert(ATTR_GRANT_ID.to_string(), grant_id.to_string()); + } + if !self.repos.is_empty() { + let joined = self + .repos + .iter() + .map(RepoSpec::to_string) + .collect::>() + .join(","); + attrs.insert(ATTR_REPOS.to_string(), joined); + } + if let Some(scopes) = &self.scopes { + attrs.insert(ATTR_SCOPES.to_string(), scopes.to_attr_value()); + } + } + + /// Reads the GitHub fields from `attrs`, parsing each value. Absent keys + /// yield the empty/`None` default; present-but-malformed values are an error. + pub fn decode(attrs: &BTreeMap) -> Result { + let grant_id = match attrs.get(ATTR_GRANT_ID) { + Some(value) => Some(GrantId::from_str(value)?), + None => None, + }; + let repos = match attrs.get(ATTR_REPOS) { + Some(value) => value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(RepoSpec::from_str) + .collect::, _>>()?, + None => Vec::new(), + }; + let scopes = match attrs.get(ATTR_SCOPES) { + Some(value) => Some(ScopeSet::from_attr_value(value)?), + None => None, + }; + Ok(Self { + grant_id, + repos, + scopes, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_full_set() { + let original = GithubAttrs { + grant_id: Some(GrantId::new("grant-abc").unwrap()), + repos: vec![ + "octocat/hello@feat/x:main".parse().unwrap(), + "my-org/api".parse().unwrap(), + ], + scopes: Some(ScopeSet::defaults()), + }; + + let mut map = BTreeMap::new(); + original.encode_into(&mut map); + + assert_eq!(map.get(ATTR_GRANT_ID).unwrap(), "grant-abc"); + assert_eq!( + map.get(ATTR_REPOS).unwrap(), + "octocat/hello@feat/x:main,my-org/api" + ); + + let decoded = GithubAttrs::decode(&map).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn empty_encodes_to_nothing_and_round_trips() { + let empty = GithubAttrs::default(); + assert!(empty.is_empty()); + let mut map = BTreeMap::new(); + empty.encode_into(&mut map); + assert!(map.is_empty()); + assert_eq!(GithubAttrs::decode(&map).unwrap(), empty); + } + + #[test] + fn preserves_unrelated_attrs() { + let mut map = BTreeMap::new(); + map.insert("other.key".to_string(), "value".to_string()); + GithubAttrs { + grant_id: Some(GrantId::new("g").unwrap()), + ..Default::default() + } + .encode_into(&mut map); + assert_eq!(map.get("other.key").unwrap(), "value"); + } + + #[test] + fn decode_rejects_malformed_values() { + let mut map = BTreeMap::new(); + map.insert(ATTR_REPOS.to_string(), "not a repo spec".to_string()); + assert!(GithubAttrs::decode(&map).is_err()); + + let mut map = BTreeMap::new(); + map.insert(ATTR_SCOPES.to_string(), "workflows:rw".to_string()); + assert!(GithubAttrs::decode(&map).is_err()); + } +} diff --git a/crates/github/src/bin/github-mock.rs b/crates/github/src/bin/github-mock.rs new file mode 100644 index 000000000..73de7c5d9 --- /dev/null +++ b/crates/github/src/bin/github-mock.rs @@ -0,0 +1,259 @@ +//! Out-of-process mock GitHub for `scripts/session-e2e.sh`. +//! +//! This wraps the in-process [`github::testing::MockGithub`] as a standalone +//! process so an e2e script can point a real `minimald` (and a real `git`) at +//! the same OAuth/REST + auth-enforcing git smart-HTTP surface the unit tests +//! use. It binds a loopback port, optionally pre-creates bare fixture repos, +//! advertises its base URL, and runs until it receives SIGINT/SIGTERM. +//! +//! The base URL it prints is meant to be fed straight into the daemon's +//! `MINIMALD_GITHUB_*_BASE_URL` overrides. +//! +//! ```text +//! github-mock [--addr HOST:PORT] [--git-root DIR] [--repo OWNER/REPO[@BRANCH]]... +//! [--exact-auth USER:PASS] [--ready-file PATH] +//! ``` +//! +//! On startup it writes `GITHUB_MOCK_BASE_URL=` to stdout (and to +//! `--ready-file`, if given, atomically) so a script can wait for readiness and +//! capture the URL. + +use std::error::Error; +use std::io::Write as _; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::process::ExitCode; + +use github::testing::{GitAuth, MockGithub}; + +type BoxError = Box; + +/// Parsed command-line configuration. +struct Config { + addr: SocketAddr, + git_root: Option, + repos: Vec, + exact_auth: Option<(String, String)>, + ready_file: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + git_root: None, + repos: Vec::new(), + exact_auth: None, + ready_file: None, + } + } +} + +/// A repo fixture to pre-create: `owner/repo` on `branch`. +struct RepoSpec { + owner: String, + repo: String, + branch: String, +} + +const USAGE: &str = "\ +Usage: github-mock [OPTIONS] + +A mock GitHub (OAuth device flow + REST + auth-enforcing git smart-HTTP) for +end-to-end tests. Runs until SIGINT/SIGTERM. + +Options: + --addr HOST:PORT Listen address (default 127.0.0.1:0 — OS-chosen port) + --git-root DIR Directory for bare fixture repos (default: a temp dir) + --repo OWNER/REPO[@BRANCH] Pre-create a bare fixture repo (repeatable; branch=main) + --exact-auth USER:PASS Require these exact git Basic credentials (default: any) + --ready-file PATH Write GITHUB_MOCK_BASE_URL= here once listening + -h, --help Show this help +"; + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("github-mock: {err}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), BoxError> { + let config = match parse_args(std::env::args().skip(1))? { + ParseOutcome::Run(config) => config, + ParseOutcome::Help => { + print!("{USAGE}"); + return Ok(()); + } + }; + + // Own the temp dir (when we created one) so it lives for the process and is + // cleaned up on exit; when a git root is supplied we leave it in place. + let (git_root, _temp_guard) = match &config.git_root { + Some(path) => (path.clone(), None), + None => { + let dir = tempfile::tempdir()?; + (dir.path().to_path_buf(), Some(dir)) + } + }; + + let mock = MockGithub::start_with_git_root_at(&git_root, config.addr).await?; + + if let Some((username, password)) = config.exact_auth { + mock.set_git_auth(GitAuth::Exact { username, password }); + } + + for spec in &config.repos { + mock.create_bare_repo(&spec.owner, &spec.repo, &spec.branch) + .map_err(|e| -> BoxError { + format!( + "failed to create fixture repo {}/{}@{}: {e}", + spec.owner, spec.repo, spec.branch + ) + .into() + })?; + } + + let base_url = mock.base_url().to_string(); + announce( + &base_url, + &git_root, + &config.repos, + config.ready_file.as_deref(), + )?; + + wait_for_shutdown().await; + eprintln!("github-mock: shutting down"); + Ok(()) +} + +/// Reports readiness: a machine-readable line on stdout, a human summary on +/// stderr, and (optionally) an atomically-written ready file. +fn announce( + base_url: &str, + git_root: &std::path::Path, + repos: &[RepoSpec], + ready_file: Option<&std::path::Path>, +) -> Result<(), BoxError> { + let line = format!("GITHUB_MOCK_BASE_URL={base_url}"); + + // stdout: the one line a caller can capture/grep; flush so a reader blocked + // on the pipe unblocks immediately. + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "{line}")?; + stdout.flush()?; + + eprintln!("github-mock: listening at {base_url}"); + eprintln!("github-mock: git root {}", git_root.display()); + for spec in repos { + eprintln!( + "github-mock: fixture {}/{} on {}", + spec.owner, spec.repo, spec.branch + ); + } + + if let Some(path) = ready_file { + write_atomically(path, line.as_bytes())?; + } + Ok(()) +} + +/// Writes `contents` to `path` via a sibling temp file + rename, so a reader +/// polling the path never observes a partial write. +fn write_atomically(path: &std::path::Path, contents: &[u8]) -> Result<(), BoxError> { + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let mut tmp = tempfile::NamedTempFile::new_in(dir)?; + tmp.write_all(contents)?; + tmp.flush()?; + tmp.persist(path).map_err(|e| e.error)?; + Ok(()) +} + +enum ParseOutcome { + Run(Config), + Help, +} + +fn parse_args(args: impl IntoIterator) -> Result { + let mut config = Config::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(ParseOutcome::Help), + "--addr" => config.addr = parse_value(&mut args, "--addr")?.parse()?, + "--git-root" => config.git_root = Some(parse_value(&mut args, "--git-root")?.into()), + "--repo" => config + .repos + .push(parse_repo(&parse_value(&mut args, "--repo")?)?), + "--exact-auth" => { + config.exact_auth = Some(parse_userpass(&parse_value(&mut args, "--exact-auth")?)?); + } + "--ready-file" => { + config.ready_file = Some(parse_value(&mut args, "--ready-file")?.into()); + } + other => return Err(format!("unexpected argument {other:?}; try --help").into()), + } + } + Ok(ParseOutcome::Run(config)) +} + +fn parse_value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .ok_or_else(|| format!("{flag} requires a value").into()) +} + +/// Parses `owner/repo` or `owner/repo@branch` (branch defaults to `main`). +fn parse_repo(spec: &str) -> Result { + let (path, branch) = match spec.split_once('@') { + Some((path, branch)) => (path, branch), + None => (spec, "main"), + }; + let (owner, repo) = path.split_once('/').ok_or_else(|| -> BoxError { + format!("--repo {spec:?} must be OWNER/REPO[@BRANCH]").into() + })?; + if owner.is_empty() || repo.is_empty() || branch.is_empty() { + return Err(format!("--repo {spec:?} has an empty owner, repo, or branch").into()); + } + Ok(RepoSpec { + owner: owner.to_string(), + repo: repo.to_string(), + branch: branch.to_string(), + }) +} + +/// Parses `user:pass` into its two parts (the password may itself contain `:`). +fn parse_userpass(value: &str) -> Result<(String, String), BoxError> { + let (user, pass) = value + .split_once(':') + .ok_or_else(|| -> BoxError { "--exact-auth must be USER:PASS".into() })?; + if user.is_empty() { + return Err("--exact-auth username must not be empty".into()); + } + Ok((user.to_string(), pass.to_string())) +} + +#[cfg(unix)] +async fn wait_for_shutdown() { + use tokio::signal::unix::{SignalKind, signal}; + match signal(SignalKind::terminate()) { + Ok(mut term) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + // If we cannot install a SIGTERM handler, fall back to SIGINT only. + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown() { + let _ = tokio::signal::ctrl_c().await; +} diff --git a/crates/github/src/config.rs b/crates/github/src/config.rs new file mode 100644 index 000000000..a87417458 --- /dev/null +++ b/crates/github/src/config.rs @@ -0,0 +1,275 @@ +//! Daemon-side GitHub configuration (spec R1.1). +//! +//! The base URLs default to public GitHub (`github.com` / `api.github.com`) and +//! are overridable through environment variables — the override seam is how the +//! test suite (and later, mock-server integration tests) points the daemon at a +//! local fake instead of the real GitHub. +//! +//! `client_id` is `Option`: at launch no real GitHub App is provisioned, so it +//! is normally `None`. Callers that need it must go through +//! [`GithubConfig::client_id`], which fails closed with +//! [`Error::NotConfigured`] rather than proceeding without an App. + +use url::Url; + +use crate::error::Error; + +/// Environment variable naming the GitHub App client id. +pub const ENV_CLIENT_ID: &str = "MINIMALD_GITHUB_CLIENT_ID"; + +/// Build-time environment variable carrying the client id shipped with a +/// release build. Read by `build.rs` only to register a rebuild trigger; the +/// value itself is baked in by [`BUILTIN_CLIENT_ID`] below. +pub const CLIENT_ID_BUILD_ENV: &str = "MINIMAL_GITHUB_CLIENT_ID"; + +/// The GitHub App client id baked in when this crate was compiled. +/// +/// A GitHub App client id is **public** — the device flow is a public-client +/// flow with no client secret (see [`crate::device_flow`]), and the id is +/// visible on the App's own page — so shipping it inside the binary discloses +/// nothing. This is what lets an installed `min` reach GitHub with no +/// configuration step: the daemon has no config file, is autospawned rather +/// than run from a service unit, and on macOS runs inside a microVM whose init +/// starts with an empty environment, so a runtime variable could not reach it. +/// +/// `None` in any build that did not set [`CLIENT_ID_BUILD_ENV`] (every dev +/// build, by default), which leaves the daemon unconfigured and failing closed. +const BUILTIN_CLIENT_ID: Option<&str> = option_env!("MINIMAL_GITHUB_CLIENT_ID"); + +/// Environment variable overriding the OAuth/device-flow base URL. +pub const ENV_OAUTH_BASE: &str = "MINIMALD_GITHUB_OAUTH_BASE_URL"; +/// Environment variable overriding the REST API base URL. +pub const ENV_API_BASE: &str = "MINIMALD_GITHUB_API_BASE_URL"; +/// Environment variable overriding the git-over-HTTPS base URL. +pub const ENV_GIT_BASE: &str = "MINIMALD_GITHUB_GIT_BASE_URL"; + +/// Default OAuth/device-flow base (public GitHub). +pub const DEFAULT_OAUTH_BASE: &str = "https://github.com"; +/// Default REST API base (public GitHub). +pub const DEFAULT_API_BASE: &str = "https://api.github.com"; +/// Default git-over-HTTPS base (public GitHub). +pub const DEFAULT_GIT_BASE: &str = "https://github.com"; + +/// Resolved GitHub configuration for the daemon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GithubConfig { + /// The GitHub App client id, or `None` when no App is configured yet. + client_id: Option, + /// Base URL for the OAuth device flow. + oauth_base: Url, + /// Base URL for the REST API. + api_base: Url, + /// Base URL for git-over-HTTPS operations. + git_base: Url, +} + +impl GithubConfig { + /// Builds a config from process environment variables, applying the public + /// GitHub defaults for any base URL that is unset. Returns + /// [`Error::InvalidConfig`] if an override is not a valid URL. + pub fn from_env() -> Result { + Self::from_source(|key| std::env::var(key).ok()) + } + + /// Core constructor parameterised over an environment lookup, so tests can + /// exercise override precedence without touching the process environment. + fn from_source(get: impl Fn(&str) -> Option) -> Result { + Self::from_parts(get, builtin_client_id()) + } + + /// [`from_source`](Self::from_source) with the baked-in client id supplied + /// rather than read from the build. Taking `builtin` as an argument keeps + /// the tests hermetic: they assert the resolution rules themselves, so they + /// hold identically in a dev build (no id baked in) and a release build + /// (one baked in) instead of silently depending on how the tree was built. + fn from_parts( + get: impl Fn(&str) -> Option, + builtin: Option, + ) -> Result { + // Runtime override first (the mock server, GHES, anyone running their + // own App), then the id baked in at build time. + let client_id = get(ENV_CLIENT_ID) + .filter(|v| !v.trim().is_empty()) + .or(builtin); + Ok(Self { + client_id, + oauth_base: resolve_url(ENV_OAUTH_BASE, DEFAULT_OAUTH_BASE, &get)?, + api_base: resolve_url(ENV_API_BASE, DEFAULT_API_BASE, &get)?, + git_base: resolve_url(ENV_GIT_BASE, DEFAULT_GIT_BASE, &get)?, + }) + } + + /// The GitHub App client id, or [`Error::NotConfigured`] when unset. This is + /// the only sanctioned way to reach the client id, so no auth flow can start + /// without an App. + pub fn client_id(&self) -> Result<&str, Error> { + self.client_id.as_deref().ok_or(Error::NotConfigured) + } + + /// Whether a client id is configured (a non-failing probe for status). + #[must_use] + pub fn is_configured(&self) -> bool { + self.client_id.is_some() + } + + /// The OAuth/device-flow base URL. + #[must_use] + pub fn oauth_base(&self) -> &Url { + &self.oauth_base + } + + /// The REST API base URL. + #[must_use] + pub fn api_base(&self) -> &Url { + &self.api_base + } + + /// The git-over-HTTPS base URL. + #[must_use] + pub fn git_base(&self) -> &Url { + &self.git_base + } +} + +/// The baked-in client id, treating an empty or whitespace-only build value as +/// absent so `MINIMAL_GITHUB_CLIENT_ID=` (set but empty, the shape a CI +/// expression yields when its variable is unset) does not present itself as a +/// configured App. +fn builtin_client_id() -> Option { + BUILTIN_CLIENT_ID + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +/// Resolves a base URL from an override env var, falling back to a default that +/// is a known-good constant. +fn resolve_url( + var: &str, + default: &str, + get: &impl Fn(&str) -> Option, +) -> Result { + match get(var).filter(|v| !v.trim().is_empty()) { + Some(value) => Url::parse(&value).map_err(|e| Error::InvalidConfig { + var: var.to_string(), + reason: e.to_string(), + }), + // The default is a compile-time constant; parsing it cannot fail. + None => Ok(Url::parse(default).expect("built-in default URL is valid")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn source(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn defaults_when_unset() { + let cfg = GithubConfig::from_parts(source(&[]), None).unwrap(); + assert_eq!(cfg.oauth_base().as_str(), "https://github.com/"); + assert_eq!(cfg.api_base().as_str(), "https://api.github.com/"); + assert_eq!(cfg.git_base().as_str(), "https://github.com/"); + assert!(!cfg.is_configured()); + } + + #[test] + fn overrides_take_precedence() { + let cfg = GithubConfig::from_parts( + source(&[ + (ENV_CLIENT_ID, "Iv1.abc123"), + (ENV_API_BASE, "http://localhost:8080/api"), + (ENV_OAUTH_BASE, "http://localhost:8080/oauth"), + ]), + None, + ) + .unwrap(); + assert_eq!(cfg.client_id().unwrap(), "Iv1.abc123"); + assert_eq!(cfg.api_base().as_str(), "http://localhost:8080/api"); + assert_eq!(cfg.oauth_base().as_str(), "http://localhost:8080/oauth"); + // git base was not overridden -> default. + assert_eq!(cfg.git_base().as_str(), "https://github.com/"); + } + + #[test] + fn missing_client_id_is_not_configured_error() { + let cfg = GithubConfig::from_parts(source(&[]), None).unwrap(); + assert!(matches!(cfg.client_id(), Err(Error::NotConfigured))); + } + + /// A shipped build carries its App id with no environment at all — the + /// property that makes `min github login` work for an installed user, who + /// has no config file, no service unit, and (on macOS) a daemon whose init + /// starts with an empty environment. + #[test] + fn a_baked_in_client_id_configures_an_otherwise_bare_environment() { + let cfg = + GithubConfig::from_parts(source(&[]), Some("Iv23li.shipped".to_string())).unwrap(); + assert!(cfg.is_configured()); + assert_eq!(cfg.client_id().unwrap(), "Iv23li.shipped"); + } + + /// The runtime variable still wins, so the mock server, GHES, and a + /// self-hosted App keep working against a build that ships an id. + #[test] + fn the_env_override_beats_a_baked_in_client_id() { + let cfg = GithubConfig::from_parts( + source(&[(ENV_CLIENT_ID, "Iv1.from-env")]), + Some("Iv23li.shipped".to_string()), + ) + .unwrap(); + assert_eq!(cfg.client_id().unwrap(), "Iv1.from-env"); + } + + /// An empty runtime value falls through to the baked-in id rather than + /// blanking it: `MINIMALD_GITHUB_CLIENT_ID=` is an unset-shaped value, not + /// a request to be unconfigured. + #[test] + fn an_empty_env_value_falls_through_to_the_baked_in_id() { + let cfg = GithubConfig::from_parts( + source(&[(ENV_CLIENT_ID, " ")]), + Some("Iv23li.shipped".to_string()), + ) + .unwrap(); + assert_eq!(cfg.client_id().unwrap(), "Iv23li.shipped"); + } + + /// A build that set the variable to an empty string — the shape a CI + /// expression yields when its repo variable is unset — must read as + /// unconfigured, not as an App whose id is "". + #[test] + fn an_empty_baked_in_value_is_not_configured() { + let cfg = GithubConfig::from_parts(source(&[]), builtin_from("")).unwrap(); + assert!(!cfg.is_configured()); + let cfg = GithubConfig::from_parts(source(&[]), builtin_from(" ")).unwrap(); + assert!(!cfg.is_configured()); + } + + /// Mirrors [`builtin_client_id`]'s emptiness filter for a supplied value, + /// so the test above exercises the same rule the build path applies. + fn builtin_from(raw: &str) -> Option { + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + + #[test] + fn blank_client_id_is_treated_as_unset() { + let cfg = GithubConfig::from_parts(source(&[(ENV_CLIENT_ID, " ")]), None).unwrap(); + assert!(matches!(cfg.client_id(), Err(Error::NotConfigured))); + } + + #[test] + fn invalid_override_url_is_rejected() { + let err = + GithubConfig::from_parts(source(&[(ENV_API_BASE, "not a url")]), None).unwrap_err(); + assert!(matches!(err, Error::InvalidConfig { .. })); + } +} diff --git a/crates/github/src/device_flow.rs b/crates/github/src/device_flow.rs new file mode 100644 index 000000000..83767c02d --- /dev/null +++ b/crates/github/src/device_flow.rs @@ -0,0 +1,678 @@ +//! GitHub OAuth device-flow client (spec R1.1) — the `client` feature. +//! +//! [`DeviceFlowClient`] runs the three HTTP calls that make up a device-flow +//! login: `POST {oauth_base}/login/device/code` +//! ([`DeviceFlowClient::start_device_flow`]), the token-exchange poll `POST +//! {oauth_base}/login/oauth/access_token` ([`DeviceFlowClient::poll`]), and +//! `GET {api_base}/user` to learn the authenticated identity +//! ([`DeviceFlowClient::fetch_user`]). [`assemble_grant`] then builds a +//! complete [`Grant`] ready for [`crate::GrantStore::save`]. +//! [`DeviceFlowClient::login`] wires the four together for the common case. +//! +//! This module is intentionally independent of `rest.rs` (the GitHub REST +//! client, also behind `client`): both are grounded in the same crate-wide +//! [`Error`] taxonomy but do not depend on each other's types, so they stay +//! separately testable and composable by the daemon-side unit that wires +//! them together. +//! +//! # Error mapping (spec R8.1) +//! +//! Every failure mode maps into the crate-wide [`Error`] used everywhere else +//! in this crate — no bespoke error type for this module: +//! +//! * A transport failure (DNS, TCP, TLS, timeout) becomes [`Error::Request`]; +//! a response that doesn't decode becomes [`Error::Decode`]. Both messages +//! come from the underlying error's `Display`, which never includes header +//! values, so no token material can reach them. +//! * A `client_id` GitHub does not recognize is the device-code endpoint's +//! one realistic pre-auth failure mode; it becomes [`Error::NotConfigured`] +//! — the same guidance as "no GitHub App configured yet", since the fix is +//! identical either way (spec R1.1). +//! * An expired or declined device code becomes [`Error::NeedsReauth`] (spec +//! R1.2): the caller must restart `min github login`. +//! * Any other OAuth-shaped error GitHub returns from the token endpoint +//! becomes [`Error::UnexpectedStatus`], carrying GitHub's own error code and +//! description. +//! +//! # Security +//! +//! [`DeviceAuthorization`] carries the device code as a [`SecretString`]: it +//! is never shown to the user (only `user_code`/`verification_uri` are, per +//! spec R1.4), and possessing it plus reachability to GitHub's token endpoint +//! is enough to complete the login, so it gets the same redacting-Debug and +//! zeroize-on-drop treatment as an access or refresh token. No public type in +//! this module renders token material through `Debug` or `Display` — see the +//! `no_token_material_in_debug_of_public_types` test. + +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use reqwest::header::ACCEPT; +use serde::Deserialize; +use url::Url; + +use crate::error::Error; +use crate::scopes::ScopeSet; +use crate::secret::SecretString; +use crate::store::{Grant, GrantState}; +use crate::types::GrantId; + +/// `User-Agent` sent on every request; GitHub requires one on all API calls. +const USER_AGENT: &str = concat!("minimal-github-device-flow/", env!("CARGO_PKG_VERSION")); + +/// RFC 8628's default backoff bump, used when a `slow_down` response doesn't +/// advise a specific new interval. +const SLOW_DOWN_STEP: Duration = Duration::from_secs(5); + +/// A device-flow client bound to one GitHub instance: `oauth_base` (normally +/// [`crate::GithubConfig::oauth_base`]) and `api_base` (normally +/// [`crate::GithubConfig::api_base`]), or both pointed at a +/// [`crate::testing::MockGithub`] in tests. +#[derive(Debug, Clone)] +pub struct DeviceFlowClient { + http: reqwest::Client, + oauth_base: Url, + api_base: Url, +} + +/// The device-code + user-code pair returned by `POST /login/device/code` +/// (spec R1.1, R1.4). +#[derive(Debug, Clone)] +pub struct DeviceAuthorization { + /// The URL to open in a browser (e.g. `https://github.com/login/device`). + pub verification_uri: Url, + /// The short code the user enters at `verification_uri`. Not secret — it + /// is meant to be read aloud or typed by the user (spec R1.4). + pub user_code: String, + /// The device code used to poll for the access token. Not a GitHub token, + /// but possessing it (plus reachability to the token endpoint) is enough + /// to complete the login, so it gets [`SecretString`]'s redacting-Debug + /// and zeroize-on-drop treatment; only [`DeviceFlowClient::poll`] reads + /// it. + device_code: SecretString, + /// Minimum time between polls (server-advised; may grow via `slow_down`). + pub interval: Duration, + /// When the device code expires. [`DeviceFlowClient::poll`] stops with + /// [`Error::NeedsReauth`] once this passes rather than polling forever, + /// even if the server never sends `expired_token`. + expires_at: Instant, +} + +/// A freshly minted user + refresh token pair (spec R1.1) — not yet a full +/// [`Grant`] (no `grant_id`/`scopes` assigned; see [`assemble_grant`]). +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AccessTokenPair { + /// The user-to-server access token (~8h lifetime). Never logged. + pub access_token: SecretString, + /// Expiry of `access_token`. + pub access_token_expires_at: DateTime, + /// The rotating refresh token (~6mo lifetime). Never logged. + pub refresh_token: SecretString, + /// Expiry of `refresh_token`. + pub refresh_token_expires_at: DateTime, +} + +/// The authenticated identity from `GET /user` (spec R1.3, G4). +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct GithubUser { + /// The user's login (handle). + pub login: String, + /// The user's numeric GitHub id. + pub id: u64, +} + +impl DeviceFlowClient { + /// Builds a client against `oauth_base` (device flow) and `api_base` + /// (`GET /user`). + #[must_use] + pub fn new(oauth_base: Url, api_base: Url) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client construction is infallible with the enabled backends"), + oauth_base, + api_base, + } + } + + /// `POST {oauth_base}/login/device/code` (spec R1.1, R1.4): starts a + /// device-flow login and returns the verification URL, user code, and + /// device code to poll with. + /// + /// # Errors + /// + /// [`Error::Request`]/[`Error::Decode`] on transport or shape failures; + /// [`Error::NotConfigured`] if GitHub rejects `client_id` (the endpoint's + /// only realistic pre-auth failure mode). + pub async fn start_device_flow(&self, client_id: &str) -> Result { + let url = self.oauth_base.join("login/device/code").map_err(url_err)?; + let resp = self + .http + .post(url) + .header(ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .form(&[("client_id", client_id)]) + .send() + .await + .map_err(request_err)?; + let status = resp.status(); + let bytes = resp.bytes().await.map_err(request_err)?; + if !status.is_success() { + // The device-code endpoint has exactly one realistic failure mode + // pre-auth: a `client_id` GitHub does not recognize. From the + // user's perspective the fix is the same as "no App configured": + // set a valid GitHub App client id. + return Err(Error::NotConfigured); + } + let wire: DeviceCodeWire = serde_json::from_slice(&bytes).map_err(decode_err)?; + let verification_uri = Url::parse(&wire.verification_uri).map_err(|e| Error::Decode { + reason: format!("invalid verification_uri from GitHub: {e}"), + })?; + Ok(DeviceAuthorization { + verification_uri, + user_code: wire.user_code, + device_code: SecretString::new(wire.device_code), + // A server-advised interval of 0 would otherwise turn the poll + // loop into a hot spin against GitHub; floor it at 1 second. + interval: Duration::from_secs(wire.interval.max(1)), + expires_at: Instant::now() + Duration::from_secs(wire.expires_in), + }) + } + + /// Polls `POST {oauth_base}/login/oauth/access_token` until the user + /// approves, honoring `authorization_pending` (keep waiting) and + /// `slow_down` (back off) per RFC 8628, until success, an expired device + /// code, or any other terminal GitHub error (spec R1.1). + /// + /// # Errors + /// + /// [`Error::NeedsReauth`] if the device code expires (server-signalled + /// `expired_token`, or this client's own real-time deadline); transport/ + /// decode errors as in [`DeviceFlowClient::start_device_flow`]; + /// [`Error::UnexpectedStatus`] for any other OAuth error GitHub reports. + pub async fn poll( + &self, + client_id: &str, + authorization: &DeviceAuthorization, + ) -> Result { + let mut interval = authorization.interval; + loop { + if Instant::now() >= authorization.expires_at { + return Err(Error::NeedsReauth); + } + tokio::time::sleep(interval).await; + let wire = self + .token_exchange(client_id, authorization.device_code.expose_secret()) + .await?; + match wire.error.as_deref() { + Some("authorization_pending") => continue, + Some("slow_down") => { + interval = wire + .interval + .map(Duration::from_secs) + .unwrap_or(interval + SLOW_DOWN_STEP); + continue; + } + Some("expired_token") => return Err(Error::NeedsReauth), + Some(other) => { + return Err(Error::UnexpectedStatus { + status: 200, + message: format!( + "{other}: {}", + wire.error_description + .as_deref() + .unwrap_or("no further detail") + ), + }); + } + None => return token_pair_from_wire(wire), + } + } + } + + /// `GET {api_base}/user`: the authenticated identity for a freshly minted + /// access token (spec R1.3). + /// + /// # Errors + /// + /// [`Error::NeedsReauth`] on `401`; transport/decode errors otherwise, as + /// in [`DeviceFlowClient::start_device_flow`]. + pub async fn fetch_user(&self, access_token: &SecretString) -> Result { + let url = self.api_base.join("user").map_err(url_err)?; + let resp = self + .http + .get(url) + .header(ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .bearer_auth(access_token.expose_secret()) + .send() + .await + .map_err(request_err)?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::NeedsReauth); + } + let bytes = resp.bytes().await.map_err(request_err)?; + if !status.is_success() { + return Err(Error::UnexpectedStatus { + status: status.as_u16(), + message: error_message(&bytes), + }); + } + let wire: UserWire = serde_json::from_slice(&bytes).map_err(decode_err)?; + Ok(GithubUser { + login: wire.login, + id: wire.id, + }) + } + + /// Runs a full device-flow login end-to-end — start, poll to completion, + /// `GET /user`, and assemble a complete [`Grant`] ready for + /// [`crate::GrantStore::save`] (spec R1.1). `on_authorization` is called + /// exactly once, as soon as the verification URL and user code are known, + /// so the caller (`min github login`) can display them (spec R1.4) + /// before this blocks on the user's approval. + /// + /// `grant_id` and `scopes` are the caller's concern (a freshly minted id + /// for "mint" or a chosen existing id for "reuse" — spec R6.4; the + /// resolved scope set — spec R5): this module only knows how to talk to + /// GitHub, not grant-id or scope policy. + /// + /// # Errors + /// + /// Whatever [`DeviceFlowClient::start_device_flow`], + /// [`DeviceFlowClient::poll`], or [`DeviceFlowClient::fetch_user`] return. + pub async fn login( + &self, + client_id: &str, + grant_id: GrantId, + scopes: ScopeSet, + on_authorization: impl FnOnce(&DeviceAuthorization), + ) -> Result { + let authorization = self.start_device_flow(client_id).await?; + on_authorization(&authorization); + let tokens = self.poll(client_id, &authorization).await?; + let user = self.fetch_user(&tokens.access_token).await?; + Ok(assemble_grant(grant_id, user, scopes, tokens)) + } + + /// One `POST {oauth_base}/login/oauth/access_token` attempt, returning + /// the raw decoded response for [`DeviceFlowClient::poll`] to interpret. + async fn token_exchange(&self, client_id: &str, device_code: &str) -> Result { + let url = self + .oauth_base + .join("login/oauth/access_token") + .map_err(url_err)?; + let resp = self + .http + .post(url) + .header(ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .form(&[ + ("client_id", client_id), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await + .map_err(request_err)?; + let status = resp.status(); + let bytes = resp.bytes().await.map_err(request_err)?; + let wire: TokenWire = serde_json::from_slice(&bytes).map_err(decode_err)?; + if !status.is_success() && wire.error.is_none() { + return Err(Error::UnexpectedStatus { + status: status.as_u16(), + message: error_message(&bytes), + }); + } + Ok(wire) + } +} + +/// Assembles a complete [`Grant`] ready for [`crate::GrantStore::save`] from +/// a freshly authenticated device-flow session (spec R1.1). +#[must_use] +pub fn assemble_grant( + grant_id: GrantId, + user: GithubUser, + scopes: ScopeSet, + tokens: AccessTokenPair, +) -> Grant { + let now = Utc::now(); + Grant { + grant_id, + github_login: user.login, + github_user_id: user.id, + scopes, + access_token: tokens.access_token, + access_token_expires_at: tokens.access_token_expires_at, + refresh_token: tokens.refresh_token, + refresh_token_expires_at: tokens.refresh_token_expires_at, + created_at: now, + last_refreshed_at: now, + state: GrantState::Valid, + } +} + +/// Builds an [`AccessTokenPair`] from a successful token-exchange response +/// (no `error` field present). +fn token_pair_from_wire(wire: TokenWire) -> Result { + let missing = |field: &str| Error::Decode { + reason: format!("GitHub's token response is missing `{field}`"), + }; + let access_token = wire.access_token.ok_or_else(|| missing("access_token"))?; + let refresh_token = wire.refresh_token.ok_or_else(|| missing("refresh_token"))?; + let expires_in = wire.expires_in.ok_or_else(|| missing("expires_in"))?; + let refresh_expires_in = wire + .refresh_token_expires_in + .ok_or_else(|| missing("refresh_token_expires_in"))?; + let now = Utc::now(); + Ok(AccessTokenPair { + access_token: SecretString::new(access_token), + access_token_expires_at: now + chrono::Duration::seconds(saturating_i64(expires_in)), + refresh_token: SecretString::new(refresh_token), + refresh_token_expires_at: now + + chrono::Duration::seconds(saturating_i64(refresh_expires_in)), + }) +} + +/// Converts a token lifetime in seconds to `i64`, saturating rather than +/// panicking on the practically-impossible case of a value over ~292 billion +/// years' worth of seconds. +fn saturating_i64(seconds: u64) -> i64 { + i64::try_from(seconds).unwrap_or(i64::MAX) +} + +fn request_err(e: reqwest::Error) -> Error { + Error::Request { + reason: e.to_string(), + } +} + +fn decode_err(e: serde_json::Error) -> Error { + Error::Decode { + reason: e.to_string(), + } +} + +fn url_err(e: url::ParseError) -> Error { + Error::Request { + reason: format!("invalid GitHub base URL: {e}"), + } +} + +/// Extracts GitHub's `message` field from an error body, falling back to a +/// bounded excerpt of the raw body so a malformed error response still yields +/// something actionable without risking an unbounded or binary blob in a log +/// (mirrors `rest.rs`'s identical convention for the same reason). +fn error_message(bytes: &[u8]) -> String { + if let Ok(value) = serde_json::from_slice::(bytes) + && let Some(message) = value.get("message").and_then(|m| m.as_str()) + { + return message.to_string(); + } + String::from_utf8_lossy(bytes).chars().take(200).collect() +} + +/// Wire shape of `POST /login/device/code`'s success response. +#[derive(Deserialize)] +struct DeviceCodeWire { + device_code: String, + user_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +/// Wire shape of `POST /login/oauth/access_token`'s response — success and +/// every scripted RFC 8628 error share one envelope, distinguished by +/// whether `error` is present. +#[derive(Deserialize, Default)] +struct TokenWire { + #[serde(default)] + access_token: Option, + #[serde(default)] + expires_in: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + refresh_token_expires_in: Option, + #[serde(default)] + interval: Option, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +/// Wire shape of `GET /user` (only the fields this client needs). +#[derive(Deserialize)] +struct UserWire { + login: String, + id: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal one-shot HTTP responder used only for the one scenario the + /// full `test-support` mock doesn't script (it never validates + /// `client_id`). Self-contained so this test compiles and runs even when + /// only the `client` feature (not `test-support`) is enabled. + async fn one_shot_response(status_line: &str, body: &str) -> Url { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let addr = listener.local_addr().expect("local addr"); + let status_line = status_line.to_string(); + let body = body.to_string(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf).await; // drain the request, unread + let response = format!( + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + } + }); + Url::parse(&format!("http://{addr}/")).expect("valid loopback url") + } + + #[tokio::test] + async fn bad_client_id_maps_to_not_configured() { + let url = one_shot_response("404 Not Found", r#"{"message":"Not Found"}"#).await; + let client = DeviceFlowClient::new(url.clone(), url); + let err = client + .start_device_flow("client-id-github-does-not-know") + .await + .unwrap_err(); + assert!(matches!(err, Error::NotConfigured), "got {err:?}"); + } + + #[cfg(feature = "test-support")] + mod mock_backed { + use super::*; + use crate::testing::{MockGithub, TokenStep}; + + fn client(mock: &MockGithub) -> DeviceFlowClient { + DeviceFlowClient::new(mock.base_url().clone(), mock.base_url().clone()) + } + + #[tokio::test] + async fn happy_path_end_to_end() { + let mock = MockGithub::start().await.expect("mock starts"); + // A short interval keeps this test's single poll fast without + // needing tokio's paused-clock machinery (which doesn't mix well + // with reqwest's own timeout timer racing real loopback I/O). + mock.configure(|fx| fx.set_device_interval(1)); + let c = client(&mock); + + let auth = c + .start_device_flow("test-client") + .await + .expect("start_device_flow ok"); + assert_eq!(auth.user_code, "WDJB-MJHT"); + assert_eq!( + auth.verification_uri.as_str(), + "https://github.com/login/device" + ); + + let tokens = c.poll("test-client", &auth).await.expect("poll ok"); + assert_eq!(tokens.access_token.expose_secret(), "ghu_mock_access_1"); + assert_eq!(tokens.refresh_token.expose_secret(), "ghr_mock_refresh_1"); + + let user = c + .fetch_user(&tokens.access_token) + .await + .expect("fetch_user ok"); + assert_eq!(user.login, "octocat"); + assert_eq!(user.id, 583_231); + + let grant_id = GrantId::new("grant-1").expect("valid grant id"); + let grant = assemble_grant(grant_id.clone(), user, ScopeSet::defaults(), tokens); + assert_eq!(grant.grant_id, grant_id); + assert_eq!(grant.github_login, "octocat"); + assert_eq!(grant.github_user_id, 583_231); + assert_eq!(grant.scopes, ScopeSet::defaults()); + assert_eq!(grant.state, GrantState::Valid); + } + + #[tokio::test] + async fn slow_down_then_approve_backs_off_and_succeeds() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| { + fx.set_device_interval(1); + fx.script_device_exchange([TokenStep::SlowDown, TokenStep::Approve]); + }); + let c = client(&mock); + + let auth = c + .start_device_flow("test-client") + .await + .expect("start_device_flow ok"); + let tokens = c + .poll("test-client", &auth) + .await + .expect("poll succeeds after slow_down backoff"); + assert!(!tokens.access_token.expose_secret().is_empty()); + + let polls = mock + .captured() + .into_iter() + .filter(|r| r.path == "/login/oauth/access_token") + .count(); + assert_eq!(polls, 2, "one slow_down response, then one approval"); + } + + #[tokio::test] + async fn pending_loop_eventually_succeeds() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| { + fx.set_device_interval(1); + fx.script_device_exchange([ + TokenStep::Pending, + TokenStep::Pending, + TokenStep::Approve, + ]); + }); + let c = client(&mock); + + let auth = c + .start_device_flow("test-client") + .await + .expect("start_device_flow ok"); + c.poll("test-client", &auth) + .await + .expect("poll succeeds after the pending loop"); + + let polls = mock + .captured() + .into_iter() + .filter(|r| r.path == "/login/oauth/access_token") + .count(); + assert_eq!(polls, 3, "two pending polls, then one approval"); + } + + #[tokio::test] + async fn expired_device_code_maps_to_needs_reauth() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| { + fx.set_device_interval(1); + fx.script_device_exchange([TokenStep::Expired]); + }); + let c = client(&mock); + + let auth = c + .start_device_flow("test-client") + .await + .expect("start_device_flow ok"); + let err = c.poll("test-client", &auth).await.unwrap_err(); + assert!(matches!(err, Error::NeedsReauth), "got {err:?}"); + } + + #[tokio::test] + async fn no_token_material_in_debug_of_public_types() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_device_interval(1)); + let c = client(&mock); + + let auth = c + .start_device_flow("test-client") + .await + .expect("start_device_flow ok"); + let tokens = c.poll("test-client", &auth).await.expect("poll ok"); + let user = c + .fetch_user(&tokens.access_token) + .await + .expect("fetch_user ok"); + + let secrets = [ + tokens.access_token.expose_secret().to_string(), + tokens.refresh_token.expose_secret().to_string(), + ]; + assert!( + secrets.iter().all(|s| !s.is_empty()), + "sanity: the mock did mint tokens" + ); + + let auth_debug = format!("{auth:?}"); + let tokens_debug = format!("{tokens:?}"); + let grant = assemble_grant( + GrantId::new("grant-1").expect("valid grant id"), + user, + ScopeSet::defaults(), + tokens, + ); + let grant_debug = format!("{grant:?}"); + + for secret in &secrets { + assert!( + !auth_debug.contains(secret.as_str()), + "DeviceAuthorization Debug leaked a token" + ); + assert!( + !tokens_debug.contains(secret.as_str()), + "AccessTokenPair Debug leaked a token" + ); + assert!( + !grant_debug.contains(secret.as_str()), + "Grant Debug leaked a token" + ); + } + // Belt-and-suspenders: the redaction marker is present instead. + assert!(tokens_debug.contains(crate::secret::REDACTED)); + assert!(grant_debug.contains(crate::secret::REDACTED)); + } + } +} diff --git a/crates/github/src/error.rs b/crates/github/src/error.rs new file mode 100644 index 000000000..4dc9246fa --- /dev/null +++ b/crates/github/src/error.rs @@ -0,0 +1,150 @@ +//! Actionable, non-secret error taxonomy shared across the GitHub session flow +//! (spec R8.1). +//! +//! Every variant is safe to surface to a user and to write to a log or a +//! diagnostic bundle: no token material, no opaque strings. Messages name the +//! concrete thing that is wrong and, where possible, the way to fix it. + +/// Errors produced by the GitHub domain layer. +/// +/// `#[non_exhaustive]` because the client feature and later units add variants +/// (rate-limit, network, token-refresh); downstream matches must keep a +/// wildcard arm. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The GitHub App is not installed on the target repository or org. Carries + /// the installation URL to guide the user (spec R1.5, R2.6). + #[error("GitHub App is not installed on the target; install it at {install_url}")] + AppNotInstalled { + /// The `github.com` App-installation URL to open. + install_url: String, + }, + + /// The stored authentication is no longer usable (refresh token expired or + /// revoked) and the user must sign in again (spec R1.2). + #[error("GitHub authentication has expired; run `min github login` to re-authenticate")] + NeedsReauth, + + /// No GitHub App client id is configured. At launch no real GitHub App + /// exists yet, so this is the expected state until one is provisioned and + /// `MINIMALD_GITHUB_CLIENT_ID` is set (spec R1.1). + #[error( + "GitHub support is not configured: set MINIMALD_GITHUB_CLIENT_ID to the GitHub App's \ + client id (no GitHub App is provisioned yet)" + )] + NotConfigured, + + /// A required permission was not granted by the resolved token (spec R5.4, + /// R8.1). Names the scope in `contents:write` form. + #[error("required GitHub permission `{scope}` was not granted")] + MissingScope { + /// The missing permission, rendered as `scope:permission`. + scope: String, + }, + + /// The base branch a working branch should be created from does not exist on + /// the remote (spec R2.6, R8.1). + #[error("base branch `{branch}` not found on the remote")] + BaseBranchNotFound { + /// The base branch name that could not be resolved. + branch: String, + }, + + /// A `owner/repo[@branch[:base]]` spec could not be parsed. Repo specs are + /// not secret, so the offending input is echoed to make the error actionable. + #[error("invalid repo spec `{input}`: {reason}")] + InvalidRepoSpec { + /// The verbatim input that failed to parse. + input: String, + /// Why it was rejected. + reason: String, + }, + + /// A grant id was empty or otherwise malformed (spec R6.4). Grant ids are + /// not secret, so the offending reason is safe to surface. + #[error("invalid grant id: {reason}")] + InvalidGrantId { + /// Why the grant id was rejected. + reason: String, + }, + + /// A permission set string could not be parsed. This is also how a request + /// for the excluded `workflows` permission surfaces (spec NG6): it is not a + /// known scope, so it is rejected here rather than silently accepted. + #[error("invalid GitHub scope `{input}`: {reason}")] + InvalidScope { + /// The verbatim scope token that failed to parse. + input: String, + /// Why it was rejected. + reason: String, + }, + + /// A configuration override (an `MINIMALD_GITHUB_*_URL` env var) was not a + /// valid URL. The variable name is named so the fix is obvious. + #[error("invalid value for {var}: {reason}")] + InvalidConfig { + /// The environment variable whose value was rejected. + var: String, + /// Why it was rejected. + reason: String, + }, + + /// A required session-`attrs` key was absent while decoding (spec R7.1). + #[error("missing required session attribute `{key}`")] + MissingAttr { + /// The `attrs` key that was expected but not present. + key: String, + }, + + /// A GitHub REST call could not be sent, or was sent but the response + /// bytes could not be read off the wire (DNS, TCP, TLS, timeout). The + /// message comes from the underlying transport error's `Display`, which + /// never includes header values, so it is safe to surface (spec R8.1). + #[error("GitHub API request failed: {reason}")] + Request { + /// A human-readable description of the transport failure. + reason: String, + }, + + /// A GitHub REST response body did not decode into the expected shape. + #[error("GitHub API response could not be decoded: {reason}")] + Decode { + /// A human-readable description of the decode failure. + reason: String, + }, + + /// GitHub answered a REST call with a status this client does not map to + /// a more specific variant. The response body is deliberately not + /// included verbatim (only GitHub's own `message`, or a bounded excerpt), + /// so this stays safe to log (spec R8.1). + #[error("GitHub API returned HTTP {status}: {message}")] + UnexpectedStatus { + /// The HTTP status code GitHub returned. + status: u16, + /// GitHub's `message` field, or a short excerpt of the response body. + message: String, + }, + + /// The repository does not exist, or is not visible to the authenticated + /// identity (spec R2.6). + #[error("repository `{owner}/{repo}` not found or not accessible")] + RepoNotFound { + /// The repository owner (user or org). + owner: String, + /// The repository name. + repo: String, + }, + + /// The referenced pull request does not exist on `owner/repo` (spec + /// R4.5). + #[error("pull request #{number} not found on {owner}/{repo}")] + PullNotFound { + /// The repository owner (user or org). + owner: String, + /// The repository name. + repo: String, + /// The pull-request number that was requested. + number: u64, + }, +} diff --git a/crates/github/src/facade.rs b/crates/github/src/facade.rs new file mode 100644 index 000000000..be94e2f47 --- /dev/null +++ b/crates/github/src/facade.rs @@ -0,0 +1,18 @@ +//! Facade verb constants shared by the in-sandbox `min` helper script and the +//! daemon dispatch (spec R3). +//! +//! The in-sandbox `min git` / GitHub-MCP facade speaks a small set of verbs back +//! to the daemon over the trusted transport. Defining them here — in the +//! dependency-light types crate both sides already share — keeps the helper +//! script and the daemon's dispatch match arm from drifting apart. + +/// Facade verb for git operations (`push`, `pull`, `fetch`, …) proxied to the +/// daemon, which performs the authenticated operation (spec R3.1). +pub const VERB_GIT: &str = "git"; + +/// Facade verb for GitHub REST API calls (used by GitHub MCP), routed through +/// the daemon so the token stays out of the sandbox (spec R3.3). +pub const VERB_API: &str = "api"; + +/// All facade verbs, for exhaustive iteration in the dispatch layer. +pub const ALL: [&str; 2] = [VERB_GIT, VERB_API]; diff --git a/crates/github/src/gitops.rs b/crates/github/src/gitops.rs new file mode 100644 index 000000000..00ca5fdcb --- /dev/null +++ b/crates/github/src/gitops.rs @@ -0,0 +1,1290 @@ +//! Daemon-side authenticated git for working-tree session repositories (spec R2, +//! R3, R6). +//! +//! This module runs real `git` against a session's working tree on the daemon +//! host, on the token's behalf, and returns the result. It is modelled on the +//! hardening in `crates/checkouts` (the `GIT_SEC_ARGS` prefix: +//! `--no-optional-locks` and `core.hooksPath=/dev/null`) but adds the property +//! the GitHub flow needs above everything else: **the token never touches argv, +//! the remote URL, or any on-disk git config.** +//! +//! # Why env-only credential injection (a deliberate deviation from the spec) +//! +//! The PRD sketches the transport as an `https://x-access-token:@…` URL. +//! That is illustrative only. Baking the token into the URL would persist it in +//! `.git/config` (`origin.url`) and echo it in process argv — both of which are +//! **sandbox-visible**, because the session's working tree lives on the host the +//! sandbox can read (R6.1). So this module fails closed instead: +//! +//! * `origin` always carries the **clean** `https://…` URL — no userinfo. +//! * Credentials are injected **only through the environment** of each `git` +//! child, via `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` +//! which install an inline, one-shot `credential.helper`. The helper is a +//! short shell snippet that prints `username`/`password` on the `get` +//! operation; the **password is read from a per-child env var** +//! ([`SECRET_ENV`]), so the token appears in neither the config *value* nor +//! anywhere git can write to disk. +//! * The helper list is **reset to empty first** (`credential.helper=`), then +//! ours is appended. This is load-bearing on a real developer host: without +//! the reset, an inherited helper (e.g. `credential.https://github.com.helper +//! = !gh auth git-credential` in the operator's `~/.gitconfig`) would answer +//! first and splice in the *wrong* user's real token — a silent +//! misattribution and credential-leak bug. The reset clears every inherited +//! (generic and URL-scoped) helper so only ours can fire. +//! * `GIT_TERMINAL_PROMPT=0` turns a missing/failed credential into a clean +//! failure instead of an interactive hang, and `protocol.allow` is pinned to +//! the exact scheme of the configured remote so a malicious redirect cannot +//! switch git onto `file://`/`ext::` transports. +//! * The operator's **global and system git config are denied** to every child +//! (`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` → `/dev/null`): a git spawned +//! here reads only the repo-local config of the directory it runs in, so the +//! "credentialed git reads only daemon-authored config" invariant holds by +//! construction, not merely because the inherited environment happens to be +//! clean of `insteadOf` rewrites or stray credential helpers. +//! +//! Finally, `git` is chatty, so all child stdout/stderr is streamed line-by-line +//! through a [`scrub`]ber that masks the live token bytes with +//! [`crate::secret::REDACTED`] before a line ever reaches a callback, a captured +//! buffer, or an error message — belt-and-suspenders against a remote that +//! echoes what it was sent. + +use std::borrow::Cow; +use std::fmt; +use std::fs; +use std::io::{self, BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::secret::{REDACTED, SecretString}; + +/// Non-secret hardening flags applied to every `git` invocation, mirroring +/// `crates/checkouts`: no optional index locks, and hooks disabled so a +/// repository can never run attacker-controlled hook scripts on the daemon. +const GIT_SEC_ARGS: [&str; 3] = ["--no-optional-locks", "-c", "core.hooksPath=/dev/null"]; + +/// The per-child environment variable the inline credential helper reads the +/// token from. Named distinctively so it is greppable and unlikely to collide. +const SECRET_ENV: &str = "MINIMALD_GH_CRED_TOKEN"; + +/// The HTTP Basic username presented alongside the token. For a GitHub App +/// user-to-server token any username works; `x-access-token` is conventional. +const CRED_USERNAME: &str = "x-access-token"; + +/// Which of a child's two output streams a line arrived on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + /// The child's standard output. + Stdout, + /// The child's standard error (where `git` writes progress). + Stderr, +} + +/// Errors from daemon-side git operations. +/// +/// Every variant is safe to surface: `stderr` embedded in [`GitError::Failed`] +/// has already passed through the token [`scrub`]ber, and no variant carries +/// credential material. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum GitError { + /// The `git` process could not be started or waited on. + #[error("failed to run git for `{operation}`: {source}")] + Spawn { + /// The logical operation being attempted (e.g. `clone`, `push`). + operation: String, + /// The underlying spawn/wait error. + #[source] + source: io::Error, + }, + + /// `git` ran but exited non-zero. `stderr` is scrubbed of token bytes. + #[error("git {operation} failed ({status}): {stderr}")] + Failed { + /// The logical operation that failed. + operation: String, + /// A human description of how it exited. + status: String, + /// The (scrubbed) tail of the child's stderr. + stderr: String, + }, + + /// The requested base branch does not exist on the remote, so a working + /// branch cannot be created from it (spec R2.6). + #[error("base branch `{branch}` not found on the remote")] + BaseBranchNotFound { + /// The base branch that could not be resolved. + branch: String, + }, + + /// A filesystem step around a git operation failed (temp dir, rename, …). + #[error("filesystem error during git {operation}: {source}")] + Io { + /// The logical operation whose filesystem step failed. + operation: String, + /// The underlying I/O error. + #[source] + source: io::Error, + }, + + /// Expected information could not be parsed out of git's output. + #[error("could not determine {what} from git output")] + Unparsable { + /// What was being parsed (e.g. `remote default branch`). + what: String, + }, + + /// The clone destination already exists; cloning would not be atomic. + #[error("clone destination `{path}` already exists")] + DestinationExists { + /// The destination path that was already present. + path: String, + }, + + /// A path could not be expressed as UTF-8 for a git argument. + #[error("path is not valid UTF-8: {path}")] + NonUtf8Path { + /// The offending path, rendered lossily. + path: String, + }, + + /// A branch/ref name that would be handed to git as a bare argument begins + /// with `-`, so git would parse it as an option rather than a ref + /// (option-injection). Refused before any git runs. + #[error("unsafe {kind} name `{name}`: names beginning with `-` are refused")] + UnsafeRefName { + /// What the name is (`branch`, `base`). + kind: String, + /// The offending name (not secret). + name: String, + }, +} + +/// Refuses a ref/branch name git would parse as an option (leading `-`). The +/// credentialed argv builders never place such a name where git could read it +/// as a flag (push additionally separates it with `--`); this boundary guard +/// makes that guarantee explicit and covers callers that pass a name derived +/// from `rev-parse`/remote output. +fn reject_option_like(kind: &str, name: &str) -> Result<(), GitError> { + if name.starts_with('-') { + return Err(GitError::UnsafeRefName { + kind: kind.to_string(), + name: name.to_string(), + }); + } + Ok(()) +} + +/// The result of [`Repo::checkout_or_create`] (spec R2.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CheckoutOutcome { + /// The branch already existed on the remote and was checked out. + Checkout, + /// The branch was absent on the remote and was created locally from the + /// base branch. It is **not** pushed (spec R2.5). + CreatedFromBase, +} + +/// How the current branch relates to its upstream — the signal that feeds the +/// "is there PR-able work?" decision (spec R4.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Ahead { + /// No upstream is configured: a freshly created branch that has never been + /// pushed. Everything on it is unpushed. + NoUpstream, + /// The branch tracks `origin/`; it is `ahead` commits ahead and + /// `behind` commits behind that upstream. + Tracking { + /// Commits on the local branch not yet on its upstream. + ahead: usize, + /// Commits on the upstream not yet merged locally. + behind: usize, + }, +} + +impl Ahead { + /// Whether this branch has work that could be turned into a pull request: + /// either it has no upstream yet, or it is strictly ahead of one. + #[must_use] + pub fn is_pr_able(&self) -> bool { + match self { + Ahead::NoUpstream => true, + Ahead::Tracking { ahead, .. } => *ahead > 0, + } + } +} + +/// Replaces every occurrence of the live token in `line` with the redaction +/// marker. A no-op when there is no token or it does not appear. +fn scrub<'a>(line: &'a str, token: Option<&str>) -> Cow<'a, str> { + match token { + Some(secret) if !secret.is_empty() && line.contains(secret) => { + Cow::Owned(line.replace(secret, REDACTED)) + } + _ => Cow::Borrowed(line), + } +} + +/// The inline `credential.helper` shell snippet. It responds only to the `get` +/// operation and reads the token from [`SECRET_ENV`] — the token itself is never +/// part of this string, only the name of the env var that carries it. +fn credential_helper_script() -> String { + format!( + "!f() {{ test \"$1\" = get && printf 'username=%s\\npassword=%s\\n' \ + '{CRED_USERNAME}' \"${{{SECRET_ENV}-}}\"; }}; f" + ) +} + +/// The transport scheme of a remote (`https`, `http`, `file`). Local paths with +/// no `scheme://` prefix are treated as `file`. +fn url_scheme(remote: &str) -> &str { + remote.split_once("://").map(|(s, _)| s).unwrap_or("file") +} + +/// A fully-built `git` invocation: the argv (after `git`) and the environment +/// overrides. Kept separate from spawning so it can be asserted on in tests +/// (the token must appear only in the [`SECRET_ENV`] value, nowhere else). +struct GitCommand { + args: Vec, + /// `(key, value)` env overrides. Exactly one value — the [`SECRET_ENV`] + /// entry — may carry token bytes; everything else is non-secret. + envs: Vec<(String, String)>, +} + +impl fmt::Debug for GitCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Redact the one secret-bearing env value so a stray debug print is safe. + let envs: Vec<(&str, &str)> = self + .envs + .iter() + .map(|(k, v)| { + if k == SECRET_ENV { + (k.as_str(), REDACTED) + } else { + (k.as_str(), v.as_str()) + } + }) + .collect(); + f.debug_struct("GitCommand") + .field("args", &self.args) + .field("envs", &envs) + .finish() + } +} + +/// Builds the hardened argv + environment for one git call. When `token` is +/// `Some`, installs the env-only credential helper (with a leading reset of any +/// inherited helper) and places the token in the [`SECRET_ENV`] value only. +fn build_git(scheme: &str, subargs: &[&str], token: Option<&str>) -> GitCommand { + let mut args: Vec = GIT_SEC_ARGS.iter().map(|s| (*s).to_string()).collect(); + args.extend(subargs.iter().map(|s| (*s).to_string())); + + // Runtime-only config (never written to any repo's config file). + let mut config: Vec<(String, String)> = vec![ + // Pin transport: deny everything, then re-allow exactly the remote's + // scheme so a redirect cannot switch git onto a dangerous transport. + ("protocol.allow".to_string(), "never".to_string()), + (format!("protocol.{scheme}.allow"), "always".to_string()), + ]; + + let mut envs: Vec<(String, String)> = vec![ + ("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()), + // Deny the operator's global/system config: the child reads only the + // repo-local config of the daemon-controlled directory it runs in + // (see the module docs — this makes the isolation hold by + // construction). `GIT_CONFIG_NOSYSTEM=1` belts-and-braces the + // `GIT_CONFIG_SYSTEM=/dev/null` redirect: either alone denies the + // system config, and NOSYSTEM also covers builds that ignore the path + // override. + ("GIT_CONFIG_GLOBAL".to_string(), "/dev/null".to_string()), + ("GIT_CONFIG_SYSTEM".to_string(), "/dev/null".to_string()), + ("GIT_CONFIG_NOSYSTEM".to_string(), "1".to_string()), + ]; + + if let Some(secret) = token { + // Reset the accumulated helper list (clears inherited helpers such as a + // host `gh auth git-credential`), then install ours as the only helper. + config.push(("credential.helper".to_string(), String::new())); + config.push(("credential.helper".to_string(), credential_helper_script())); + // The plaintext lives only here, only for this child's lifetime. + envs.push((SECRET_ENV.to_string(), secret.to_string())); + } + + envs.push(("GIT_CONFIG_COUNT".to_string(), config.len().to_string())); + for (i, (key, value)) in config.into_iter().enumerate() { + envs.push((format!("GIT_CONFIG_KEY_{i}"), key)); + envs.push((format!("GIT_CONFIG_VALUE_{i}"), value)); + } + + GitCommand { args, envs } +} + +/// The captured, already-scrubbed result of a git child. +#[derive(Debug)] +struct GitOutput { + success: bool, + code: Option, + stdout: String, + stderr: String, +} + +/// Drains one child stream line-by-line, forwarding each line to `tx`. Reading +/// on a dedicated thread per stream avoids the classic pipe-buffer deadlock when +/// git writes a lot to both stdout and stderr. +fn read_lines(reader: R, stream: OutputStream, tx: mpsc::Sender<(OutputStream, String)>) { + let mut buffered = BufReader::new(reader); + let mut raw = Vec::new(); + loop { + raw.clear(); + match buffered.read_until(b'\n', &mut raw) { + Ok(0) => break, + Ok(_) => { + while matches!(raw.last(), Some(b'\n') | Some(b'\r')) { + raw.pop(); + } + let line = String::from_utf8_lossy(&raw).into_owned(); + if tx.send((stream, line)).is_err() { + break; + } + } + Err(_) => break, + } + } +} + +/// Runs one hardened git command in `cwd`, streaming each output line (scrubbed) +/// to `on_line` and returning the captured, scrubbed output. `remote` is used +/// only to derive the transport scheme to pin; `token`, when present, is +/// injected env-only. +/// +/// A non-zero exit is **not** an error here — the caller decides via [`require`] +/// — so callers that want to inspect a failure (e.g. probing for an upstream) +/// can do so. +fn run_git( + cwd: &Path, + remote: &str, + operation: &str, + subargs: &[&str], + token: Option<&SecretString>, + mut on_line: impl FnMut(OutputStream, &str), +) -> Result { + let exposed: Option<&str> = token.map(SecretString::expose_secret); + let spec = build_git(url_scheme(remote), subargs, exposed); + + let mut command = Command::new("git"); + command + .args(&spec.args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in &spec.envs { + command.env(key, value); + } + + let mut child = command.spawn().map_err(|source| GitError::Spawn { + operation: operation.to_string(), + source, + })?; + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + let (tx, rx) = mpsc::channel::<(OutputStream, String)>(); + let mut out_buf = String::new(); + let mut err_buf = String::new(); + + std::thread::scope(|scope| { + let tx_out = tx.clone(); + scope.spawn(move || read_lines(stdout, OutputStream::Stdout, tx_out)); + let tx_err = tx.clone(); + scope.spawn(move || read_lines(stderr, OutputStream::Stderr, tx_err)); + // Drop the original sender so `rx` ends once both readers finish. + drop(tx); + + for (stream, line) in rx { + let scrubbed = scrub(&line, exposed); + on_line(stream, scrubbed.as_ref()); + let buf = match stream { + OutputStream::Stdout => &mut out_buf, + OutputStream::Stderr => &mut err_buf, + }; + buf.push_str(scrubbed.as_ref()); + buf.push('\n'); + } + }); + + let status = child.wait().map_err(|source| GitError::Spawn { + operation: operation.to_string(), + source, + })?; + + Ok(GitOutput { + success: status.success(), + code: status.code(), + stdout: out_buf, + stderr: err_buf, + }) +} + +/// Turns a non-zero git exit into a [`GitError::Failed`]; passes success +/// through. The embedded stderr is already token-scrubbed. +fn require(out: GitOutput, operation: &str) -> Result { + if out.success { + return Ok(out); + } + let stderr = { + let trimmed = out.stderr.trim(); + if trimmed.is_empty() { + "(no stderr)".to_string() + } else { + trimmed.to_string() + } + }; + Err(GitError::Failed { + operation: operation.to_string(), + status: match out.code { + Some(code) => format!("exit status {code}"), + None => "terminated by signal".to_string(), + }, + stderr, + }) +} + +/// Parses a `git ls-remote --symref origin HEAD` line of the form +/// `ref: refs/heads/\tHEAD` into ``. +fn parse_symref(line: &str) -> Option { + let rest = line.strip_prefix("ref:")?.trim_start(); + let refname = rest.split_whitespace().next()?; + refname.strip_prefix("refs/heads/").map(str::to_string) +} + +/// A unique temp sibling of `dest` inside `parent`, on the same filesystem so +/// the finalizing rename is atomic. +fn unique_temp(parent: &Path, dest: &Path) -> PathBuf { + let name = dest.file_name().and_then(|n| n.to_str()).unwrap_or("repo"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + parent.join(format!(".{name}.gitops-{}-{nanos}.tmp", std::process::id())) +} + +/// An authenticated git working tree for a session repository. +/// +/// `remote` is always the **clean** URL (no credentials); authentication is +/// supplied per operation as an [`Option<&SecretString>`] and injected env-only. +/// Passing `None` runs an unauthenticated operation (local-only, or against a +/// transport that needs no credentials, e.g. `file://` fixtures). +#[derive(Debug)] +#[must_use] +pub struct Repo { + work: PathBuf, + remote: String, +} + +impl Repo { + /// Clones `remote` into `dest` atomically: the clone lands in a temp sibling + /// of `dest` and is renamed into place only on success, so a failure leaves + /// **no** half-primed directory behind (spec R2.6). `dest` must not already + /// exist. Progress lines are streamed (scrubbed) to `on_line`. + pub fn clone( + remote: impl Into, + dest: impl AsRef, + token: Option<&SecretString>, + on_line: impl FnMut(OutputStream, &str), + ) -> Result { + let remote = remote.into(); + let dest = dest.as_ref(); + + if dest.exists() { + return Err(GitError::DestinationExists { + path: dest.display().to_string(), + }); + } + + let parent = dest + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|source| GitError::Io { + operation: "clone".to_string(), + source, + })?; + + let temp = unique_temp(parent, dest); + let temp_str = temp.to_str().ok_or_else(|| GitError::NonUtf8Path { + path: temp.display().to_string(), + })?; + + let outcome = run_git( + parent, + &remote, + "clone", + &["clone", "--origin", "origin", &remote, temp_str], + token, + on_line, + ) + .and_then(|out| require(out, "clone")); + + match outcome { + Ok(_) => { + fs::rename(&temp, dest).map_err(|source| { + let _ = fs::remove_dir_all(&temp); + GitError::Io { + operation: "clone (finalize rename)".to_string(), + source, + } + })?; + Ok(Repo { + work: dest.to_path_buf(), + remote, + }) + } + Err(err) => { + // Leave nothing behind on failure (R2.6). + let _ = fs::remove_dir_all(&temp); + Err(err) + } + } + } + + /// Wraps an already-cloned working tree at `work` whose `origin` points at + /// the clean URL `remote`. Does no I/O; use for adopt-local flows and to + /// operate on a tree produced elsewhere. + pub fn open(work: impl Into, remote: impl Into) -> Repo { + Repo { + work: work.into(), + remote: remote.into(), + } + } + + /// The working-tree path. + #[must_use] + pub fn work_dir(&self) -> &Path { + &self.work + } + + /// The clean remote URL bound to `origin`. + #[must_use] + pub fn remote(&self) -> &str { + &self.remote + } + + /// Fetches from `origin`, pruning deleted remote branches. Streams progress. + pub fn fetch( + &self, + token: Option<&SecretString>, + on_line: impl FnMut(OutputStream, &str), + ) -> Result<(), GitError> { + require( + self.git("fetch", &["fetch", "--prune", "origin"], token, on_line)?, + "fetch", + )?; + Ok(()) + } + + /// Fast-forwards the current branch from its upstream. Streams progress. + pub fn pull( + &self, + token: Option<&SecretString>, + on_line: impl FnMut(OutputStream, &str), + ) -> Result<(), GitError> { + require( + self.git("pull", &["pull", "--ff-only"], token, on_line)?, + "pull", + )?; + Ok(()) + } + + /// Pushes `branch` to `origin`, setting it as the upstream. Pushing is always + /// explicit (spec R3.4); nothing here is called implicitly on branch + /// creation. Streams progress. + pub fn push( + &self, + token: Option<&SecretString>, + branch: &str, + on_line: impl FnMut(OutputStream, &str), + ) -> Result<(), GitError> { + // Option-injection guard: `branch` comes from `rev-parse`/the caller, + // so refuse a leading-dash name and additionally separate it from the + // options with `--` so git parses it as a refspec, never a flag. + reject_option_like("branch", branch)?; + require( + self.git( + "push", + &["push", "--set-upstream", "origin", "--", branch], + token, + on_line, + )?, + "push", + )?; + Ok(()) + } + + /// Puts the working tree on `branch`, checkout-or-create (spec R2.2): + /// + /// * if `branch` exists on the remote, fetch and check it out as a tracking + /// branch; + /// * otherwise create it locally from `base` (defaulting to the remote's + /// default branch) **without pushing** (spec R2.5). If the base is absent + /// on the remote, fails with [`GitError::BaseBranchNotFound`] (R2.6). + pub fn checkout_or_create( + &self, + token: Option<&SecretString>, + branch: &str, + base: Option<&str>, + mut on_line: impl FnMut(OutputStream, &str), + ) -> Result { + // The branch/base become bare `checkout -B/-b ` arguments; refuse + // a leading-dash name up front (option-injection guard). + reject_option_like("branch", branch)?; + if self.remote_has_branch(token, branch)? { + let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"); + require( + self.git( + "fetch", + &["fetch", "--no-tags", "origin", &refspec], + token, + &mut on_line, + )?, + "fetch", + )?; + let origin_ref = format!("origin/{branch}"); + require( + self.git( + "checkout", + &["checkout", "-B", branch, "--track", &origin_ref], + None, + &mut on_line, + )?, + "checkout", + )?; + return Ok(CheckoutOutcome::Checkout); + } + + let base = match base { + Some(base) => base.to_string(), + None => self.default_branch(token)?, + }; + reject_option_like("base", &base)?; + if !self.remote_has_branch(token, &base)? { + return Err(GitError::BaseBranchNotFound { branch: base }); + } + let refspec = format!("+refs/heads/{base}:refs/remotes/origin/{base}"); + require( + self.git( + "fetch", + &["fetch", "--no-tags", "origin", &refspec], + token, + &mut on_line, + )?, + "fetch", + )?; + let origin_base = format!("origin/{base}"); + // `--no-track`: a created branch has no upstream until it is explicitly + // pushed, which is exactly what feeds the PR-able signal. + require( + self.git( + "checkout", + &["checkout", "-b", branch, "--no-track", &origin_base], + None, + &mut on_line, + )?, + "checkout", + )?; + Ok(CheckoutOutcome::CreatedFromBase) + } + + /// The name of the currently checked-out branch. Fails on a detached HEAD. + pub fn current_branch(&self) -> Result { + let out = require( + self.git_quiet("rev-parse", &["rev-parse", "--abbrev-ref", "HEAD"], None)?, + "rev-parse", + )?; + let branch = out.stdout.trim(); + if branch.is_empty() || branch == "HEAD" { + return Err(GitError::Unparsable { + what: "current branch (detached HEAD?)".to_string(), + }); + } + Ok(branch.to_string()) + } + + /// The remote's default branch, via `ls-remote --symref origin HEAD`. + /// Authenticated (private repos need the token to answer). + pub fn default_branch(&self, token: Option<&SecretString>) -> Result { + let out = require( + self.git_quiet( + "ls-remote", + &["ls-remote", "--symref", "origin", "HEAD"], + token, + )?, + "ls-remote", + )?; + out.stdout + .lines() + .find_map(parse_symref) + .ok_or_else(|| GitError::Unparsable { + what: "remote default branch".to_string(), + }) + } + + /// How the current branch relates to its upstream (spec R4.6). Returns + /// [`Ahead::NoUpstream`] when the branch has never been pushed. + pub fn ahead_of_upstream(&self) -> Result { + let upstream = self.git_quiet( + "rev-parse", + &[ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + None, + )?; + if !upstream.success { + return Ok(Ahead::NoUpstream); + } + let out = require( + self.git_quiet( + "rev-list", + &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], + None, + )?, + "rev-list", + )?; + // Output is "\t" (left = upstream side, right = HEAD). + let mut counts = out.stdout.split_whitespace(); + let behind = counts.next().and_then(|n| n.parse().ok()); + let ahead = counts.next().and_then(|n| n.parse().ok()); + match (behind, ahead) { + (Some(behind), Some(ahead)) => Ok(Ahead::Tracking { ahead, behind }), + _ => Err(GitError::Unparsable { + what: "ahead/behind commit counts".to_string(), + }), + } + } + + /// Whether `origin` advertises a head named `branch`. + fn remote_has_branch( + &self, + token: Option<&SecretString>, + branch: &str, + ) -> Result { + reject_option_like("branch", branch)?; + let out = require( + self.git_quiet( + "ls-remote", + &["ls-remote", "--heads", "origin", "--", branch], + token, + )?, + "ls-remote", + )?; + Ok(!out.stdout.trim().is_empty()) + } + + /// Runs a git command in the working tree, streaming output to `on_line`. + fn git( + &self, + operation: &str, + subargs: &[&str], + token: Option<&SecretString>, + on_line: impl FnMut(OutputStream, &str), + ) -> Result { + run_git(&self.work, &self.remote, operation, subargs, token, on_line) + } + + /// Runs a git command in the working tree, discarding streamed output (used + /// for value-returning probes like `rev-parse`/`ls-remote`). + fn git_quiet( + &self, + operation: &str, + subargs: &[&str], + token: Option<&SecretString>, + ) -> Result { + self.git(operation, subargs, token, |_, _| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secret::{REDACTED, SecretString}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + + /// Runs `git` with a fixed identity, asserting success — for building + /// fixtures and making commits in tests. + fn git_run(args: &[&str], cwd: &Path) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "tester") + .env("GIT_AUTHOR_EMAIL", "tester@example.com") + .env("GIT_COMMITTER_NAME", "tester") + .env("GIT_COMMITTER_EMAIL", "tester@example.com") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed in {cwd:?}"); + } + + /// Creates a bare `file://` remote under `root` with a single commit on + /// `default_branch`; returns `(bare_path, file_url)`. + fn make_bare_remote(root: &Path, default_branch: &str) -> (PathBuf, String) { + let seed = root.join("seed"); + fs::create_dir_all(&seed).expect("seed dir"); + git_run(&["init", "-q", "-b", default_branch, "."], &seed); + fs::write(seed.join("README.md"), b"seed\n").expect("write readme"); + git_run(&["add", "README.md"], &seed); + git_run(&["commit", "-q", "-m", "initial"], &seed); + + let bare = root.join("remote.git"); + git_run( + &[ + "clone", + "-q", + "--bare", + seed.to_str().expect("utf-8"), + bare.to_str().expect("utf-8"), + ], + root, + ); + let url = format!("file://{}", bare.display()); + (bare, url) + } + + /// Recursively asserts no file under `dir` (including `.git/config`) contains + /// the token bytes. Only the `test-support` mock tests inject a real token, + /// so the helper is gated with them. + #[cfg(feature = "test-support")] + fn assert_no_token_on_disk(dir: &Path, token: &str) { + fn contains(hay: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && hay.windows(needle.len()).any(|w| w == needle) + } + let needle = token.as_bytes(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(path) = stack.pop() { + let meta = fs::symlink_metadata(&path).expect("stat"); + if meta.file_type().is_symlink() { + continue; + } + if meta.is_dir() { + for entry in fs::read_dir(&path).expect("read_dir") { + stack.push(entry.expect("dir entry").path()); + } + } else if meta.is_file() { + let bytes = fs::read(&path).unwrap_or_default(); + assert!( + !contains(&bytes, needle), + "token bytes found on disk in {}", + path.display() + ); + } + } + } + + #[test] + fn option_like_ref_names_are_refused_before_any_git_runs() { + // A leading-dash branch/base would be parsed by git as an option once + // it reached `git push … ` / `git checkout -B `; the + // boundary guard refuses it up front (no working tree needed: it fails + // before spawning git). + let repo = Repo::open("/nonexistent", "file:///unused"); + let err = repo + .push(None, "-oProxyCommand=evil", |_, _| {}) + .expect_err("dash-leading branch must be refused"); + assert!( + matches!(err, GitError::UnsafeRefName { ref kind, .. } if kind == "branch"), + "unexpected error: {err:?}" + ); + let err = repo + .checkout_or_create(None, "--upload-pack=x", Some("main"), |_, _| {}) + .expect_err("dash-leading branch must be refused"); + assert!(matches!(err, GitError::UnsafeRefName { .. }), "{err:?}"); + } + + #[test] + fn scrub_masks_only_the_current_token() { + assert_eq!( + scrub("password=ghs_secret trailing", Some("ghs_secret")).as_ref(), + "password=[REDACTED:gh] trailing" + ); + assert_eq!( + scrub("nothing to see", Some("ghs_secret")).as_ref(), + "nothing to see" + ); + assert_eq!(scrub("ghs_secret", None).as_ref(), "ghs_secret"); + assert_eq!(scrub("unchanged", Some("")).as_ref(), "unchanged"); + } + + #[test] + fn build_git_keeps_the_token_out_of_argv_and_config() { + let token = "ghs_super_secret_TOKEN_value_0xDEAD"; + let cmd = build_git( + "https", + &["clone", "https://github.com/octo/hello.git", "/dest"], + Some(token), + ); + + // The argv is entirely non-secret. + for arg in &cmd.args { + assert!(!arg.contains(token), "token leaked into argv: {arg}"); + } + + // Exactly one env value carries the token, and it is the dedicated var. + let secret_bearing: Vec<_> = cmd.envs.iter().filter(|(_, v)| v.contains(token)).collect(); + assert_eq!( + secret_bearing.len(), + 1, + "token appears in more than one place" + ); + assert_eq!(secret_bearing[0].0, SECRET_ENV); + assert_eq!(secret_bearing[0].1, token); + + // Reconstruct the injected config and assert its shape. + let count: usize = cmd + .envs + .iter() + .find_map(|(k, v)| (k == "GIT_CONFIG_COUNT").then(|| v.parse().expect("count"))) + .expect("GIT_CONFIG_COUNT present"); + let value_of = |name: &str| { + cmd.envs + .iter() + .find_map(|(k, v)| (k == name).then(|| v.clone())) + }; + let config: Vec<(String, String)> = (0..count) + .map(|i| { + ( + value_of(&format!("GIT_CONFIG_KEY_{i}")).expect("key"), + value_of(&format!("GIT_CONFIG_VALUE_{i}")).expect("value"), + ) + }) + .collect(); + + // Protocol pinned to https; no config value carries the token. + assert!( + config + .iter() + .any(|(k, v)| k == "protocol.allow" && v == "never") + ); + assert!( + config + .iter() + .any(|(k, v)| k == "protocol.https.allow" && v == "always") + ); + for (_, v) in &config { + assert!(!v.contains(token), "token leaked into git config value"); + } + + // Two credential.helper entries: a reset (empty) then our helper, which + // references the env var by NAME, not the token. + let helpers: Vec<&String> = config + .iter() + .filter(|(k, _)| k == "credential.helper") + .map(|(_, v)| v) + .collect(); + assert_eq!(helpers.len(), 2, "expected reset + helper"); + assert!( + helpers[0].is_empty(), + "first helper entry must reset the list" + ); + assert!(helpers[1].contains(SECRET_ENV)); + assert!(!helpers[1].contains(token)); + + // The operator's global/system config is denied to the child, so the + // credentialed leg reads only the repo-local config of the directory + // it runs in (regression: this must hold by construction, not by the + // host environment happening to be clean). + for var in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] { + assert_eq!( + value_of(var).as_deref(), + Some("/dev/null"), + "{var} must be pinned to /dev/null" + ); + } + assert_eq!( + value_of("GIT_CONFIG_NOSYSTEM").as_deref(), + Some("1"), + "GIT_CONFIG_NOSYSTEM must deny the system config outright" + ); + + // Debug is redaction-safe. + let dbg = format!("{cmd:?}"); + assert!(!dbg.contains(token), "token leaked in Debug: {dbg}"); + assert!(dbg.contains(REDACTED)); + } + + #[test] + fn clone_then_checkout_or_create_over_file_url() { + let tmp = tempfile::tempdir().expect("tmp"); + let (bare, url) = make_bare_remote(tmp.path(), "main"); + // An existing remote branch to check out. + git_run(&["branch", "feat/exists", "main"], &bare); + + let dest = tmp.path().join("work"); + let repo = Repo::clone(url, &dest, None, |_, _| {}).expect("clone"); + assert_eq!(repo.current_branch().expect("branch"), "main"); + assert_eq!(repo.default_branch(None).expect("default"), "main"); + + // Existing remote branch -> checkout. + let outcome = repo + .checkout_or_create(None, "feat/exists", None, |_, _| {}) + .expect("checkout existing"); + assert_eq!(outcome, CheckoutOutcome::Checkout); + assert_eq!(repo.current_branch().unwrap(), "feat/exists"); + + // Absent remote branch -> created from base, no upstream, PR-able. + let outcome = repo + .checkout_or_create(None, "feat/new", Some("main"), |_, _| {}) + .expect("create new"); + assert_eq!(outcome, CheckoutOutcome::CreatedFromBase); + assert_eq!(repo.current_branch().unwrap(), "feat/new"); + assert_eq!(repo.ahead_of_upstream().unwrap(), Ahead::NoUpstream); + assert!(repo.ahead_of_upstream().unwrap().is_pr_able()); + } + + #[test] + fn absent_base_branch_is_a_clean_error() { + let tmp = tempfile::tempdir().expect("tmp"); + let (_bare, url) = make_bare_remote(tmp.path(), "main"); + let dest = tmp.path().join("work"); + let repo = Repo::clone(url, &dest, None, |_, _| {}).expect("clone"); + + let err = repo + .checkout_or_create(None, "feat/x", Some("no-such-base"), |_, _| {}) + .expect_err("missing base must fail"); + assert!( + matches!(err, GitError::BaseBranchNotFound { ref branch } if branch == "no-such-base"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn ahead_detection_counts_local_commits() { + let tmp = tempfile::tempdir().expect("tmp"); + let (bare, url) = make_bare_remote(tmp.path(), "main"); + git_run(&["branch", "feat/x", "main"], &bare); + + let dest = tmp.path().join("work"); + let repo = Repo::clone(url, &dest, None, |_, _| {}).expect("clone"); + repo.checkout_or_create(None, "feat/x", None, |_, _| {}) + .expect("checkout"); + assert_eq!( + repo.ahead_of_upstream().unwrap(), + Ahead::Tracking { + ahead: 0, + behind: 0 + } + ); + + fs::write(dest.join("local.txt"), b"local\n").expect("write"); + git_run(&["add", "local.txt"], &dest); + git_run(&["commit", "-q", "-m", "local work"], &dest); + assert_eq!( + repo.ahead_of_upstream().unwrap(), + Ahead::Tracking { + ahead: 1, + behind: 0 + } + ); + assert!(repo.ahead_of_upstream().unwrap().is_pr_able()); + } + + #[test] + fn failed_clone_leaves_no_directory() { + let tmp = tempfile::tempdir().expect("tmp"); + let dest = tmp.path().join("work"); + let bogus = format!("file://{}/does-not-exist.git", tmp.path().display()); + + let err = Repo::clone(bogus, &dest, None, |_, _| {}).expect_err("clone must fail"); + assert!( + matches!(err, GitError::Failed { .. }), + "unexpected: {err:?}" + ); + assert!(!dest.exists(), "failed clone left a directory behind"); + // No leftover temp sibling either. + let leftovers: Vec = fs::read_dir(tmp.path()) + .expect("read_dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + !leftovers.iter().any(|n| n.contains("gitops-")), + "temp sibling not cleaned up: {leftovers:?}" + ); + } + + #[test] + fn scrubber_masks_a_token_echoed_by_a_scripted_command() { + let tmp = tempfile::tempdir().expect("tmp"); + let work = tmp.path(); + git_run(&["init", "-q", "-b", "main", "."], work); + + let token = "ghs_echoed_secret_VALUE_zzz"; + // A scripted git alias echoes whatever the credential env var holds — the + // injected token — proving the stream scrubber masks it before anyone + // sees it. + let alias = format!("alias.leak=!printf 'the token is %s end\\n' \"${{{SECRET_ENV}-}}\""); + let secret = SecretString::new(token); + + let mut streamed = String::new(); + let out = run_git( + work, + "file:///unused", + "leak", + &["-c", &alias, "leak"], + Some(&secret), + |_, line| { + streamed.push_str(line); + streamed.push('\n'); + }, + ) + .expect("run scripted alias"); + + assert!(out.success, "alias should run; stderr: {}", out.stderr); + assert!( + streamed.contains(REDACTED), + "expected redaction marker in stream: {streamed}" + ); + assert!( + !streamed.contains(token), + "token leaked to the stream callback: {streamed}" + ); + assert!( + !out.stdout.contains(token), + "token leaked into captured stdout" + ); + assert!(out.stdout.contains(REDACTED)); + } + + // Tests that exercise the auth-enforcing smart-HTTP mock. They prove the + // env-only credential helper actually fires: the mock rejects any request + // that does not present the exact injected token. + #[cfg(feature = "test-support")] + mod mock { + use super::{assert_no_token_on_disk, git_run}; + use crate::gitops::{CheckoutOutcome, GitError, Repo}; + use crate::secret::SecretString; + use crate::testing::{GitAuth, MockGithub}; + + fn repo_url(mock: &MockGithub, owner: &str, repo: &str) -> String { + // base_url() ends with '/'. + format!("{}{owner}/{repo}.git", mock.base_url()) + } + + #[tokio::test] + async fn clone_checkout_and_push_succeed_with_the_injected_token() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octo", "hello", "main") + .expect("bare repo"); + let token = "ghs_injected_secret_0xABC123"; + // Only the exact injected credentials are accepted -> success proves + // the helper fired and carried our token. + mock.set_git_auth(GitAuth::Exact { + username: "x-access-token".to_string(), + password: token.to_string(), + }); + + let url = repo_url(&mock, "octo", "hello"); + let root = tempfile::tempdir().expect("tmp"); + let dest = root.path().join("hello"); + let git_root = mock.git_root().to_path_buf(); + + let dest_for_work = dest.clone(); + // Run the blocking git work off the async runtime so the mock keeps + // serving its own connections. + let repo = tokio::task::spawn_blocking(move || -> Result { + let secret = SecretString::new(token); + let repo = Repo::clone(url, &dest_for_work, Some(&secret), |_, _| {})?; + let outcome = + repo.checkout_or_create(Some(&secret), "feat/x", Some("main"), |_, _| {})?; + assert_eq!(outcome, CheckoutOutcome::CreatedFromBase); + + std::fs::write(dest_for_work.join("new.txt"), b"work\n").expect("write"); + git_run(&["add", "new.txt"], &dest_for_work); + git_run(&["commit", "-q", "-m", "session work"], &dest_for_work); + + repo.push(Some(&secret), "feat/x", |_, _| {})?; + Ok(repo) + }) + .await + .expect("join") + .expect("git ops succeed with the helper"); + + // The branch is now on the remote (push really landed). + let bare = git_root.join("octo").join("hello.git"); + let show = std::process::Command::new("git") + .args([ + "--git-dir", + bare.to_str().expect("utf-8"), + "show-ref", + "refs/heads/feat/x", + ]) + .output() + .expect("show-ref"); + assert!(show.status.success(), "pushed branch missing on the remote"); + + // Nothing on disk — including .git/config — holds token bytes. + assert_no_token_on_disk(repo.work_dir(), token); + } + + #[tokio::test] + async fn wrong_token_is_rejected_and_leaves_no_directory() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octo", "hello", "main") + .expect("bare repo"); + mock.set_git_auth(GitAuth::Exact { + username: "x-access-token".to_string(), + password: "the-correct-token".to_string(), + }); + + let url = repo_url(&mock, "octo", "hello"); + let root = tempfile::tempdir().expect("tmp"); + let dest = root.path().join("hello"); + + let dest_for_clone = dest.clone(); + let result = tokio::task::spawn_blocking(move || { + let wrong = SecretString::new("a-wrong-token"); + Repo::clone(url, &dest_for_clone, Some(&wrong), |_, _| {}) + }) + .await + .expect("join"); + + assert!(result.is_err(), "clone with a wrong token must fail"); + assert!(!dest.exists(), "failed clone must leave no directory"); + } + + #[tokio::test] + async fn unauthenticated_clone_is_rejected() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octo", "hello", "main") + .expect("bare repo"); + mock.set_git_auth(GitAuth::Exact { + username: "x-access-token".to_string(), + password: "the-correct-token".to_string(), + }); + + let url = repo_url(&mock, "octo", "hello"); + let root = tempfile::tempdir().expect("tmp"); + let dest = root.path().join("hello"); + + let dest_for_clone = dest.clone(); + let result = tokio::task::spawn_blocking(move || { + // No token at all -> no credential helper wired -> 401 at the gate. + Repo::clone(url, &dest_for_clone, None, |_, _| {}) + }) + .await + .expect("join"); + + assert!(result.is_err(), "unauthenticated clone must fail"); + assert!(!dest.exists(), "failed clone must leave no directory"); + } + } +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs new file mode 100644 index 000000000..e176c3b84 --- /dev/null +++ b/crates/github/src/lib.rs @@ -0,0 +1,82 @@ +//! Pure domain types for GitHub-integrated `minimald` sessions (spec 10). +//! +//! This crate's default feature set keeps clear of any HTTP stack: it holds the +//! parsed value types, the typed permission model, the secret newtype, the +//! daemon configuration, the session-`attrs` codec, the on-disk grant store, +//! and the error taxonomy that both `minimald` and the `min` client agree on. +//! Local filesystem I/O for the grant store (`serde`, `serde_json`, `chrono`) +//! is part of the default build; no network I/O, and no reqwest, is pulled in +//! until the future `client` feature lands. Keeping the dependency set light +//! lets `mfile` and `minimal` depend on this crate cheaply. +//! +//! The device-flow + GitHub API client lands later behind a `client` feature +//! (see `Cargo.toml`); code here is structured so that module can be added +//! without reshaping the public surface below. +//! +//! # Security posture +//! +//! Per the spec's load-bearing decision, a GitHub token lives only in the +//! daemon and never enters a sandbox. Two rules this crate enforces mechanically: +//! +//! * The only carrier for token material is [`SecretString`], which redacts in +//! `Debug`/`Display` and zeroizes on drop and has no `serde` derive. A bare +//! `String` holding a token is therefore a review-rejectable pattern. +//! * The `workflows` GitHub permission is unrepresentable: [`Scope`] has no such +//! variant, so no code path can request it (spec NG6). + +pub mod attrs; +pub mod config; +pub mod error; +pub mod facade; +pub mod gitops; +pub mod scopes; +pub mod secret; +pub mod store; +pub mod types; + +// The GitHub REST client (user, App-installation check, default branch, +// pull-request list/create/get). Gated behind `client` so the default, +// I/O-free build never pulls in reqwest. +#[cfg(feature = "client")] +pub mod rest; + +// The OAuth device-flow client (start/poll/fetch-user, plus assembling a +// complete `Grant`). Also gated behind `client`; independent of `rest` so the +// two stay separately testable. +#[cfg(feature = "client")] +pub mod device_flow; + +// The token-refresh state machine (`GrantManager`): per-grant single-flight, +// persist-before-use rotation, and the sticky needs-reauth transition. Gated +// behind `client` with the rest of the HTTP stack, since its production +// backend (`HttpRefreshBackend`) refreshes over HTTP. +#[cfg(feature = "client")] +pub mod refresh; + +// A programmable, in-process mock GitHub (OAuth device flow + REST + an +// auth-enforcing git smart-HTTP endpoint) plus the `github-mock` binary. It is +// test-only scaffolding — never a production dependency — so it is gated behind +// the `test-support` feature and carries no weight in the default build. +#[cfg(feature = "test-support")] +pub mod testing; + +pub use config::GithubConfig; +#[cfg(feature = "client")] +pub use device_flow::{ + AccessTokenPair, DeviceAuthorization, DeviceFlowClient, GithubUser, assemble_grant, +}; +pub use error::Error; +#[cfg(feature = "client")] +pub use refresh::{ + GrantManager, GrantPhase, GrantTokenProvider, HttpRefreshBackend, RefreshBackend, RefreshError, + RefreshFailure, with_reauth_retry, +}; +#[cfg(feature = "client")] +pub use rest::{ + Installation, InstallationAccount, PullRef, PullRequest, Repository, RestClient, StaticToken, + TokenProvider, User, +}; +pub use scopes::{Permission, Scope, ScopeSet}; +pub use secret::SecretString; +pub use store::{Grant, GrantState, GrantStore, GrantSummary}; +pub use types::{AuthChoice, BranchSpec, GrantId, RepoSpec}; diff --git a/crates/github/src/refresh.rs b/crates/github/src/refresh.rs new file mode 100644 index 000000000..7f49a6a37 --- /dev/null +++ b/crates/github/src/refresh.rs @@ -0,0 +1,1334 @@ +//! Token-refresh state machine for stored GitHub grants (spec R1.2, R6.2) — +//! the auth hard core of the `client` feature. +//! +//! [`GrantManager`] owns the lifecycle of every stored [`Grant`]'s access +//! token. Per grant, the observable phases are +//! `Valid -> NearExpiry (<5 min) -> Refreshing -> Valid | NeedsReauth` +//! (see [`GrantPhase`]); the persisted states remain the two in +//! [`GrantState`], because `NearExpiry` is a pure function of the clock and +//! `Refreshing` is a pure function of the in-flight lock. +//! +//! [`GrantManager::token_for`] is **the only sanctioned token accessor in the +//! entire system**: every daemon-side consumer (REST, git ops, facade) must +//! obtain access tokens through it — directly, or via the +//! [`TokenProvider`]-implementing [`GrantTokenProvider`] handle — so that +//! refresh, rotation persistence, and the needs-reauth transition can never be +//! bypassed. Reading `Grant::access_token` off a [`GrantStore`] anywhere else +//! is a review-rejectable pattern. +//! +//! # Why the design is shaped this way +//! +//! * **Per-grant single-flight.** GitHub **rotates the refresh token on every +//! refresh**: the exchange consumes the presented refresh token and answers +//! with a new one. If two callers raced the exchange, the loser would hold a +//! consumed refresh token and the winner's rotation could be lost on disk — +//! either way the grant is bricked until re-auth. So each grant has one +//! `tokio::sync::Mutex`; concurrent [`GrantManager::token_for`] callers +//! queue on it, the first performs the refresh, and the rest re-read the +//! store under the lock and observe the already-rotated pair (N callers ⇒ +//! exactly one HTTP refresh). +//! * **Persist-before-use (write-ahead rotation).** The rotated +//! refresh+access pair is written — atomically and `fsync`'d, via +//! [`GrantStore::save`] — **before** the new access token is handed to any +//! caller. No token that is not durable on disk ever escapes this module, +//! so a crash at any point loses at most an access token (refreshable), and +//! the on-disk refresh chain is never behind a token that callers are +//! already using. If the persist fails, the new pair is discarded and the +//! caller gets an error instead of a token (fail closed). +//! * **Failure taxonomy** ([`RefreshFailure`]): transport errors and 5xx are +//! *transient* — retried a bounded number of times, and the grant stays +//! `Valid` (the refresh token was not consumed, so a later call simply +//! retries). `invalid_grant`/`bad_refresh_token` is *terminal*: the state +//! is persisted as `needs_reauth` and every subsequent call returns the +//! sticky, actionable [`RefreshError::AuthExpired`] — re-auth, never silent +//! failure (spec R1.2). Anything else OAuth-shaped is *fatal* for this +//! attempt but leaves the grant `Valid`. +//! * **Retry-once-on-401 for REST callers** ([`with_reauth_retry`]): a `401` +//! from GitHub despite a seemingly valid token (early revocation, clock +//! skew) surfaces from [`crate::RestClient`] as [`Error::NeedsReauth`]; the +//! helper forces one refresh ([`GrantManager::refresh_now`]) and retries the +//! call once. A second `401` is terminal. `refresh_now` carries a short +//! just-refreshed grace so a stampede of concurrent `401` retries collapses +//! into one rotation. +//! +//! # Security +//! +//! Tokens only ever travel as [`SecretString`]; no [`RefreshError`] variant, +//! `Debug` output, or log-worthy message in this module can carry token +//! material (errors carry logins, grant ids, statuses, and I/O causes only). + +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::sync::{Arc, Mutex as SyncMutex}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use reqwest::header::ACCEPT; +use serde::Deserialize; +use url::Url; + +use crate::device_flow::AccessTokenPair; +use crate::error::Error; +use crate::rest::TokenProvider; +use crate::secret::SecretString; +use crate::store::{Grant, GrantState, GrantStore}; +use crate::types::GrantId; + +/// `User-Agent` sent on refresh requests; GitHub requires one on all calls. +const USER_AGENT: &str = concat!("minimal-github-refresh/", env!("CARGO_PKG_VERSION")); + +/// How close to expiry an access token may get before [`GrantManager::token_for`] +/// refreshes it instead of returning it (the `NearExpiry` threshold, 5 minutes). +const NEAR_EXPIRY_SECS: i64 = 5 * 60; + +/// How recently a grant must have been refreshed for +/// [`GrantManager::refresh_now`] to skip the rotation and return the current +/// token. Collapses concurrent 401-retry stampedes into one rotation, and +/// terminates the retry-once loop: if a token refreshed seconds ago still +/// answers `401`, re-auth is genuinely required. +const FORCED_REFRESH_GRACE_SECS: i64 = 10; + +/// Bounded retry budget for [`RefreshFailure::Transient`] failures: total +/// attempts are `1 + TRANSIENT_RETRIES`. +const TRANSIENT_RETRIES: u32 = 2; + +/// Base backoff between transient-failure retries (multiplied by the attempt +/// number). Deliberately short: refresh normally runs minutes ahead of expiry, +/// so there is no need to be patient, only to not hammer. +const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(100); + +fn near_expiry_window() -> chrono::Duration { + chrono::Duration::seconds(NEAR_EXPIRY_SECS) +} + +fn forced_refresh_grace() -> chrono::Duration { + chrono::Duration::seconds(FORCED_REFRESH_GRACE_SECS) +} + +/// The observable lifecycle phase of a grant (spec R1.2), for status surfaces +/// like `min github status`. Only [`GrantState`]'s two states persist; the +/// other two are derived (see the module docs). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GrantPhase { + /// The access token is valid and not near expiry. + Valid, + /// The access token expires within the refresh threshold (5 minutes); the + /// next [`GrantManager::token_for`] will refresh before returning. + NearExpiry, + /// A refresh for this grant is in flight right now. + Refreshing, + /// The grant cannot be refreshed (persisted `needs_reauth`, or the + /// refresh token itself has expired); only `min github login` helps. + NeedsReauth, +} + +/// The phase of `grant` at time `now`, ignoring in-flight refreshes (which +/// only [`GrantManager::phase`] can observe). +#[must_use] +pub fn phase_of(grant: &Grant, now: DateTime) -> GrantPhase { + if grant.state == GrantState::NeedsReauth || grant.refresh_token_expires_at <= now { + // An expired refresh token cannot be exchanged; only re-auth helps. + GrantPhase::NeedsReauth + } else if grant.access_token_expires_at <= now + near_expiry_window() { + GrantPhase::NearExpiry + } else { + GrantPhase::Valid + } +} + +/// How one refresh-exchange attempt failed, as classified by a +/// [`RefreshBackend`]. The classification drives the state machine: only +/// `InvalidGrant` moves a grant to `needs_reauth`; only `Transient` is +/// retried. +#[derive(Debug)] +#[non_exhaustive] +pub enum RefreshFailure { + /// GitHub reported `invalid_grant`/`bad_refresh_token`: the refresh token + /// is expired, revoked, or already rotated away. Terminal — the grant + /// must go to `needs_reauth` (spec R1.2). + InvalidGrant, + /// A failure that did not consume the refresh token and may succeed on + /// retry: transport errors (DNS, TCP, TLS, timeout) and `5xx` responses. + /// The grant stays `Valid`. + Transient(Error), + /// A failure that will not improve on retry (malformed response, an OAuth + /// error other than `invalid_grant`, a non-5xx HTTP error). The grant + /// stays `Valid`; the attempt is simply reported. + Fatal(Error), +} + +/// Performs one refresh-token exchange. [`GrantManager`] is generic over this +/// so the state machine is testable with scripted backends while production +/// uses [`HttpRefreshBackend`] against GitHub's OAuth endpoint. +/// +/// Implementations must classify failures per [`RefreshFailure`]'s contract — +/// in particular, they must **never** report a transport-level failure as +/// [`RefreshFailure::InvalidGrant`], since that classification is what +/// invalidates a grant. +pub trait RefreshBackend: Send + Sync { + /// Exchanges `refresh_token` for a freshly rotated access + refresh pair. + fn refresh( + &self, + refresh_token: &SecretString, + ) -> impl Future> + Send; +} + +/// A shared backend refreshes exactly like the backend it wraps. +impl RefreshBackend for Arc { + fn refresh( + &self, + refresh_token: &SecretString, + ) -> impl Future> + Send { + (**self).refresh(refresh_token) + } +} + +/// The production [`RefreshBackend`]: `POST +/// {oauth_base}/login/oauth/access_token` with `grant_type=refresh_token`, +/// per GitHub's "Refreshing user access tokens" flow. `oauth_base` is +/// normally [`crate::GithubConfig::oauth_base`] (or a +/// `crate::testing::MockGithub` in tests); `client_id` is the GitHub App's +/// public client id (not a secret). +#[derive(Debug, Clone)] +pub struct HttpRefreshBackend { + http: reqwest::Client, + oauth_base: Url, + client_id: String, +} + +impl HttpRefreshBackend { + /// Builds a backend against `oauth_base` for the App named by `client_id`. + #[must_use] + pub fn new(oauth_base: Url, client_id: impl Into) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client construction is infallible with the enabled backends"), + oauth_base, + client_id: client_id.into(), + } + } +} + +impl RefreshBackend for HttpRefreshBackend { + async fn refresh( + &self, + refresh_token: &SecretString, + ) -> Result { + let transient = |reason: String| RefreshFailure::Transient(Error::Request { reason }); + let url = self + .oauth_base + .join("login/oauth/access_token") + .map_err(|e| { + RefreshFailure::Fatal(Error::Request { + reason: format!("invalid GitHub base URL: {e}"), + }) + })?; + let resp = self + .http + .post(url) + .header(ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .form(&[ + ("client_id", self.client_id.as_str()), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.expose_secret()), + ]) + .send() + .await + .map_err(|e| transient(e.to_string()))?; + let status = resp.status(); + let bytes = resp.bytes().await.map_err(|e| transient(e.to_string()))?; + if status.is_server_error() { + return Err(RefreshFailure::Transient(Error::UnexpectedStatus { + status: status.as_u16(), + message: error_message(&bytes), + })); + } + let wire: RefreshWire = serde_json::from_slice(&bytes).map_err(|e| { + if status.is_success() { + RefreshFailure::Fatal(Error::Decode { + reason: e.to_string(), + }) + } else { + RefreshFailure::Fatal(Error::UnexpectedStatus { + status: status.as_u16(), + message: error_message(&bytes), + }) + } + })?; + match wire.error.as_deref() { + // GitHub uses `bad_refresh_token` in places its docs say + // `invalid_grant`; both mean the same dead refresh token. + Some("invalid_grant" | "bad_refresh_token") => Err(RefreshFailure::InvalidGrant), + Some(other) => Err(RefreshFailure::Fatal(Error::UnexpectedStatus { + status: status.as_u16(), + message: format!( + "{other}: {}", + wire.error_description + .as_deref() + .unwrap_or("no further detail") + ), + })), + None if !status.is_success() => Err(RefreshFailure::Fatal(Error::UnexpectedStatus { + status: status.as_u16(), + message: error_message(&bytes), + })), + None => pair_from_wire(wire), + } + } +} + +/// Errors from [`GrantManager`]. Distinct from the crate-wide [`Error`] so the +/// sticky auth-expired condition can carry the login it names (making the +/// message actionable, spec R8.1); converts into [`Error`] (see the `From` +/// impl) for [`TokenProvider`] call sites. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RefreshError { + /// The grant's refresh token is expired or revoked; the state is persisted + /// as `needs_reauth` and this error repeats — stickily, without further + /// GitHub traffic — until the user re-authenticates (spec R1.2). + #[error("github auth expired for {login}; run 'min github login'")] + AuthExpired { + /// The GitHub login the dead grant belonged to. + login: String, + }, + + /// No grant with this id is stored. + #[error("no stored GitHub auth grant `{grant_id}`; run 'min github login'")] + UnknownGrant { + /// The grant id that was requested. + grant_id: String, + }, + + /// Reading the grant from the on-disk store failed. + #[error("reading GitHub auth grant `{grant_id}` from the grant store failed")] + Load { + /// The grant id whose file could not be read. + grant_id: String, + /// The underlying I/O failure. + #[source] + source: std::io::Error, + }, + + /// Persisting the rotated tokens failed. The freshly minted pair was + /// discarded rather than handed out (persist-before-use, fail closed). + #[error( + "persisting refreshed GitHub credentials for grant `{grant_id}` failed; \ + the refreshed tokens were discarded" + )] + Persist { + /// The grant id whose file could not be written. + grant_id: String, + /// The underlying I/O failure. + #[source] + source: std::io::Error, + }, + + /// The refresh exchange failed without invalidating the grant (transport, + /// 5xx after retries, malformed response, unexpected OAuth error). The + /// grant stays `Valid`; a later call retries. + #[error(transparent)] + Github(#[from] Error), +} + +impl From for Error { + fn from(err: RefreshError) -> Self { + match err { + RefreshError::Github(e) => e, + // Both mean "no usable stored auth": per `TokenProvider`'s + // contract, that must surface as `NeedsReauth` so REST callers + // handle exactly one auth-expiry variant. (The login-bearing + // message stays available to `token_for` callers.) + RefreshError::AuthExpired { .. } | RefreshError::UnknownGrant { .. } => { + Error::NeedsReauth + } + // `Error` has no storage variant; from a REST caller's viewpoint + // the request as a whole failed, so `Request` with the full I/O + // cause chain is the closest honest mapping (never any token + // material — these variants carry ids and I/O errors only). + other @ (RefreshError::Load { .. } | RefreshError::Persist { .. }) => Error::Request { + reason: chained_display(&other), + }, + } + } +} + +/// Renders an error with its full `source()` chain, `: `-separated, so a +/// mapped error keeps its root cause visible. +fn chained_display(err: &dyn std::error::Error) -> String { + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + out.push_str(": "); + out.push_str(&cause.to_string()); + source = cause.source(); + } + out +} + +/// Which freshness policy a token request runs under. +#[derive(Clone, Copy)] +enum RefreshPolicy { + /// Refresh only when the access token is within the near-expiry window. + IfNearExpiry, + /// Refresh unless the grant was refreshed within the forced-refresh + /// grace (the 401-retry path). + Forced, +} + +/// Shared state behind [`GrantManager`]'s cheap clones. +struct ManagerInner { + store: GrantStore, + backend: B, + /// One single-flight mutex per grant id. Entries are never removed: the + /// set of grant ids a daemon touches is small and bounded (spec NG2 — one + /// local user), so the map cannot grow meaningfully. + locks: SyncMutex>>>, +} + +/// The token-refresh state machine over a [`GrantStore`] (see the module docs +/// for the full contract). Cheap to clone; clones share state, so a process +/// must hold exactly one lineage of clones per store directory for the +/// single-flight guarantee to hold. +pub struct GrantManager { + inner: Arc>, +} + +impl Clone for GrantManager { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl fmt::Debug for GrantManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The backend is deliberately omitted: it needs no Debug bound, and + // nothing about it belongs in logs. + f.debug_struct("GrantManager") + .field("store", &self.inner.store) + .finish_non_exhaustive() + } +} + +impl GrantManager { + /// Builds a manager over `store`, refreshing through `backend`. + #[must_use] + pub fn new(store: GrantStore, backend: B) -> Self { + Self { + inner: Arc::new(ManagerInner { + store, + backend, + locks: SyncMutex::new(HashMap::new()), + }), + } + } + + /// The underlying grant store. + #[must_use] + pub fn store(&self) -> &GrantStore { + &self.inner.store + } + + /// A cloneable [`TokenProvider`] handle bound to one grant, for wiring a + /// [`crate::RestClient`] (or any other consumer of the trait) to this + /// manager. + #[must_use] + pub fn token_provider(&self, grant_id: GrantId) -> GrantTokenProvider { + GrantTokenProvider { + manager: self.clone(), + grant_id, + } + } + + /// The per-grant single-flight lock, created on first use. + fn lock_for(&self, grant_id: &GrantId) -> Arc> { + let mut locks = self.inner.locks.lock().expect("grant-lock map poisoned"); + Arc::clone(locks.entry(grant_id.clone()).or_default()) + } +} + +impl GrantManager { + /// Returns an access token for `grant_id` that is valid right now, + /// transparently refreshing (and rotating) first when the stored token is + /// within 5 minutes of expiry. **The only sanctioned token accessor** — + /// see the module docs. + /// + /// # Errors + /// + /// [`RefreshError::AuthExpired`] (sticky) once the grant needs re-auth; + /// [`RefreshError::UnknownGrant`]/[`RefreshError::Load`] when the grant + /// can't be read; [`RefreshError::Persist`] when the rotated pair can't + /// be made durable (no token is returned then — fail closed); + /// [`RefreshError::Github`] for refresh attempts that failed without + /// invalidating the grant. + pub async fn token_for(&self, grant_id: &GrantId) -> Result { + self.token_inner(grant_id, RefreshPolicy::IfNearExpiry) + .await + } + + /// Forces a refresh regardless of expiry — the 401-retry hook (see + /// [`with_reauth_retry`]) — unless the grant was already refreshed within + /// the last few seconds, in which case the current token is returned + /// unrotated (see [`FORCED_REFRESH_GRACE_SECS`] for why). + /// + /// # Errors + /// + /// As [`GrantManager::token_for`]. + pub async fn refresh_now(&self, grant_id: &GrantId) -> Result { + self.token_inner(grant_id, RefreshPolicy::Forced).await + } + + /// The grant's current [`GrantPhase`]. Advisory: the phase can change the + /// moment this returns (a refresh may start or finish concurrently). + /// + /// # Errors + /// + /// [`RefreshError::UnknownGrant`]/[`RefreshError::Load`] when the grant + /// can't be read. + pub async fn phase(&self, grant_id: &GrantId) -> Result { + let lock = self.lock_for(grant_id); + // Held => a `token_inner` call is in flight for this grant right now. + if lock.try_lock().is_err() { + return Ok(GrantPhase::Refreshing); + } + let grant = self.load(grant_id).await?; + Ok(phase_of(&grant, Utc::now())) + } + + async fn token_inner( + &self, + grant_id: &GrantId, + policy: RefreshPolicy, + ) -> Result { + let lock = self.lock_for(grant_id); + let _flight = lock.lock().await; + + // (Re-)read under the lock. A caller that queued behind a concurrent + // refresh observes the already-rotated, already-persisted pair here + // and returns without its own HTTP call — this line is the + // single-flight collapse. + let mut grant = self.load(grant_id).await?; + + if grant.state == GrantState::NeedsReauth { + // Sticky: no GitHub traffic once a grant needs re-auth. + return Err(RefreshError::AuthExpired { + login: grant.github_login, + }); + } + + let now = Utc::now(); + let fresh_enough = match policy { + RefreshPolicy::IfNearExpiry => { + grant.access_token_expires_at > now + near_expiry_window() + } + RefreshPolicy::Forced => now < grant.last_refreshed_at + forced_refresh_grace(), + }; + if fresh_enough { + return Ok(grant.access_token); + } + + // An expired refresh token cannot be exchanged; transition without a + // doomed round-trip to GitHub (spec R1.2). + if grant.refresh_token_expires_at <= now { + return Err(self.transition_to_needs_reauth(grant).await); + } + + let pair = match self.refresh_with_retry(&grant.refresh_token).await { + Ok(pair) => pair, + Err(RefreshFailure::InvalidGrant) => { + return Err(self.transition_to_needs_reauth(grant).await); + } + Err(RefreshFailure::Transient(e) | RefreshFailure::Fatal(e)) => { + // The refresh token was not consumed; the grant stays `Valid` + // on disk and a later call retries from scratch. + return Err(RefreshError::Github(e)); + } + }; + + // Persist-before-use: make the rotated pair durable BEFORE any caller + // sees the new access token. On persist failure the pair is dropped + // and an error returned instead of a token (fail closed) — handing + // out a token whose rotation is not on disk would let a crash orphan + // the refresh chain callers are relying on. + grant.access_token = pair.access_token; + grant.access_token_expires_at = pair.access_token_expires_at; + grant.refresh_token = pair.refresh_token; + grant.refresh_token_expires_at = pair.refresh_token_expires_at; + grant.last_refreshed_at = Utc::now(); + grant.state = GrantState::Valid; + self.persist(&grant).await?; + + Ok(grant.access_token) + } + + /// Persists `needs_reauth` and builds the sticky error. Must be called + /// with the grant's single-flight lock held. + async fn transition_to_needs_reauth(&self, mut grant: Grant) -> RefreshError { + grant.state = GrantState::NeedsReauth; + let login = grant.github_login.clone(); + if let Err(_persist) = self.persist(&grant).await { + // Deliberately not propagated: the auth-expired condition came + // from the exchange itself and is re-derivable (the dead refresh + // token fails identically next time), so surfacing a disk error + // here would mask the actionable guidance. An unpersisted sticky + // state costs at most one redundant doomed refresh attempt after + // a daemon restart. + } + RefreshError::AuthExpired { login } + } + + /// One refresh exchange with bounded retry on transient failures. + async fn refresh_with_retry( + &self, + refresh_token: &SecretString, + ) -> Result { + let mut attempt: u32 = 0; + loop { + match self.inner.backend.refresh(refresh_token).await { + // The retried attempt's error is superseded, not swallowed: + // if the budget runs out, the final attempt's error returns. + Err(RefreshFailure::Transient(_)) if attempt < TRANSIENT_RETRIES => { + attempt += 1; + tokio::time::sleep(RETRY_BACKOFF_BASE * attempt).await; + } + other => return other, + } + } + } + + /// Reads a grant off the store without blocking the async runtime. + async fn load(&self, grant_id: &GrantId) -> Result { + let store = self.inner.store.clone(); + let id = grant_id.clone(); + tokio::task::spawn_blocking(move || store.get(&id)) + .await + .expect("grant-store read task panicked") + .map_err(|source| RefreshError::Load { + grant_id: grant_id.to_string(), + source, + })? + .ok_or_else(|| RefreshError::UnknownGrant { + grant_id: grant_id.to_string(), + }) + } + + /// Writes a grant to the store (atomic + `fsync`, per [`GrantStore::save`]) + /// without blocking the async runtime. + async fn persist(&self, grant: &Grant) -> Result<(), RefreshError> { + let store = self.inner.store.clone(); + let owned = grant.clone(); + tokio::task::spawn_blocking(move || store.save(&owned)) + .await + .expect("grant-store write task panicked") + .map_err(|source| RefreshError::Persist { + grant_id: grant.grant_id.to_string(), + source, + }) + } +} + +/// A [`TokenProvider`] bound to one grant of a [`GrantManager`] — the bridge +/// that lets [`crate::RestClient`] (and any other consumer of the trait) pull +/// refresh-aware tokens without knowing about grants. +pub struct GrantTokenProvider { + manager: GrantManager, + grant_id: GrantId, +} + +impl Clone for GrantTokenProvider { + fn clone(&self) -> Self { + Self { + manager: self.manager.clone(), + grant_id: self.grant_id.clone(), + } + } +} + +impl fmt::Debug for GrantTokenProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GrantTokenProvider") + .field("grant_id", &self.grant_id) + .finish_non_exhaustive() + } +} + +impl TokenProvider for GrantTokenProvider { + async fn token(&self) -> Result { + self.manager + .token_for(&self.grant_id) + .await + .map_err(Error::from) + } +} + +/// Runs `op` with retry-once-on-401 semantics for REST callers: when `op` +/// fails with [`Error::NeedsReauth`] (how a `401` surfaces from +/// [`crate::RestClient`]), forces one refresh via +/// [`GrantManager::refresh_now`] and runs `op` once more. Any second failure +/// — including a repeat `401`, or the refresh itself finding the grant dead — +/// is returned as-is. +/// +/// # Errors +/// +/// Whatever `op` returns; or the forced refresh's failure mapped through +/// `From` (a dead grant surfaces as [`Error::NeedsReauth`]). +pub async fn with_reauth_retry( + manager: &GrantManager, + grant_id: &GrantId, + op: F, +) -> Result +where + B: RefreshBackend, + F: Fn() -> Fut, + Fut: Future>, +{ + match op().await { + Err(Error::NeedsReauth) => { + manager.refresh_now(grant_id).await.map_err(Error::from)?; + op().await + } + other => other, + } +} + +/// Builds an [`AccessTokenPair`] from a successful refresh response. +fn pair_from_wire(wire: RefreshWire) -> Result { + let missing = |field: &str| { + RefreshFailure::Fatal(Error::Decode { + reason: format!("GitHub's refresh response is missing `{field}`"), + }) + }; + let access_token = wire.access_token.ok_or_else(|| missing("access_token"))?; + let refresh_token = wire.refresh_token.ok_or_else(|| missing("refresh_token"))?; + let expires_in = wire.expires_in.ok_or_else(|| missing("expires_in"))?; + let refresh_expires_in = wire + .refresh_token_expires_in + .ok_or_else(|| missing("refresh_token_expires_in"))?; + let now = Utc::now(); + Ok(AccessTokenPair { + access_token: SecretString::new(access_token), + access_token_expires_at: now + chrono::Duration::seconds(saturating_i64(expires_in)), + refresh_token: SecretString::new(refresh_token), + refresh_token_expires_at: now + + chrono::Duration::seconds(saturating_i64(refresh_expires_in)), + }) +} + +/// Converts a token lifetime in seconds to `i64`, saturating rather than +/// panicking on absurd values (mirrors `device_flow`'s identical convention). +fn saturating_i64(seconds: u64) -> i64 { + i64::try_from(seconds).unwrap_or(i64::MAX) +} + +/// Extracts GitHub's `message` field from an error body, falling back to a +/// bounded excerpt of the raw body (mirrors `rest.rs`/`device_flow.rs`'s +/// identical convention, for the same no-unbounded-blob-in-logs reason). +fn error_message(bytes: &[u8]) -> String { + if let Ok(value) = serde_json::from_slice::(bytes) + && let Some(message) = value.get("message").and_then(|m| m.as_str()) + { + return message.to_string(); + } + String::from_utf8_lossy(bytes).chars().take(200).collect() +} + +/// Wire shape of a `grant_type=refresh_token` exchange — success and OAuth +/// errors share one envelope, distinguished by whether `error` is present. +/// (`device_flow` has a private equivalent; duplicated deliberately so the +/// two modules stay independently compilable and testable.) +#[derive(Deserialize, Default)] +struct RefreshWire { + #[serde(default)] + access_token: Option, + #[serde(default)] + expires_in: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + refresh_token_expires_in: Option, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::VecDeque; + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tempfile::TempDir; + + use crate::scopes::ScopeSet; + + /// A scripted step for [`MockBackend`]. + #[derive(Clone, Copy)] + enum Step { + /// Succeed, minting `mock_access_N` / `mock_refresh_N`. + Rotate, + /// Fail with [`RefreshFailure::InvalidGrant`]. + InvalidGrant, + /// Fail with [`RefreshFailure::Transient`]. + Transient, + /// Fail with [`RefreshFailure::Fatal`]. + Fatal, + } + + /// A scripted [`RefreshBackend`]: consumes one [`Step`] per call (an + /// exhausted script keeps rotating) and counts calls. + struct MockBackend { + calls: AtomicUsize, + seq: AtomicUsize, + delay: Duration, + script: SyncMutex>, + } + + impl MockBackend { + fn scripted(steps: impl IntoIterator) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + seq: AtomicUsize::new(0), + delay: Duration::ZERO, + script: SyncMutex::new(steps.into_iter().collect()), + }) + } + + fn rotating() -> Arc { + Self::scripted([]) + } + + fn with_delay(self: Arc, delay: Duration) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + seq: AtomicUsize::new(0), + delay, + script: SyncMutex::new(self.script.lock().unwrap().clone()), + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + impl RefreshBackend for MockBackend { + async fn refresh( + &self, + _refresh_token: &SecretString, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.delay > Duration::ZERO { + tokio::time::sleep(self.delay).await; + } + let step = self + .script + .lock() + .unwrap() + .pop_front() + .unwrap_or(Step::Rotate); + match step { + Step::Rotate => { + let n = self.seq.fetch_add(1, Ordering::SeqCst) + 1; + let now = Utc::now(); + Ok(AccessTokenPair { + access_token: SecretString::new(format!("mock_access_{n}")), + access_token_expires_at: now + chrono::Duration::hours(8), + refresh_token: SecretString::new(format!("mock_refresh_{n}")), + refresh_token_expires_at: now + chrono::Duration::days(180), + }) + } + Step::InvalidGrant => Err(RefreshFailure::InvalidGrant), + Step::Transient => Err(RefreshFailure::Transient(Error::Request { + reason: "scripted transient failure".to_string(), + })), + Step::Fatal => Err(RefreshFailure::Fatal(Error::UnexpectedStatus { + status: 400, + message: "scripted fatal failure".to_string(), + })), + } + } + } + + fn grant_id() -> GrantId { + GrantId::new("grant-1").unwrap() + } + + /// A stored grant whose access/refresh tokens expire the given durations + /// from now. `last_refreshed_at` is set an hour back so the forced-refresh + /// grace never triggers by accident. + fn grant_expiring_in(access: chrono::Duration, refresh: chrono::Duration) -> Grant { + let now = Utc::now(); + Grant { + grant_id: grant_id(), + github_login: "octocat".to_string(), + github_user_id: 42, + scopes: ScopeSet::defaults(), + access_token: SecretString::new("ghu_seed_access"), + access_token_expires_at: now + access, + refresh_token: SecretString::new("ghr_seed_refresh"), + refresh_token_expires_at: now + refresh, + created_at: now - chrono::Duration::hours(1), + last_refreshed_at: now - chrono::Duration::hours(1), + state: GrantState::Valid, + } + } + + fn setup( + grant: &Grant, + backend: Arc, + ) -> (TempDir, GrantStore, GrantManager>) { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(tmp.path().join("grants")).unwrap(); + store.save(grant).unwrap(); + let manager = GrantManager::new(store.clone(), backend); + (tmp, store, manager) + } + + // --- phase / threshold --------------------------------------------------- + + #[test] + fn phase_of_encodes_the_near_expiry_threshold() { + let now = Utc::now(); + let fresh = grant_expiring_in(chrono::Duration::minutes(6), chrono::Duration::days(30)); + assert_eq!(phase_of(&fresh, now), GrantPhase::Valid); + + let near = grant_expiring_in(chrono::Duration::minutes(4), chrono::Duration::days(30)); + assert_eq!(phase_of(&near, now), GrantPhase::NearExpiry); + + let mut reauth = fresh.clone(); + reauth.state = GrantState::NeedsReauth; + assert_eq!(phase_of(&reauth, now), GrantPhase::NeedsReauth); + + // An expired refresh token is needs-reauth regardless of the access + // token's freshness: nothing can be refreshed any more. + let dead_refresh = + grant_expiring_in(chrono::Duration::hours(8), chrono::Duration::seconds(-1)); + assert_eq!(phase_of(&dead_refresh, now), GrantPhase::NeedsReauth); + } + + #[tokio::test] + async fn fresh_token_is_returned_without_any_refresh() { + let backend = MockBackend::rotating(); + let grant = grant_expiring_in(chrono::Duration::minutes(6), chrono::Duration::days(30)); + let (_tmp, _store, manager) = setup(&grant, Arc::clone(&backend)); + + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "ghu_seed_access"); + assert_eq!( + backend.calls(), + 0, + "a fresh token must not trigger a refresh" + ); + } + + #[tokio::test] + async fn near_expiry_token_is_refreshed_before_being_returned() { + let backend = MockBackend::rotating(); + let grant = grant_expiring_in(chrono::Duration::minutes(4), chrono::Duration::days(30)); + let (_tmp, _store, manager) = setup(&grant, Arc::clone(&backend)); + + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "mock_access_1"); + assert_eq!(backend.calls(), 1); + } + + // --- write-ahead rotation ------------------------------------------------ + + #[tokio::test] + async fn rotation_is_on_disk_by_the_time_the_token_is_returned() { + let backend = MockBackend::rotating(); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, backend); + + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "mock_access_1"); + + // The instant a caller holds the new access token, the rotated pair — + // crucially including the NEW refresh token — must already be durable. + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.access_token.expose_secret(), "mock_access_1"); + assert_eq!(on_disk.refresh_token.expose_secret(), "mock_refresh_1"); + assert_eq!(on_disk.state, GrantState::Valid); + assert!(on_disk.last_refreshed_at > grant.last_refreshed_at); + } + + #[tokio::test] + async fn failed_persist_fails_closed_and_leaves_the_old_grant_intact() { + let backend = MockBackend::rotating(); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + // Crash-inject the persist: a directory squatting on the store's temp + // path makes `GrantStore::save` fail before touching the final file. + fs::create_dir(store.dir().join("grant-1.json.tmp")).unwrap(); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!(matches!(err, RefreshError::Persist { .. }), "got {err:?}"); + assert_eq!(backend.calls(), 1); + + // Fail closed: the freshly minted pair was discarded, never returned, + // and never leaks through the error's rendering... + let rendered = format!("{err} / {err:?} / {}", chained_display(&err)); + assert!(!rendered.contains("mock_access_1")); + assert!(!rendered.contains("mock_refresh_1")); + + // ...and the on-disk grant is exactly as before the attempt. + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.access_token.expose_secret(), "ghu_seed_access"); + assert_eq!(on_disk.refresh_token.expose_secret(), "ghr_seed_refresh"); + assert_eq!(on_disk.state, GrantState::Valid); + } + + // --- single-flight ------------------------------------------------------- + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_callers_collapse_to_exactly_one_refresh() { + let backend = MockBackend::rotating().with_delay(Duration::from_millis(100)); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, _store, manager) = setup(&grant, Arc::clone(&backend)); + + let tasks: Vec<_> = (0..16) + .map(|_| { + let manager = manager.clone(); + tokio::spawn(async move { manager.token_for(&grant_id()).await.unwrap() }) + }) + .collect(); + for task in tasks { + let token = task.await.unwrap(); + assert_eq!( + token.expose_secret(), + "mock_access_1", + "every caller must observe the single rotated token" + ); + } + assert_eq!( + backend.calls(), + 1, + "N concurrent token_for calls must perform exactly one refresh" + ); + } + + // --- needs-reauth -------------------------------------------------------- + + #[tokio::test] + async fn invalid_grant_persists_needs_reauth_and_the_error_is_sticky() { + let backend = MockBackend::scripted([Step::InvalidGrant]); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert_eq!( + err.to_string(), + "github auth expired for octocat; run 'min github login'" + ); + assert_eq!(backend.calls(), 1); + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.state, GrantState::NeedsReauth); + + // Sticky: the second call repeats the actionable error without any + // further backend traffic (spec R1.2 — re-auth, never silent retry). + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!(matches!(err, RefreshError::AuthExpired { .. })); + assert_eq!(backend.calls(), 1, "needs_reauth must not refresh again"); + assert_eq!( + manager.phase(&grant_id()).await.unwrap(), + GrantPhase::NeedsReauth + ); + } + + #[tokio::test] + async fn expired_refresh_token_goes_needs_reauth_without_calling_github() { + let backend = MockBackend::rotating(); + // Access token near expiry AND the refresh token itself already dead. + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::seconds(-1)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!( + matches!(err, RefreshError::AuthExpired { .. }), + "got {err:?}" + ); + assert_eq!(backend.calls(), 0, "a dead refresh token must not be sent"); + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.state, GrantState::NeedsReauth); + } + + // --- transient failures -------------------------------------------------- + + #[tokio::test] + async fn transient_failure_is_retried_and_then_succeeds() { + let backend = MockBackend::scripted([Step::Transient, Step::Rotate]); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "mock_access_1"); + assert_eq!(backend.calls(), 2, "one failed attempt, one retry"); + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.refresh_token.expose_secret(), "mock_refresh_1"); + } + + #[tokio::test] + async fn exhausted_transient_retries_leave_the_grant_valid_and_retryable() { + // Enough scripted failures to cover two full retry cycles (the mock + // falls back to `Rotate` once its script runs out). + let per_call = 1 + TRANSIENT_RETRIES as usize; + let backend = MockBackend::scripted(vec![Step::Transient; per_call * 2]); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!(matches!(err, RefreshError::Github(_)), "got {err:?}"); + assert_eq!( + backend.calls(), + per_call, + "initial attempt + bounded retries" + ); + + // Never sticky: the grant stays Valid on disk with its refresh token + // untouched, and the next call attempts a fresh refresh cycle. + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.state, GrantState::Valid); + assert_eq!(on_disk.refresh_token.expose_secret(), "ghr_seed_refresh"); + + manager.token_for(&grant_id()).await.unwrap_err(); + assert_eq!(backend.calls(), per_call * 2); + } + + #[tokio::test] + async fn fatal_failure_is_not_retried_and_leaves_the_grant_valid() { + let backend = MockBackend::scripted([Step::Fatal, Step::Rotate]); + let grant = grant_expiring_in(chrono::Duration::minutes(1), chrono::Duration::days(30)); + let (_tmp, store, manager) = setup(&grant, Arc::clone(&backend)); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!(matches!(err, RefreshError::Github(_)), "got {err:?}"); + assert_eq!(backend.calls(), 1, "fatal failures must not burn retries"); + let on_disk = store.get(&grant_id()).unwrap().unwrap(); + assert_eq!(on_disk.state, GrantState::Valid); + } + + // --- forced refresh (401 retry hook) ------------------------------------ + + #[tokio::test] + async fn refresh_now_rotates_a_fresh_token_but_the_grace_collapses_repeats() { + let backend = MockBackend::rotating(); + let grant = grant_expiring_in(chrono::Duration::hours(8), chrono::Duration::days(30)); + let (_tmp, _store, manager) = setup(&grant, Arc::clone(&backend)); + + // Forced: rotates even though the token is nowhere near expiry. + let token = manager.refresh_now(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "mock_access_1"); + assert_eq!(backend.calls(), 1); + + // A second forced refresh within the grace returns the same token + // without another rotation — this is what stops a 401-retry stampede + // from rotating N times, and what terminates the retry-once loop. + let token = manager.refresh_now(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "mock_access_1"); + assert_eq!(backend.calls(), 1); + } + + #[tokio::test] + async fn unknown_grant_is_an_actionable_error() { + let backend = MockBackend::rotating(); + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(tmp.path().join("grants")).unwrap(); + let manager = GrantManager::new(store, backend); + + let err = manager.token_for(&grant_id()).await.unwrap_err(); + assert!( + matches!(err, RefreshError::UnknownGrant { .. }), + "got {err:?}" + ); + assert!(err.to_string().contains("min github login")); + // The TokenProvider-facing mapping folds this into NeedsReauth. + assert!(matches!(Error::from(err), Error::NeedsReauth)); + } + + // --- HTTP backend + REST integration, against the mock GitHub ----------- + + #[cfg(feature = "test-support")] + mod http_backed { + use super::*; + + use crate::device_flow::DeviceFlowClient; + use crate::device_flow::assemble_grant; + use crate::rest::RestClient; + use crate::testing::{MockGithub, RefreshStep}; + + fn install_url() -> Url { + Url::parse("https://github.com/apps/minimal/installations/new").unwrap() + } + + fn http_backend(mock: &MockGithub) -> HttpRefreshBackend { + HttpRefreshBackend::new(mock.base_url().clone(), "test-client") + } + + /// Counts refresh exchanges the mock has served. + fn refresh_calls(mock: &MockGithub) -> usize { + mock.captured() + .iter() + .filter(|r| { + r.path == "/login/oauth/access_token" + && r.body_string().contains("grant_type=refresh_token") + }) + .count() + } + + #[tokio::test] + async fn http_backend_speaks_the_refresh_wire_shape() { + let mock = MockGithub::start().await.expect("mock starts"); + let pair = http_backend(&mock) + .refresh(&SecretString::new("ghr_current")) + .await + .expect("refresh ok"); + assert_eq!(pair.access_token.expose_secret(), "ghu_mock_access_1"); + assert_eq!(pair.refresh_token.expose_secret(), "ghr_mock_refresh_1"); + + let captured = mock.captured(); + let req = captured + .iter() + .find(|r| r.path == "/login/oauth/access_token") + .expect("refresh request captured"); + let body = req.body_string(); + assert!(body.contains("grant_type=refresh_token")); + assert!(body.contains("client_id=test-client")); + assert!(body.contains("refresh_token=ghr_current")); + } + + #[tokio::test] + async fn http_backend_maps_invalid_grant() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.script_refresh([RefreshStep::InvalidGrant])); + let err = http_backend(&mock) + .refresh(&SecretString::new("ghr_revoked")) + .await + .unwrap_err(); + assert!(matches!(err, RefreshFailure::InvalidGrant), "got {err:?}"); + } + + /// The full write-ahead loop against a rotation-*enforcing* mock: each + /// refresh must present exactly the refresh token minted by the + /// previous one, which only works if every rotation was persisted and + /// re-read — a lost rotation would surface as `invalid_grant` here. + #[tokio::test] + async fn manager_survives_strict_rotation_across_sequential_refreshes() { + let mock = MockGithub::start().await.expect("mock starts"); + // A 60s access TTL keeps every minted token inside the 5-minute + // near-expiry window, so each token_for performs a refresh. + mock.configure(|fx| { + fx.set_device_interval(1); + fx.set_token_ttls(60, 15_897_600); + }); + + // Seed the grant through a real device-flow login so the mock + // knows the current refresh token, then turn on strict rotation. + let dfc = DeviceFlowClient::new(mock.base_url().clone(), mock.base_url().clone()); + let auth = dfc.start_device_flow("test-client").await.unwrap(); + let tokens = dfc.poll("test-client", &auth).await.unwrap(); + let user = dfc.fetch_user(&tokens.access_token).await.unwrap(); + let grant = assemble_grant(grant_id(), user, ScopeSet::defaults(), tokens); + mock.configure(|fx| fx.set_strict_refresh(true)); + + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(tmp.path().join("grants")).unwrap(); + store.save(&grant).unwrap(); + let manager = GrantManager::new(store.clone(), http_backend(&mock)); + + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "ghu_mock_access_2"); + let token = manager.token_for(&grant_id()).await.unwrap(); + assert_eq!(token.expose_secret(), "ghu_mock_access_3"); + + // Each exchange presented the previous rotation's refresh token. + let presented: Vec = mock + .captured() + .iter() + .filter(|r| { + r.path == "/login/oauth/access_token" + && r.body_string().contains("grant_type=refresh_token") + }) + .map(|r| r.body_string()) + .collect(); + assert_eq!(presented.len(), 2); + assert!(presented[0].contains("refresh_token=ghr_mock_refresh_1")); + assert!(presented[1].contains("refresh_token=ghr_mock_refresh_2")); + + // And a rotated-away refresh token is dead — the brick a lost + // rotation would cause, demonstrated directly. + let err = http_backend(&mock) + .refresh(&SecretString::new("ghr_mock_refresh_1")) + .await + .unwrap_err(); + assert!(matches!(err, RefreshFailure::InvalidGrant), "got {err:?}"); + } + + #[tokio::test] + async fn with_reauth_retry_refreshes_once_and_retries_the_call() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_force_unauthorized(true)); + + let grant = grant_expiring_in(chrono::Duration::hours(8), chrono::Duration::days(30)); + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(tmp.path().join("grants")).unwrap(); + store.save(&grant).unwrap(); + let manager = GrantManager::new(store, http_backend(&mock)); + let client = RestClient::new( + mock.base_url().clone(), + install_url(), + manager.token_provider(grant_id()), + ); + + // First /user answers 401; before the retry, "GitHub" recovers + // (as if the old token had been revoked but the account is fine). + let attempts = AtomicUsize::new(0); + let user = with_reauth_retry(&manager, &grant_id(), || { + if attempts.fetch_add(1, Ordering::SeqCst) == 1 { + mock.configure(|fx| fx.set_force_unauthorized(false)); + } + client.get_user() + }) + .await + .expect("retried call succeeds"); + assert_eq!(user.login, "octocat"); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert_eq!(refresh_calls(&mock), 1, "exactly one forced refresh"); + } + + #[tokio::test] + async fn with_reauth_retry_gives_up_after_a_second_401() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_force_unauthorized(true)); + + let grant = grant_expiring_in(chrono::Duration::hours(8), chrono::Duration::days(30)); + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(tmp.path().join("grants")).unwrap(); + store.save(&grant).unwrap(); + let manager = GrantManager::new(store, http_backend(&mock)); + let client = RestClient::new( + mock.base_url().clone(), + install_url(), + manager.token_provider(grant_id()), + ); + + let err = with_reauth_retry(&manager, &grant_id(), || client.get_user()) + .await + .unwrap_err(); + assert!(matches!(err, Error::NeedsReauth), "got {err:?}"); + assert_eq!(refresh_calls(&mock), 1, "retry-once, not retry-forever"); + let user_calls = mock.captured().iter().filter(|r| r.path == "/user").count(); + assert_eq!(user_calls, 2); + } + } +} diff --git a/crates/github/src/rest.rs b/crates/github/src/rest.rs new file mode 100644 index 000000000..a19cae712 --- /dev/null +++ b/crates/github/src/rest.rs @@ -0,0 +1,614 @@ +//! Typed GitHub REST client: authenticated user, App-installation check, +//! repository default branch, and pull-request list/create/get (spec R1.4, +//! R1.5, R2.6, R4.5). +//! +//! [`RestClient`] is generic over [`TokenProvider`] so the daemon can plug in +//! its real, refresh-aware token source while tests use [`StaticToken`]. Every +//! call is built from an `api_base` [`url::Url`] — normally +//! [`crate::GithubConfig::api_base`] — so the client is retargetable at the +//! in-process mock GitHub (`crate::testing`, `test-support` feature) in tests +//! and points at real `https://api.github.com` in production. +//! +//! # Error mapping (spec R8.1) +//! +//! * `401` from any endpoint becomes [`Error::NeedsReauth`] — the caller +//! should prompt `min github login` rather than retry. +//! * `404` on [`RestClient::repo_installation`] becomes +//! [`Error::AppNotInstalled`], carrying the caller-supplied installation URL +//! (spec R1.5). +//! * `404` elsewhere becomes an endpoint-specific, actionable variant +//! ([`Error::RepoNotFound`], [`Error::PullNotFound`]). +//! * Any other non-2xx status becomes [`Error::UnexpectedStatus`], carrying +//! only the status code and GitHub's own `message` (or a bounded excerpt of +//! the body) — never the full response, which could otherwise grow +//! unbounded or echo request data back into a log. +//! * Transport failures (DNS, TCP, TLS, timeout) and response bodies that +//! don't decode become [`Error::Request`] / [`Error::Decode`], built from +//! the underlying error's `Display`, which never includes header values — +//! so no token material can reach these error paths. + +use std::future::Future; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::error::Error; +use crate::secret::SecretString; + +/// Supplies a valid GitHub access token for an authenticated REST call. +/// +/// Implementations own refresh: a call to [`TokenProvider::token`] must +/// return a token that is valid *right now*, refreshing a stored grant first +/// if needed. Any failure to produce one (including a revoked or expired +/// refresh token) must surface as [`Error::NeedsReauth`] per spec R1.2, so +/// [`RestClient`] callers only ever need to handle that one variant for +/// auth-expiry, whether it originated from the provider or from a `401` +/// GitHub returned despite a seemingly-valid token. +pub trait TokenProvider: Send + Sync { + /// Returns a currently-valid access token. + fn token(&self) -> impl Future> + Send; +} + +/// A [`TokenProvider`] that always returns the same fixed token. +/// +/// Real callers (the daemon) implement a refresh-aware provider over the +/// on-disk [`crate::GrantStore`]; this one exists so [`RestClient`] is +/// unit-testable without wiring a live grant store. +#[derive(Debug, Clone)] +pub struct StaticToken(SecretString); + +impl StaticToken { + /// Wraps a fixed token that every call returns unchanged. + #[must_use] + pub fn new(token: impl Into) -> Self { + Self(SecretString::new(token)) + } +} + +impl TokenProvider for StaticToken { + async fn token(&self) -> Result { + Ok(self.0.clone()) + } +} + +/// The authenticated user (`GET /user`, spec R1.3). +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct User { + /// The user's login (handle). + pub login: String, + /// The user's numeric GitHub id. + pub id: u64, +} + +/// A GitHub App installation on a repository's owner +/// (`GET /repos/{owner}/{repo}/installation`). +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct Installation { + /// The installation id. + pub id: u64, + /// The installed App's slug (e.g. `minimal`). + pub app_slug: String, + /// Whether the installation targets a `User` or an `Organization`. + pub target_type: String, + /// The account (user or org) the installation is scoped to. + pub account: InstallationAccount, +} + +/// The account an [`Installation`] is scoped to. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct InstallationAccount { + /// The account's login. + pub login: String, +} + +/// A repository (`GET /repos/{owner}/{repo}`); only the fields this client +/// needs. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct Repository { + /// `owner/repo`. + pub full_name: String, + /// The repository's default branch. + pub default_branch: String, +} + +/// A pull request, as returned by list, create, and get. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PullRequest { + /// The pull-request number. + pub number: u64, + /// The pull-request title. + pub title: String, + /// The pull-request description. Absent in some list responses, so this + /// defaults to empty rather than failing to decode. + #[serde(default)] + pub body: String, + /// `"open"`, `"closed"`, etc. + pub state: String, + /// The web URL for the pull request. + pub html_url: String, + /// The head (source) branch. + pub head: PullRef, + /// The base (target) branch. + pub base: PullRef, +} + +/// One end (`head` or `base`) of a [`PullRequest`]: just the ref name this +/// client needs. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PullRef { + /// The branch name. + #[serde(rename = "ref")] + pub ref_name: String, +} + +/// Request body for [`RestClient::create_pull`]. +#[derive(Debug, Clone, Serialize)] +struct CreatePullBody<'a> { + title: &'a str, + head: &'a str, + base: &'a str, + body: &'a str, + draft: bool, +} + +/// A typed GitHub REST client (spec R1.4, R1.5, R2.6, R4.5). +/// +/// See the module docs for the error-mapping contract. +#[derive(Debug, Clone)] +pub struct RestClient { + http: reqwest::Client, + api_base: Url, + install_url: Url, + token: T, +} + +impl RestClient { + /// Builds a client against `api_base` (normally + /// [`crate::GithubConfig::api_base`]). + /// + /// `install_url` is the GitHub App installation page to surface when + /// [`RestClient::repo_installation`] finds the App not installed (spec + /// R1.5) — e.g. `https://github.com/apps/minimal/installations/new`. It + /// is caller-supplied rather than derived here: building it correctly + /// needs the App's public slug, which a 404 response carries no way to + /// learn. + #[must_use] + pub fn new(api_base: Url, install_url: Url, token: T) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client construction is infallible with the enabled backends"), + api_base, + install_url, + token, + } + } + + /// The authenticated user (spec R1.3). + pub async fn get_user(&self) -> Result { + let url = self.url("user")?; + let resp = self.send(self.http.get(url)).await?; + decode(resp, |status, message| Error::UnexpectedStatus { + status, + message, + }) + .await + } + + /// Whether — and as whom — the GitHub App is installed on `owner/repo`. + /// + /// A `404` here is GitHub's signal that the App is not installed on this + /// target; that case becomes [`Error::AppNotInstalled`] rather than an + /// opaque failure (spec R1.5). + pub async fn repo_installation(&self, owner: &str, repo: &str) -> Result { + let url = self.url(&format!("repos/{owner}/{repo}/installation"))?; + let resp = self.send(self.http.get(url)).await?; + let install_url = self.install_url.to_string(); + decode(resp, move |status, message| { + if status == 404 { + Error::AppNotInstalled { + install_url: install_url.clone(), + } + } else { + Error::UnexpectedStatus { status, message } + } + }) + .await + } + + /// The repository's default branch. + pub async fn repo_default_branch(&self, owner: &str, repo: &str) -> Result { + Ok(self.get_repo(owner, repo).await?.default_branch) + } + + async fn get_repo(&self, owner: &str, repo: &str) -> Result { + let url = self.url(&format!("repos/{owner}/{repo}"))?; + let resp = self.send(self.http.get(url)).await?; + decode(resp, repo_not_found(owner, repo)).await + } + + /// Lists open pull requests on `owner/repo` whose head matches + /// `head_owner:head_branch`, for existing-PR-before-create detection + /// (spec R4.5). + pub async fn list_pulls( + &self, + owner: &str, + repo: &str, + head_owner: &str, + head_branch: &str, + ) -> Result, Error> { + let mut url = self.url(&format!("repos/{owner}/{repo}/pulls"))?; + url.query_pairs_mut() + .append_pair("head", &format!("{head_owner}:{head_branch}")) + .append_pair("state", "open"); + let resp = self.send(self.http.get(url)).await?; + decode(resp, repo_not_found(owner, repo)).await + } + + /// Opens a pull request on `owner/repo`. + #[allow(clippy::too_many_arguments)] + pub async fn create_pull( + &self, + owner: &str, + repo: &str, + title: &str, + head: &str, + base: &str, + body: &str, + draft: bool, + ) -> Result { + let url = self.url(&format!("repos/{owner}/{repo}/pulls"))?; + let payload = CreatePullBody { + title, + head, + base, + body, + draft, + }; + let json = serde_json::to_vec(&payload).map_err(|e| Error::Request { + reason: format!("encoding create-pull payload: {e}"), + })?; + let builder = self + .http + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(json); + let resp = self.send(builder).await?; + decode(resp, repo_not_found(owner, repo)).await + } + + /// Fetches a single pull request by number. + pub async fn get_pull( + &self, + owner: &str, + repo: &str, + number: u64, + ) -> Result { + let url = self.url(&format!("repos/{owner}/{repo}/pulls/{number}"))?; + let resp = self.send(self.http.get(url)).await?; + let (owner, repo) = (owner.to_string(), repo.to_string()); + decode(resp, move |status, message| { + if status == 404 { + Error::PullNotFound { + owner: owner.clone(), + repo: repo.clone(), + number, + } + } else { + Error::UnexpectedStatus { status, message } + } + }) + .await + } + + /// Resolves `path` (relative, no leading `/`) against `api_base`. + fn url(&self, path: &str) -> Result { + self.api_base.join(path).map_err(|e| Error::Request { + reason: format!("building request URL for `{path}`: {e}"), + }) + } + + /// Attaches auth/standard headers and sends the request. + async fn send(&self, builder: reqwest::RequestBuilder) -> Result { + let token = self.token.token().await?; + builder + .bearer_auth(token.expose_secret()) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, "minimal-github-client") + .send() + .await + .map_err(|e| Error::Request { + reason: e.to_string(), + }) + } +} + +/// Builds an `on_error` closure for [`decode`] that maps a `404` to +/// [`Error::RepoNotFound`] and anything else to [`Error::UnexpectedStatus`]. +fn repo_not_found(owner: &str, repo: &str) -> impl FnOnce(u16, String) -> Error + 'static { + let (owner, repo) = (owner.to_string(), repo.to_string()); + move |status, message| { + if status == 404 { + Error::RepoNotFound { owner, repo } + } else { + Error::UnexpectedStatus { status, message } + } + } +} + +/// Decodes a GitHub REST response, applying the shared `401 -> NeedsReauth` +/// mapping universally and deferring any other non-2xx status to `on_error`. +async fn decode(resp: reqwest::Response, on_error: F) -> Result +where + R: for<'de> Deserialize<'de>, + F: FnOnce(u16, String) -> Error, +{ + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::NeedsReauth); + } + let bytes = resp.bytes().await.map_err(|e| Error::Request { + reason: e.to_string(), + })?; + if !status.is_success() { + return Err(on_error(status.as_u16(), error_message(&bytes))); + } + serde_json::from_slice(&bytes).map_err(|e| Error::Decode { + reason: e.to_string(), + }) +} + +/// Extracts GitHub's `message` field from an error body, falling back to a +/// bounded excerpt of the raw body so a malformed error response still yields +/// something actionable without risking an unbounded or binary blob in a log. +fn error_message(bytes: &[u8]) -> String { + if let Ok(value) = serde_json::from_slice::(bytes) + && let Some(message) = value.get("message").and_then(|m| m.as_str()) + { + return message.to_string(); + } + String::from_utf8_lossy(bytes).chars().take(200).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "test-support")] + mod mock_tests { + use super::*; + use crate::testing::MockGithub; + + async fn client(mock: &MockGithub) -> RestClient { + let install_url = Url::parse("https://github.com/apps/minimal/installations/new") + .expect("valid install URL"); + RestClient::new( + mock.base_url().clone(), + install_url, + StaticToken::new("ghu_mock_token"), + ) + } + + #[tokio::test] + async fn get_user_returns_the_mock_identity() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_user("octocat", 583_231)); + let user = client(&mock).await.get_user().await.expect("get_user ok"); + assert_eq!(user.login, "octocat"); + assert_eq!(user.id, 583_231); + } + + #[tokio::test] + async fn get_user_401_maps_to_needs_reauth() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_force_unauthorized(true)); + let err = client(&mock).await.get_user().await.unwrap_err(); + assert!(matches!(err, Error::NeedsReauth), "got {err:?}"); + } + + #[tokio::test] + async fn installation_present_reports_the_installation() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_installed("octocat", "hello", true)); + let install = client(&mock) + .await + .repo_installation("octocat", "hello") + .await + .expect("installation present"); + assert_eq!(install.app_slug, "minimal"); + assert_eq!(install.account.login, "octocat"); + } + + #[tokio::test] + async fn installation_absent_maps_to_app_not_installed_with_url() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_installed("octocat", "hello", false)); + let err = client(&mock) + .await + .repo_installation("octocat", "hello") + .await + .unwrap_err(); + match err { + Error::AppNotInstalled { install_url } => { + assert_eq!( + install_url, + "https://github.com/apps/minimal/installations/new" + ); + } + other => panic!("expected AppNotInstalled, got {other:?}"), + } + } + + #[tokio::test] + async fn repo_default_branch_reads_the_configured_branch() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| fx.set_default_branch("octocat", "hello", "develop")); + let branch = client(&mock) + .await + .repo_default_branch("octocat", "hello") + .await + .expect("default branch ok"); + assert_eq!(branch, "develop"); + } + + #[tokio::test] + async fn list_pulls_detects_existing_pr_by_head() { + let mock = MockGithub::start().await.expect("mock starts"); + mock.configure(|fx| { + fx.add_pull("octocat", "hello", "feat/x", "main"); + }); + let pulls = client(&mock) + .await + .list_pulls("octocat", "hello", "octocat", "feat/x") + .await + .expect("list_pulls ok"); + assert_eq!(pulls.len(), 1); + assert_eq!(pulls[0].head.ref_name, "feat/x"); + assert_eq!(pulls[0].base.ref_name, "main"); + assert_eq!(pulls[0].state, "open"); + + // A different head branch does not match the existing PR. + let none = client(&mock) + .await + .list_pulls("octocat", "hello", "octocat", "feat/other") + .await + .expect("list_pulls ok"); + assert!(none.is_empty()); + } + + #[tokio::test] + async fn create_pull_sends_the_expected_payload_shape() { + let mock = MockGithub::start().await.expect("mock starts"); + let pr = client(&mock) + .await + .create_pull( + "octocat", + "hello", + "Add feature x", + "feat/x", + "main", + "does the thing", + true, + ) + .await + .expect("create_pull ok"); + assert_eq!(pr.title, "Add feature x"); + assert_eq!(pr.head.ref_name, "feat/x"); + assert_eq!(pr.base.ref_name, "main"); + assert!(pr.html_url.contains("/octocat/hello/pull/")); + + let requests = mock.captured(); + let create_req = requests + .iter() + .find(|r| r.method == "POST" && r.path == "/repos/octocat/hello/pulls") + .expect("create-pull request captured"); + let body: serde_json::Value = + serde_json::from_str(&create_req.body_string()).expect("valid JSON body"); + assert_eq!(body["title"], "Add feature x"); + assert_eq!(body["head"], "feat/x"); + assert_eq!(body["base"], "main"); + assert_eq!(body["body"], "does the thing"); + assert_eq!(body["draft"], true); + assert_eq!(create_req.bearer_token(), Some("ghu_mock_token")); + } + + #[tokio::test] + async fn create_pull_422_maps_to_unexpected_status() { + let mock = MockGithub::start().await.expect("mock starts"); + // Omitting `head` triggers the mock's 422 "head is required" path. + let err = client(&mock) + .await + .create_pull("octocat", "hello", "t", "", "main", "", false) + .await + .unwrap_err(); + match err { + Error::UnexpectedStatus { status, message } => { + assert_eq!(status, 422); + assert_eq!(message, "head is required"); + } + other => panic!("expected UnexpectedStatus, got {other:?}"), + } + } + + #[tokio::test] + async fn get_pull_404_maps_to_pull_not_found() { + let mock = MockGithub::start().await.expect("mock starts"); + // The mock does not route single-PR-by-number GETs, so it always + // 404s; the client must still map that to `PullNotFound`, not a + // generic `UnexpectedStatus`. + let err = client(&mock) + .await + .get_pull("octocat", "hello", 999) + .await + .unwrap_err(); + match err { + Error::PullNotFound { + owner, + repo, + number, + } => { + assert_eq!(owner, "octocat"); + assert_eq!(repo, "hello"); + assert_eq!(number, 999); + } + other => panic!("expected PullNotFound, got {other:?}"), + } + } + } + + #[test] + fn repo_not_found_mapper_maps_404_and_defers_other_statuses() { + let mapper = repo_not_found("octocat", "hello"); + match mapper(404, "Not Found".to_string()) { + Error::RepoNotFound { owner, repo } => { + assert_eq!(owner, "octocat"); + assert_eq!(repo, "hello"); + } + other => panic!("expected RepoNotFound, got {other:?}"), + } + + let mapper = repo_not_found("octocat", "hello"); + match mapper(500, "boom".to_string()) { + Error::UnexpectedStatus { status, message } => { + assert_eq!(status, 500); + assert_eq!(message, "boom"); + } + other => panic!("expected UnexpectedStatus, got {other:?}"), + } + } + + #[test] + fn error_message_prefers_githubs_message_field_and_falls_back_to_excerpt() { + let with_message = serde_json::json!({ "message": "Not Found" }).to_string(); + assert_eq!(error_message(with_message.as_bytes()), "Not Found"); + + let long_body = "x".repeat(500); + let excerpt = error_message(long_body.as_bytes()); + assert_eq!(excerpt.chars().count(), 200); + } + + #[test] + fn static_token_provider_returns_its_fixed_token() { + use std::task::{Context, Poll, Waker}; + + // `StaticToken::token` never actually awaits anything, so a single + // synchronous poll with a no-op waker resolves it without needing a + // tokio runtime. + let provider = StaticToken::new("ghu_fixed"); + let mut fut = std::pin::pin!(provider.token()); + let mut cx = Context::from_waker(Waker::noop()); + let token = match fut.as_mut().poll(&mut cx) { + Poll::Ready(v) => v, + Poll::Pending => panic!("StaticToken::token unexpectedly pending"), + }; + assert_eq!(token.unwrap().expose_secret(), "ghu_fixed"); + } +} diff --git a/crates/github/src/scopes.rs b/crates/github/src/scopes.rs new file mode 100644 index 000000000..195185801 --- /dev/null +++ b/crates/github/src/scopes.rs @@ -0,0 +1,259 @@ +//! Typed GitHub App permission model (spec R5). +//! +//! The set of representable permissions is closed to exactly what the MVP needs. +//! Crucially, there is **no** `workflows` variant: the GitHub Actions workflow +//! permission is unrepresentable by construction, so no code path — not even a +//! typo — can request it (spec NG6). A request for `workflows` in a config +//! string is rejected as an unknown scope. + +use std::collections::BTreeMap; +use std::fmt; + +use crate::error::Error; + +/// A GitHub App repository permission. +/// +/// Ordering is the canonical consent-render order (`contents`, `pull_requests`, +/// `issues`, `metadata`) so a [`ScopeSet`] backed by a `BTreeMap` renders +/// deterministically. +/// +/// `#[non_exhaustive]`: more permissions may be added, but `workflows` will not +/// be (spec NG6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum Scope { + /// Repository contents: clone, fetch, push, branches, commits. + Contents, + /// Pull requests: open and update PRs. + PullRequests, + /// Issues: standard dev work via the GitHub MCP. + Issues, + /// Repository metadata: the mandatory read-only baseline. + Metadata, +} + +impl Scope { + /// The canonical wire/consent name of this permission. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Scope::Contents => "contents", + Scope::PullRequests => "pull_requests", + Scope::Issues => "issues", + Scope::Metadata => "metadata", + } + } + + /// Parses a canonical scope name. Unknown names — including the deliberately + /// excluded `workflows` (spec NG6) — are rejected. + pub fn parse(name: &str) -> Result { + match name { + "contents" => Ok(Scope::Contents), + "pull_requests" => Ok(Scope::PullRequests), + "issues" => Ok(Scope::Issues), + "metadata" => Ok(Scope::Metadata), + other => Err(Error::InvalidScope { + input: other.to_string(), + reason: "unknown or unsupported permission".to_string(), + }), + } + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The access level requested for a [`Scope`]. `Write` implies read (GitHub's +/// `write` permission subsumes `read`), rendered `rw` for consent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Permission { + /// Read-only access. + Read, + /// Read/write access (rendered `rw`). + Write, +} + +impl Permission { + /// The canonical consent/wire rendering: `read` or `rw`. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Permission::Read => "read", + Permission::Write => "rw", + } + } + + /// Parses a permission token. Accepts `read`, and `rw`/`write` for write. + pub fn parse(token: &str) -> Result { + match token { + "read" => Ok(Permission::Read), + "rw" | "write" => Ok(Permission::Write), + other => Err(Error::InvalidScope { + input: other.to_string(), + reason: "permission must be `read` or `rw`".to_string(), + }), + } + } +} + +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A resolved set of permissions to request, one level per [`Scope`]. +/// +/// The backing map keys on `Scope`, whose ordering is the consent-render order, +/// so [`ScopeSet::render_for_consent`] and [`ScopeSet::to_attr_value`] are +/// deterministic. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ScopeSet { + perms: BTreeMap, +} + +impl ScopeSet { + /// An empty scope set. + #[must_use] + pub fn empty() -> Self { + Self { + perms: BTreeMap::new(), + } + } + + /// The R5.1 default scope set: `contents:rw`, `pull_requests:rw`, + /// `issues:rw`, `metadata:read`. `workflows` is excluded (spec NG6) and is + /// not even representable here. + #[must_use] + pub fn defaults() -> Self { + let mut perms = BTreeMap::new(); + perms.insert(Scope::Contents, Permission::Write); + perms.insert(Scope::PullRequests, Permission::Write); + perms.insert(Scope::Issues, Permission::Write); + perms.insert(Scope::Metadata, Permission::Read); + Self { perms } + } + + /// Sets the permission for a scope, returning the updated set (builder style). + #[must_use] + pub fn with(mut self, scope: Scope, permission: Permission) -> Self { + self.perms.insert(scope, permission); + self + } + + /// The requested permission for `scope`, if any. + #[must_use] + pub fn permission(&self, scope: Scope) -> Option { + self.perms.get(&scope).copied() + } + + /// Whether the set requests any level for `scope`. + #[must_use] + pub fn contains(&self, scope: Scope) -> bool { + self.perms.contains_key(&scope) + } + + /// Iterates the `(scope, permission)` pairs in consent-render order. + pub fn iter(&self) -> impl Iterator + '_ { + self.perms.iter().map(|(s, p)| (*s, *p)) + } + + /// Whether the set is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.perms.is_empty() + } + + /// Human-facing consent line, e.g. `contents:rw, pull_requests:rw, + /// issues:rw, metadata:read` (spec R5.3). Comma-and-space separated. + #[must_use] + pub fn render_for_consent(&self) -> String { + self.perms + .iter() + .map(|(s, p)| format!("{s}:{p}")) + .collect::>() + .join(", ") + } + + /// Compact, parseable form for session `attrs`, e.g. + /// `contents:rw,pull_requests:rw,issues:rw,metadata:read` (no spaces). + #[must_use] + pub fn to_attr_value(&self) -> String { + self.perms + .iter() + .map(|(s, p)| format!("{s}:{p}")) + .collect::>() + .join(",") + } + + /// Parses the compact `attrs` form produced by [`ScopeSet::to_attr_value`]. + /// Rejects unknown scopes (including `workflows`) and malformed entries. + pub fn from_attr_value(value: &str) -> Result { + let mut perms = BTreeMap::new(); + for entry in value.split(',').map(str::trim).filter(|e| !e.is_empty()) { + let (scope, perm) = entry.split_once(':').ok_or_else(|| Error::InvalidScope { + input: entry.to_string(), + reason: "expected `scope:permission`".to_string(), + })?; + perms.insert(Scope::parse(scope)?, Permission::parse(perm)?); + } + Ok(Self { perms }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_r5_1() { + let set = ScopeSet::defaults(); + assert_eq!(set.permission(Scope::Contents), Some(Permission::Write)); + assert_eq!(set.permission(Scope::PullRequests), Some(Permission::Write)); + assert_eq!(set.permission(Scope::Issues), Some(Permission::Write)); + assert_eq!(set.permission(Scope::Metadata), Some(Permission::Read)); + } + + #[test] + fn defaults_render_in_canonical_order() { + assert_eq!( + ScopeSet::defaults().render_for_consent(), + "contents:rw, pull_requests:rw, issues:rw, metadata:read" + ); + } + + #[test] + fn workflows_is_unrepresentable_and_rejected() { + // Not a known scope: parsing any config that names it fails closed. + assert!(Scope::parse("workflows").is_err()); + assert!(ScopeSet::from_attr_value("workflows:read").is_err()); + assert!(ScopeSet::from_attr_value("contents:rw,workflows:rw").is_err()); + // And the defaults never carry it. + assert!( + !ScopeSet::defaults() + .render_for_consent() + .contains("workflows") + ); + } + + #[test] + fn attr_value_round_trips() { + let set = ScopeSet::defaults(); + let encoded = set.to_attr_value(); + assert_eq!( + encoded, + "contents:rw,pull_requests:rw,issues:rw,metadata:read" + ); + assert_eq!(ScopeSet::from_attr_value(&encoded).unwrap(), set); + } + + #[test] + fn rejects_malformed_scope_entries() { + assert!(ScopeSet::from_attr_value("contents").is_err()); // no permission + assert!(ScopeSet::from_attr_value("contents:sideways").is_err()); // bad perm + assert!(ScopeSet::from_attr_value("nope:rw").is_err()); // bad scope + } +} diff --git a/crates/github/src/secret.rs b/crates/github/src/secret.rs new file mode 100644 index 000000000..d8d1d9eab --- /dev/null +++ b/crates/github/src/secret.rs @@ -0,0 +1,94 @@ +//! [`SecretString`]: the single carrier type for GitHub token material. +//! +//! The security model (spec R6) says a token lives only in the daemon and never +//! enters a sandbox, log, or diagnostic bundle. This type enforces the parts of +//! that a type *can* enforce: +//! +//! * `Debug` and `Display` render `[REDACTED:gh]`, so a token cannot leak +//! through a formatting call, a `tracing` field, or a diagnostic dump. +//! * The inner bytes are zeroized on drop, so a freed token does not linger in +//! memory. +//! * There is deliberately **no** `serde` derive here. Serialization of a stored +//! token is the job of a future `store` module that will opt in explicitly; +//! until then, a raw `String` token — or a `#[derive(Serialize)]` on a +//! token-bearing struct — is a review-rejectable pattern, because the only +//! sanctioned carrier is this type and it will not serialize by accident. +//! +//! Reading the plaintext is intentionally awkward: the sole accessor is +//! [`SecretString::expose_secret`], named so that every read site is greppable +//! in review. + +use std::fmt; + +use zeroize::Zeroize; + +/// The redaction marker rendered by both `Debug` and `Display`. +pub const REDACTED: &str = "[REDACTED:gh]"; + +/// A string whose contents are secret (a GitHub user or refresh token). +/// +/// Construct with [`SecretString::new`]; read with +/// [`SecretString::expose_secret`]. Never logged, never serialized. +#[derive(Clone)] +pub struct SecretString(String); + +impl SecretString { + /// Wraps secret material. + #[must_use] + pub fn new(secret: impl Into) -> Self { + Self(secret.into()) + } + + /// Borrows the plaintext. The name makes every read site greppable; use it + /// only where the plaintext is genuinely required (e.g. building an + /// authenticated request in the daemon). + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(REDACTED) + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(REDACTED) + } +} + +impl Drop for SecretString { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// A `From` for ergonomic construction; still routes through the newtype. +impl From for SecretString { + fn from(secret: String) -> Self { + Self::new(secret) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_and_display_redact() { + let secret = SecretString::new("ghu_supersecrettoken"); + assert_eq!(format!("{secret}"), REDACTED); + assert_eq!(format!("{secret:?}"), REDACTED); + // The plaintext appears in neither rendering. + assert!(!format!("{secret} {secret:?}").contains("supersecret")); + } + + #[test] + fn expose_secret_returns_plaintext() { + let secret = SecretString::new("ghu_token"); + assert_eq!(secret.expose_secret(), "ghu_token"); + } +} diff --git a/crates/github/src/store.rs b/crates/github/src/store.rs new file mode 100644 index 000000000..1d388cd21 --- /dev/null +++ b/crates/github/src/store.rs @@ -0,0 +1,767 @@ +//! On-disk store for GitHub authentication grants (spec R1.3, R6.4). +//! +//! One JSON file per grant, under a directory the daemon injects (it will pass +//! `minimal_state_dir()/github/grants/`, mirroring the `Store` actor pattern +//! for session records at `crates/minimald/src/store.rs`). Multiple grants can +//! coexist on disk at once — the substrate the future `GrantManager` needs for +//! reuse-or-mint (spec R1.3, R6.4): a subsequent sandbox can either reuse an +//! existing grant or mint a fresh, separately-scoped one, and both then live +//! side by side. +//! +//! # Security posture +//! +//! - **File layout**: the directory is created/kept at mode `0700`, each +//! grant file at `0600`. Both are re-asserted (`chmod`) every time this +//! store opens the directory or reads an existing file, so permissions that +//! drifted (a stale daemon version, a manual `cp`, an odd umask) are +//! corrected rather than silently trusted. +//! - **Atomic writes**: [`GrantStore::save`] never writes the destination +//! path directly. It writes a sibling temp file (created with `0600` from +//! the first `open(2)`, mode re-asserted immediately after in case the +//! process umask widened it) and `rename(2)`s it into place. A crash or +//! error at any point before the rename leaves the previous file (or no +//! file) exactly as it was — never a half-written grant. +//! - **Secrets stay [`SecretString`]**: `access_token` and `refresh_token` +//! are the only fields carrying token material, and both are named with +//! `token` in the key, so the diagnostics redaction denylist +//! (`SENSITIVE_KEY_PARTS` in `crates/diagnostics/src/redact.rs`) masks them +//! by key name wherever this store's JSON is dumped (spec R6.2), on top of +//! `SecretString`'s own `Debug`/`Display` redaction. [`SecretString`] itself +//! carries no `serde` impl (see `secret.rs`); the (de)serialization for +//! those two fields is opted into explicitly here, via +//! `#[serde(with = "secret_codec")]`, so a token round-trips to disk without +//! ever widening `SecretString`'s own surface. +//! - **[`GrantStore::list`] returns [`GrantSummary`]**, a type that +//! structurally has no token field — token material never leaves this +//! module except through [`GrantStore::get`], which yields a full +//! [`Grant`] for the one caller that legitimately needs it (a future +//! `GrantManager` building an authenticated request). +//! - **Grant ids are filesystem components.** [`GrantId`] permits any +//! non-empty string; this store additionally requires ids to be safe as a +//! bare filename (ASCII alphanumerics, `-`, `_`) and fails closed +//! ([`std::io::ErrorKind::InvalidInput`]) on anything else, so a +//! maliciously- or accidentally-crafted grant id can never traverse out of +//! the grants directory. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::scopes::ScopeSet; +use crate::secret::SecretString; +use crate::types::GrantId; + +/// Permission bits asserted on the grants directory. +#[cfg(unix)] +const DIR_MODE: u32 = 0o700; +/// Permission bits asserted on each grant file. +#[cfg(unix)] +const FILE_MODE: u32 = 0o600; + +/// Lifecycle state of a stored grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrantState { + /// The grant's tokens are, as far as this store knows, still usable. + Valid, + /// The refresh token has expired or been revoked; the user must + /// re-authenticate (spec R1.2) before this grant can be used again. + NeedsReauth, +} + +/// A stored GitHub authentication grant: the user + refresh tokens plus +/// enough metadata to display, refresh, and manage it (spec R1.3, R6.4). +/// +/// `Debug` is safe to log: the two `SecretString` fields render as +/// `[REDACTED:gh]` (see `secret.rs`), and every other field is non-secret +/// (a login, a numeric id, scope grants, and timestamps). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Grant { + /// Opaque id naming this grant (spec R6.4 reuse-or-mint); also the + /// on-disk file's stem. + #[serde(with = "grant_id_codec")] + pub grant_id: GrantId, + /// The authenticated GitHub login (spec R1.4, G4 real-user attribution). + pub github_login: String, + /// The authenticated GitHub user's numeric id. + pub github_user_id: u64, + /// The permission set this grant was minted with (spec R5). + #[serde(with = "scope_set_codec")] + pub scopes: ScopeSet, + /// The user-to-server access token (~8h lifetime). Never logged, never + /// enters a sandbox (spec R6.1). + #[serde(with = "secret_codec")] + pub access_token: SecretString, + /// Expiry of `access_token`. + pub access_token_expires_at: DateTime, + /// The rotating refresh token (~6mo lifetime). Never logged, never + /// enters a sandbox (spec R6.1). + #[serde(with = "secret_codec")] + pub refresh_token: SecretString, + /// Expiry of `refresh_token`; past this point the grant needs re-auth + /// rather than a silent refresh (spec R1.2). + pub refresh_token_expires_at: DateTime, + /// When this grant was first minted. + pub created_at: DateTime, + /// When `access_token` was last refreshed (initially equal to + /// `created_at`). + pub last_refreshed_at: DateTime, + /// Whether the grant is usable or needs re-authentication. + pub state: GrantState, +} + +/// Metadata-only view of a [`Grant`], with no field that can carry token +/// material — the shape [`GrantStore::list`] returns, so enumerating grants +/// (e.g. for `min github status`) can never leak a token (spec R6.1, R6.2). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrantSummary { + /// See [`Grant::grant_id`]. + #[serde(with = "grant_id_codec")] + pub grant_id: GrantId, + /// See [`Grant::github_login`]. + pub github_login: String, + /// See [`Grant::github_user_id`]. + pub github_user_id: u64, + /// See [`Grant::scopes`]. + #[serde(with = "scope_set_codec")] + pub scopes: ScopeSet, + /// See [`Grant::access_token_expires_at`]. + pub access_token_expires_at: DateTime, + /// See [`Grant::refresh_token_expires_at`]. + pub refresh_token_expires_at: DateTime, + /// See [`Grant::created_at`]. + pub created_at: DateTime, + /// See [`Grant::last_refreshed_at`]. + pub last_refreshed_at: DateTime, + /// See [`Grant::state`]. + pub state: GrantState, +} + +impl From<&Grant> for GrantSummary { + fn from(g: &Grant) -> Self { + Self { + grant_id: g.grant_id.clone(), + github_login: g.github_login.clone(), + github_user_id: g.github_user_id, + scopes: g.scopes.clone(), + access_token_expires_at: g.access_token_expires_at, + refresh_token_expires_at: g.refresh_token_expires_at, + created_at: g.created_at, + last_refreshed_at: g.last_refreshed_at, + state: g.state, + } + } +} + +/// `serde(with = ...)` bridge for [`GrantId`]. `GrantId` deliberately has no +/// `serde` derive of its own (it is a domain type shared far beyond this +/// store); this module opts a `Grant`/`GrantSummary` field into a plain +/// string encoding without widening `GrantId`'s own surface. +mod grant_id_codec { + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::types::GrantId; + + pub fn serialize(id: &GrantId, s: S) -> Result { + s.serialize_str(id.as_str()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let raw = String::deserialize(d)?; + GrantId::new(raw).map_err(serde::de::Error::custom) + } +} + +/// `serde(with = ...)` bridge for [`ScopeSet`], using its existing compact +/// `attrs`-style encoding (`contents:rw,pull_requests:rw,...`) as the on-disk +/// form too, so the two codecs (session `attrs`, grant store) never drift. +mod scope_set_codec { + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::scopes::ScopeSet; + + pub fn serialize(scopes: &ScopeSet, s: S) -> Result { + s.serialize_str(&scopes.to_attr_value()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let raw = String::deserialize(d)?; + ScopeSet::from_attr_value(&raw).map_err(serde::de::Error::custom) + } +} + +/// `serde(with = ...)` bridge for [`SecretString`]. `SecretString` +/// deliberately carries no `serde` impl of its own (see `secret.rs`) — this +/// is the one place, opted into explicitly and by name, where a token is +/// allowed to round-trip through a string for on-disk storage. +mod secret_codec { + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::secret::SecretString; + + pub fn serialize(secret: &SecretString, s: S) -> Result { + s.serialize_str(secret.expose_secret()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let raw = String::deserialize(d)?; + Ok(SecretString::new(raw)) + } +} + +/// Validates that `grant_id` is safe to use as a bare filename component: +/// non-empty, ASCII alphanumerics/`-`/`_` only, bounded length. `GrantId` +/// itself only guarantees non-empty (it is a general-purpose domain type), +/// so this store adds its own fail-closed check rather than trusting an +/// arbitrary string into a path — the input is not echoed, since grant ids +/// may originate from paths not fully under this crate's control. +fn filename_component(grant_id: &GrantId) -> io::Result<&str> { + let s = grant_id.as_str(); + let safe = !s.is_empty() + && s.len() <= 128 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')); + if !safe { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "grant id is not safe for use as a filename (only ASCII letters, digits, `-`, \ + and `_` are allowed)", + )); + } + Ok(s) +} + +/// Re-asserts `0700` on a directory. No-op on non-Unix targets (this store's +/// production use is Linux/macOS only, per the workspace's platform matrix). +#[cfg(unix)] +fn assert_dir_mode(dir: &Path) -> io::Result<()> { + fs::set_permissions(dir, fs::Permissions::from_mode(DIR_MODE)) +} +#[cfg(not(unix))] +fn assert_dir_mode(_dir: &Path) -> io::Result<()> { + Ok(()) +} + +/// Re-asserts `0600` on a file, by path. No-op on non-Unix targets. +#[cfg(unix)] +fn assert_file_mode(path: &Path) -> io::Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)) +} +#[cfg(not(unix))] +fn assert_file_mode(_path: &Path) -> io::Result<()> { + Ok(()) +} + +/// Re-asserts `0600` on an already-open file. Preferred over the path-based +/// [`assert_file_mode`] when a file handle is already in hand (`get`, +/// `list`): it corrects the permissions of the exact inode just opened, +/// rather than re-resolving the path (which could, in principle, now name +/// something else). No-op on non-Unix targets. +#[cfg(unix)] +fn assert_file_mode_fd(file: &fs::File) -> io::Result<()> { + file.set_permissions(fs::Permissions::from_mode(FILE_MODE)) +} +#[cfg(not(unix))] +fn assert_file_mode_fd(_file: &fs::File) -> io::Result<()> { + Ok(()) +} + +/// Opens a fresh file at `path` for writing, refusing if something is +/// already there (the caller is responsible for clearing a stale temp file +/// first). Created at `0600` directly (via `open(2)`'s mode argument) and +/// then `chmod`'d again immediately, closing the (typically zero-width) +/// window a permissive umask could otherwise leave open. +fn create_private_file(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(FILE_MODE); + let file = options.open(path)?; + assert_file_mode(path)?; + Ok(file) +} + +/// On-disk store for GitHub authentication [`Grant`]s: one JSON file per +/// grant under an injected directory. See the module docs for the security +/// properties this type maintains. +#[derive(Debug, Clone)] +pub struct GrantStore { + dir: PathBuf, +} + +impl GrantStore { + /// Opens (creating if necessary) a grant store rooted at `dir`. Asserts + /// the directory exists at mode `0700`, correcting it if it already + /// existed with different permissions. + /// + /// # Errors + /// + /// I/O error if the directory cannot be created or its permissions + /// cannot be set. + pub fn open(dir: impl Into) -> io::Result { + let dir = dir.into(); + fs::create_dir_all(&dir)?; + assert_dir_mode(&dir)?; + Ok(Self { dir }) + } + + /// The directory this store is rooted at. + #[must_use] + pub fn dir(&self) -> &Path { + &self.dir + } + + /// The path a grant's JSON file would live at. + fn path_for(&self, grant_id: &GrantId) -> io::Result { + Ok(self + .dir + .join(format!("{}.json", filename_component(grant_id)?))) + } + + /// The path a grant's write-in-progress temp file lives at, alongside + /// (never inside) the final path so the closing `rename(2)` is same- + /// directory and therefore atomic. + fn tmp_path_for(&self, grant_id: &GrantId) -> io::Result { + Ok(self + .dir + .join(format!("{}.json.tmp", filename_component(grant_id)?))) + } + + /// Writes `grant` to disk, atomically. A prior grant at the same id is + /// replaced only once the new content is fully written and `fsync`'d — + /// a crash or error at any earlier point leaves the previous file (or no + /// file) untouched, never a partially written one. + /// + /// # Errors + /// + /// I/O error if `grant.grant_id` isn't filename-safe, or if the + /// temp-file write, `fsync`, or rename fails. + pub fn save(&self, grant: &Grant) -> io::Result<()> { + let final_path = self.path_for(&grant.grant_id)?; + let tmp_path = self.tmp_path_for(&grant.grant_id)?; + + // Clear any stale temp file left by a previous crash before writing + // a fresh one; `create_private_file` refuses to write over an + // existing path. Anything else in the way (e.g. the path + // unexpectedly being a directory) is a real error, not something to + // paper over. + if let Err(e) = fs::remove_file(&tmp_path) + && e.kind() != io::ErrorKind::NotFound + { + return Err(e); + } + + let file = create_private_file(&tmp_path)?; + let write_result = serde_json::to_writer_pretty(&file, grant) + .map_err(io::Error::from) + .and_then(|()| file.sync_all()); + drop(file); + if let Err(e) = write_result { + // Best-effort cleanup of the half-written temp file; the final + // path was never touched, so the store's visible state is + // unchanged regardless of whether this cleanup succeeds. + let _ = fs::remove_file(&tmp_path); + return Err(e); + } + + fs::rename(&tmp_path, &final_path)?; + self.sync_dir() + } + + /// `fsync`s the grants directory itself, making the just-completed + /// `rename(2)` durable. Without this, a power loss shortly after `save` + /// returns could resurrect the *previous* file — which, for a refresh- + /// token **rotation** (see `refresh.rs`), would mean a refresh token + /// GitHub has already invalidated: a bricked grant. No-op on non-Unix + /// targets (directories aren't openable there; this store's production + /// use is Linux/macOS only). + #[cfg(unix)] + fn sync_dir(&self) -> io::Result<()> { + fs::File::open(&self.dir)?.sync_all() + } + #[cfg(not(unix))] + fn sync_dir(&self) -> io::Result<()> { + Ok(()) + } + + /// Reads back a single grant by id, or `None` if no grant with that id + /// is stored. + /// + /// Re-asserts `0600` on the file before reading it, in case its + /// permissions drifted since it was written. + /// + /// # Errors + /// + /// I/O error if `grant_id` isn't filename-safe, if the file exists but + /// its permissions can't be corrected, or if it can't be read/parsed as + /// a [`Grant`]. + pub fn get(&self, grant_id: &GrantId) -> io::Result> { + let path = self.path_for(grant_id)?; + let file = match fs::File::open(&path) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + assert_file_mode_fd(&file)?; + let grant: Grant = serde_json::from_reader(file).map_err(io::Error::from)?; + Ok(Some(grant)) + } + + /// Lists every stored grant as metadata-only [`GrantSummary`] values, in + /// grant-id order. Token material is never read into a `GrantSummary` + /// (see the module docs). + /// + /// # Errors + /// + /// I/O error if the directory can't be enumerated, or if a stored grant + /// file's permissions can't be corrected or it can't be read/parsed. + pub fn list(&self) -> io::Result> { + let mut out = Vec::new(); + let entries = match fs::read_dir(&self.dir) { + Ok(rd) => rd, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e), + }; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + // Only well-formed `.json` grant files; skips index-less + // leftovers such as a crashed `.json.tmp` write. + if !name.ends_with(".json") { + continue; + } + let path = entry.path(); + let file = fs::File::open(&path)?; + assert_file_mode_fd(&file)?; + let grant: Grant = serde_json::from_reader(file).map_err(io::Error::from)?; + out.push(GrantSummary::from(&grant)); + } + out.sort_by(|a, b| a.grant_id.cmp(&b.grant_id)); + Ok(out) + } + + /// Deletes a stored grant. Deleting an id that isn't stored is not an + /// error (idempotent). + /// + /// # Errors + /// + /// I/O error if `grant_id` isn't filename-safe or the file can't be + /// removed for a reason other than already being absent. + pub fn delete(&self, grant_id: &GrantId) -> io::Result<()> { + let path = self.path_for(grant_id)?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_grant(id: &str, login: &str, token: &str) -> Grant { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + Grant { + grant_id: GrantId::new(id).unwrap(), + github_login: login.to_string(), + github_user_id: 42, + scopes: ScopeSet::defaults(), + access_token: SecretString::new(format!("ghu_{token}")), + access_token_expires_at: now + chrono::Duration::hours(8), + refresh_token: SecretString::new(format!("ghr_{token}")), + refresh_token_expires_at: now + chrono::Duration::days(180), + created_at: now, + last_refreshed_at: now, + state: GrantState::Valid, + } + } + + fn grants_dir(tmp: &TempDir) -> PathBuf { + tmp.path().join("github").join("grants") + } + + #[test] + fn round_trips_every_field() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let grant = sample_grant("grant-1", "octocat", "secrettoken123"); + store.save(&grant).unwrap(); + + let loaded = store.get(&grant.grant_id).unwrap().expect("grant present"); + assert_eq!(loaded.grant_id, grant.grant_id); + assert_eq!(loaded.github_login, grant.github_login); + assert_eq!(loaded.github_user_id, grant.github_user_id); + assert_eq!(loaded.scopes, grant.scopes); + assert_eq!( + loaded.access_token.expose_secret(), + grant.access_token.expose_secret() + ); + assert_eq!( + loaded.access_token_expires_at, + grant.access_token_expires_at + ); + assert_eq!( + loaded.refresh_token.expose_secret(), + grant.refresh_token.expose_secret() + ); + assert_eq!( + loaded.refresh_token_expires_at, + grant.refresh_token_expires_at + ); + assert_eq!(loaded.created_at, grant.created_at); + assert_eq!(loaded.last_refreshed_at, grant.last_refreshed_at); + assert_eq!(loaded.state, grant.state); + } + + #[test] + fn get_of_unknown_grant_is_none_not_error() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + assert!( + store + .get(&GrantId::new("does-not-exist").unwrap()) + .unwrap() + .is_none() + ); + } + + #[test] + fn multiple_grants_coexist() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let a = sample_grant("grant-a", "alice", "tok-a"); + let b = sample_grant("grant-b", "bob", "tok-b"); + store.save(&a).unwrap(); + store.save(&b).unwrap(); + + // Both are independently readable... + assert_eq!( + store.get(&a.grant_id).unwrap().unwrap().github_login, + "alice" + ); + assert_eq!(store.get(&b.grant_id).unwrap().unwrap().github_login, "bob"); + + // ...and both show up in the listing. + let logins: Vec = store + .list() + .unwrap() + .into_iter() + .map(|s| s.github_login) + .collect(); + assert_eq!(logins, vec!["alice".to_string(), "bob".to_string()]); + } + + #[test] + fn deleting_one_grant_leaves_others_intact() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let a = sample_grant("grant-a", "alice", "tok-a"); + let b = sample_grant("grant-b", "bob", "tok-b"); + store.save(&a).unwrap(); + store.save(&b).unwrap(); + + store.delete(&a.grant_id).unwrap(); + + assert!(store.get(&a.grant_id).unwrap().is_none()); + assert_eq!(store.get(&b.grant_id).unwrap().unwrap().github_login, "bob"); + assert_eq!(store.list().unwrap().len(), 1); + } + + #[test] + fn delete_of_unknown_grant_is_not_an_error() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + store + .delete(&GrantId::new("never-existed").unwrap()) + .unwrap(); + } + + #[test] + fn list_summary_carries_no_token_field() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let secret_marker = "very-secret-marker-xyz"; + store + .save(&sample_grant("grant-1", "octocat", secret_marker)) + .unwrap(); + + let summaries = store.list().unwrap(); + assert_eq!(summaries.len(), 1); + + // Structural guarantee (GrantSummary has no `access_token` / + // `refresh_token` field: only their non-secret `*_expires_at` + // timestamps) plus a belt-and-suspenders check that the actual + // secret value never made it into the serialized summary. + let encoded = serde_json::to_string(&summaries[0]).unwrap(); + assert!(!encoded.contains(secret_marker)); + assert!(!encoded.contains("\"access_token\"")); + assert!(!encoded.contains("\"refresh_token\"")); + } + + #[test] + fn debug_of_loaded_grant_leaks_nothing() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let secret_marker = "leak-would-show-up-here"; + let grant = sample_grant("grant-1", "octocat", secret_marker); + store.save(&grant).unwrap(); + let loaded = store.get(&grant.grant_id).unwrap().unwrap(); + + let debugged = format!("{loaded:?}"); + assert!(!debugged.contains(secret_marker)); + assert!(debugged.contains(crate::secret::REDACTED)); + } + + #[cfg(unix)] + #[test] + fn directory_and_file_modes_are_asserted() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + store + .save(&sample_grant("grant-1", "octocat", "t")) + .unwrap(); + + let dir_mode = fs::metadata(store.dir()).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "grants dir must be 0700"); + + let file_mode = fs::metadata(store.dir().join("grant-1.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600, "grant file must be 0600"); + } + + #[cfg(unix)] + #[test] + fn dir_mode_is_reasserted_on_reopen() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let dir = grants_dir(&tmp); + GrantStore::open(&dir).unwrap(); + + // Simulate permission drift, then reopen. + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + GrantStore::open(&dir).unwrap(); + + let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o700, + "reopening must correct drifted dir permissions" + ); + } + + #[cfg(unix)] + #[test] + fn file_mode_is_reasserted_on_open_of_pre_existing_file() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + let grant = sample_grant("grant-1", "octocat", "t"); + store.save(&grant).unwrap(); + + let file_path = store.dir().join("grant-1.json"); + // Simulate permission drift on an already-written grant file. + fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap(); + + // A single `get` must correct it back to 0600. + store.get(&grant.grant_id).unwrap(); + let mode = fs::metadata(&file_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "get() must correct drifted file permissions"); + + // Drift again and prove `list` corrects it too. + fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap(); + store.list().unwrap(); + let mode = fs::metadata(&file_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "list() must correct drifted file permissions"); + } + + #[test] + fn atomic_write_leaves_no_partial_file_on_simulated_failure() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + let original = sample_grant("grant-1", "octocat", "original-token"); + store.save(&original).unwrap(); + + let final_path = store.dir().join("grant-1.json"); + let before = fs::read(&final_path).unwrap(); + + // Force the write to fail partway through: put a directory where the + // temp file needs to be created. `create_private_file`'s + // `create_new` open then fails before a single byte of the new + // content is written, and the final path is never touched. + let tmp_path = store.dir().join("grant-1.json.tmp"); + fs::create_dir(&tmp_path).unwrap(); + + let mut updated = original.clone(); + updated.github_login = "attacker-controlled-update".to_string(); + // The exact `ErrorKind` a directory-in-the-way produces isn't the + // point here (it varies: removing a directory via `remove_file` + // doesn't uniformly map to one kind); what matters is that the save + // fails and never touches the destination. + store.save(&updated).unwrap_err(); + + // The destination file is untouched: same bytes as before the failed + // save, and still parses as the *original* grant, not a partial or + // updated one. + let after = fs::read(&final_path).unwrap(); + assert_eq!( + before, after, + "final file must be untouched by a failed write" + ); + let reread = store.get(&original.grant_id).unwrap().unwrap(); + assert_eq!(reread.github_login, "octocat"); + + // Nothing masquerading as the temp file: it's still the directory we + // planted, never replaced by a (partial or complete) regular file. + assert!(tmp_path.is_dir()); + } + + #[test] + fn rejects_grant_id_unsafe_as_a_filename() { + let tmp = TempDir::new().unwrap(); + let store = GrantStore::open(grants_dir(&tmp)).unwrap(); + + // `GrantId` itself only forbids empty strings, so a traversal-shaped + // id can reach the store; the store must still refuse it rather than + // resolve it into a path outside its own directory. + let traversal_id = GrantId::new("../../etc/passwd").unwrap(); + let mut grant = sample_grant("placeholder", "octocat", "t"); + grant.grant_id = traversal_id.clone(); + + let err = store.save(&grant).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!(store.get(&traversal_id).is_err()); + assert!(store.delete(&traversal_id).is_err()); + + // Nothing escaped the grants directory. + assert!(!tmp.path().join("etc").exists()); + } +} diff --git a/crates/github/src/testing/fixtures.rs b/crates/github/src/testing/fixtures.rs new file mode 100644 index 000000000..fb070ce00 --- /dev/null +++ b/crates/github/src/testing/fixtures.rs @@ -0,0 +1,576 @@ +//! Programmable state and OAuth/REST route logic for the mock GitHub server. +//! +//! [`Fixtures`] is the mutable heart of the mock: it holds the scripted device +//! flow, the token/refresh behaviour (including refresh-token **rotation**), the +//! authenticated user, per-repo installation and default-branch state, and the +//! in-memory pull-request table. The HTTP transport ([`super::http`]) hands each +//! parsed request to [`Fixtures::handle_oauth`] or [`Fixtures::handle_rest`], +//! which read/mutate this state and build a [`Response`] — synchronously, so the +//! caller never holds the guard across an `.await`. +//! +//! Everything here speaks the real GitHub wire shapes (form-encoded token +//! exchange, the `error`/`error_description` device-flow envelope, the REST JSON +//! objects) so the production device-flow and REST clients can be pointed at the +//! mock unchanged via the `MINIMALD_GITHUB_*_BASE_URL` overrides. + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::http::{Request, Response}; + +/// One scripted step of the device-code token-exchange poll +/// (`grant_type=urn:ietf:params:oauth:grant-type:device_code`). +/// +/// A test scripts a sequence with [`Fixtures::script_device_exchange`]; each +/// poll consumes the next step, and the **last** step is sticky (repeated once +/// the queue is exhausted) so an `Approve` at the end stays approved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TokenStep { + /// `authorization_pending` — the user has not yet approved. + Pending, + /// `slow_down` — the client is polling too fast; interval is bumped. + SlowDown, + /// `expired_token` — the device code expired before approval. + Expired, + /// `access_denied` — the user rejected the request. + AccessDenied, + /// The user approved: mint a fresh access + refresh token pair. + Approve, +} + +/// One scripted step of a refresh-token exchange (`grant_type=refresh_token`). +/// +/// The default behaviour with no script is [`RefreshStep::Rotate`] on every +/// call. Scripted sequences are sticky-on-last like [`TokenStep`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RefreshStep { + /// Succeed and rotate: mint a fresh access token **and a fresh refresh + /// token** (GitHub rotates refresh tokens on every refresh). + Rotate, + /// `invalid_grant` — the refresh token is expired/revoked; the caller must + /// re-authenticate. + InvalidGrant, +} + +/// Per-repository mock state. +#[derive(Debug, Clone)] +struct RepoFixture { + default_branch: String, + installed: bool, + pulls: Vec, +} + +impl Default for RepoFixture { + fn default() -> Self { + Self { + // Happy-path default so a repo works without explicit setup; tests + // flip these to exercise the not-installed / other-branch paths. + default_branch: "main".to_string(), + installed: true, + pulls: Vec::new(), + } + } +} + +/// An in-memory pull request. +#[derive(Debug, Clone)] +struct Pull { + number: u64, + title: String, + head: String, + base: String, + body: String, + html_url: String, +} + +/// The authenticated GitHub user the mock reports from `GET /user`. +#[derive(Debug, Clone)] +struct UserFixture { + login: String, + id: u64, +} + +/// Programmable state backing the mock GitHub server. +/// +/// Construct via [`Fixtures::default`] (which yields sensible happy-path +/// defaults) and mutate through the setter methods before — or between — +/// requests. +#[derive(Debug)] +pub struct Fixtures { + // Device flow. + device_code: String, + user_code: String, + verification_uri: String, + device_interval: u64, + device_expires_in: u64, + token_script: Vec, + token_cursor: usize, + + // Token/refresh minting. + refresh_script: Vec, + refresh_cursor: usize, + token_seq: u64, + access_ttl: u64, + refresh_ttl: u64, + last_refresh_token: Option, + strict_refresh: bool, + + // REST. + user: UserFixture, + force_unauthorized: bool, + repos: BTreeMap<(String, String), RepoFixture>, + next_pull_number: u64, + + /// Public base URL of the mock, used to build `html_url`s. Set by the server + /// once it has bound a port. + public_base: String, +} + +impl Default for Fixtures { + fn default() -> Self { + Self { + device_code: "mock_device_code".to_string(), + user_code: "WDJB-MJHT".to_string(), + verification_uri: "https://github.com/login/device".to_string(), + device_interval: 5, + device_expires_in: 900, + // Empty script => approve immediately, so the common case (drive a + // full login) needs no setup. + token_script: Vec::new(), + token_cursor: 0, + refresh_script: Vec::new(), + refresh_cursor: 0, + token_seq: 0, + access_ttl: 28_800, // ~8h, like a real user token + refresh_ttl: 15_897_600, // ~6mo + last_refresh_token: None, + strict_refresh: false, + user: UserFixture { + login: "octocat".to_string(), + id: 583_231, + }, + force_unauthorized: false, + repos: BTreeMap::new(), + next_pull_number: 1000, + public_base: "https://github.example".to_string(), + } + } +} + +impl Fixtures { + // --- device-flow / token scripting -------------------------------------- + + /// Sets the device/user codes and verification URI returned by + /// `POST /login/device/code`. + pub fn set_device_codes( + &mut self, + device_code: impl Into, + user_code: impl Into, + verification_uri: impl Into, + ) { + self.device_code = device_code.into(); + self.user_code = user_code.into(); + self.verification_uri = verification_uri.into(); + } + + /// Sets the poll `interval` (seconds) advertised in the device-code and + /// `slow_down` responses. + pub fn set_device_interval(&mut self, seconds: u64) { + self.device_interval = seconds; + } + + /// Scripts the device-code token-exchange poll. The sequence is consumed one + /// step per poll; the last step is sticky. An empty script approves + /// immediately. + pub fn script_device_exchange(&mut self, steps: impl IntoIterator) { + self.token_script = steps.into_iter().collect(); + self.token_cursor = 0; + } + + /// Scripts refresh-token exchanges. Empty (the default) rotates on every + /// call; otherwise the sequence is consumed one step per refresh, last + /// sticky. + pub fn script_refresh(&mut self, steps: impl IntoIterator) { + self.refresh_script = steps.into_iter().collect(); + self.refresh_cursor = 0; + } + + /// When enabled, a refresh presenting anything other than the most recently + /// issued refresh token is rejected with `invalid_grant` — modelling + /// GitHub's refresh-token rotation, so a lost rotation bricks the grant. + pub fn set_strict_refresh(&mut self, strict: bool) { + self.strict_refresh = strict; + } + + /// Sets the access-token and refresh-token lifetimes (seconds) reported in + /// token responses. + pub fn set_token_ttls(&mut self, access_ttl: u64, refresh_ttl: u64) { + self.access_ttl = access_ttl; + self.refresh_ttl = refresh_ttl; + } + + // --- REST state --------------------------------------------------------- + + /// Sets the login and numeric id reported by `GET /user`. + pub fn set_user(&mut self, login: impl Into, id: u64) { + self.user = UserFixture { + login: login.into(), + id, + }; + } + + /// When enabled, authenticated REST endpoints answer `401 Bad credentials`, + /// exercising the "401 → needs re-auth" client path. + pub fn set_force_unauthorized(&mut self, unauthorized: bool) { + self.force_unauthorized = unauthorized; + } + + /// Sets whether the App is installed on `owner/repo` (spec R1.5). Not + /// installed makes `GET /repos/{o}/{r}/installation` answer `404`. + pub fn set_installed(&mut self, owner: &str, repo: &str, installed: bool) { + self.repo_entry(owner, repo).installed = installed; + } + + /// Sets the default branch reported for `owner/repo`. + pub fn set_default_branch(&mut self, owner: &str, repo: &str, branch: impl Into) { + self.repo_entry(owner, repo).default_branch = branch.into(); + } + + /// Seeds an existing open pull request for `owner/repo` so + /// existing-PR-by-head detection (spec R4.5) fires. Returns the PR number. + pub fn add_pull( + &mut self, + owner: &str, + repo: &str, + head: impl Into, + base: impl Into, + ) -> u64 { + let number = self.next_pull_number; + self.next_pull_number += 1; + let head = head.into(); + let base = base.into(); + let html_url = format!("{}/{owner}/{repo}/pull/{number}", self.public_base); + self.repo_entry(owner, repo).pulls.push(Pull { + number, + title: format!("PR for {head}"), + head, + base, + body: String::new(), + html_url, + }); + number + } + + /// Sets the base URL used to construct `html_url`s. Called by the server + /// once bound. + pub(super) fn set_public_base(&mut self, base: impl Into) { + self.public_base = base.into(); + } + + // --- routing ------------------------------------------------------------ + + /// Handles an OAuth / device-flow request (`/login/...`). Synchronous: no + /// `.await`, so the caller can hold the state lock across it. + pub(super) fn handle_oauth(&mut self, req: &Request) -> Response { + match req.path.as_str() { + "/login/device/code" => self.device_code_response(), + "/login/oauth/access_token" => self.token_response(req), + _ => not_found("unknown OAuth endpoint"), + } + } + + /// Handles a REST request. Synchronous, as [`Fixtures::handle_oauth`]. + pub(super) fn handle_rest(&mut self, req: &Request) -> Response { + let segments = req.segments(); + match (req.method.as_str(), segments.as_slice()) { + ("GET", ["user"]) => self.user_response(), + ("GET", ["repos", owner, repo]) => self.repo_response(owner, repo), + ("GET", ["repos", owner, repo, "installation"]) => { + self.installation_response(owner, repo) + } + ("GET", ["repos", owner, repo, "pulls"]) => self.list_pulls_response(owner, repo, req), + ("POST", ["repos", owner, repo, "pulls"]) => { + self.create_pull_response(owner, repo, req) + } + _ => not_found("unknown REST endpoint"), + } + } + + // --- device flow -------------------------------------------------------- + + fn device_code_response(&self) -> Response { + Response::json( + 200, + &json!({ + "device_code": self.device_code, + "user_code": self.user_code, + "verification_uri": self.verification_uri, + "expires_in": self.device_expires_in, + "interval": self.device_interval, + }), + ) + } + + fn token_response(&mut self, req: &Request) -> Response { + let form = parse_form(&req.body); + let grant_type = form_get(&form, "grant_type").unwrap_or_default(); + if grant_type == "refresh_token" { + let presented = form_get(&form, "refresh_token").unwrap_or_default(); + self.refresh_response(&presented) + } else { + self.device_poll_response() + } + } + + fn device_poll_response(&mut self) -> Response { + match self.next_token_step() { + TokenStep::Pending => oauth_error(200, "authorization_pending", "waiting for the user"), + TokenStep::SlowDown => Response::json( + 200, + &json!({ + "error": "slow_down", + "error_description": "polling too fast", + "interval": self.device_interval + 5, + }), + ), + TokenStep::Expired => oauth_error(200, "expired_token", "the device code has expired"), + TokenStep::AccessDenied => { + oauth_error(200, "access_denied", "the user cancelled the request") + } + TokenStep::Approve => self.mint_token_response(), + } + } + + fn refresh_response(&mut self, presented: &str) -> Response { + if self.strict_refresh { + match &self.last_refresh_token { + Some(expected) if expected == presented => {} + _ => { + return oauth_error( + 200, + "invalid_grant", + "the refresh token is expired or was already rotated", + ); + } + } + } + match self.next_refresh_step() { + RefreshStep::Rotate => self.mint_token_response(), + RefreshStep::InvalidGrant => oauth_error( + 200, + "invalid_grant", + "the refresh token is expired or revoked", + ), + } + } + + /// Mints a fresh access + refresh token pair (always a new refresh token, so + /// callers observe rotation) and returns the OAuth success envelope. + fn mint_token_response(&mut self) -> Response { + self.token_seq += 1; + let seq = self.token_seq; + let access = format!("ghu_mock_access_{seq}"); + let refresh = format!("ghr_mock_refresh_{seq}"); + self.last_refresh_token = Some(refresh.clone()); + Response::json( + 200, + &json!({ + "access_token": access, + "expires_in": self.access_ttl, + "refresh_token": refresh, + "refresh_token_expires_in": self.refresh_ttl, + "token_type": "bearer", + "scope": "", + }), + ) + } + + fn next_token_step(&mut self) -> TokenStep { + step_at(&self.token_script, &mut self.token_cursor).unwrap_or(TokenStep::Approve) + } + + fn next_refresh_step(&mut self) -> RefreshStep { + step_at(&self.refresh_script, &mut self.refresh_cursor).unwrap_or(RefreshStep::Rotate) + } + + // --- REST --------------------------------------------------------------- + + fn user_response(&self) -> Response { + if self.force_unauthorized { + return bad_credentials(); + } + Response::json( + 200, + &json!({ "login": self.user.login, "id": self.user.id }), + ) + } + + fn repo_response(&mut self, owner: &str, repo: &str) -> Response { + if self.force_unauthorized { + return bad_credentials(); + } + let entry = self.repo_entry(owner, repo); + Response::json( + 200, + &json!({ + "name": repo, + "full_name": format!("{owner}/{repo}"), + "owner": { "login": owner }, + "default_branch": entry.default_branch, + }), + ) + } + + fn installation_response(&mut self, owner: &str, repo: &str) -> Response { + if self.force_unauthorized { + return bad_credentials(); + } + if self.repo_entry(owner, repo).installed { + Response::json( + 200, + &json!({ + "id": 42, + "app_slug": "minimal", + "target_type": "User", + "account": { "login": owner }, + }), + ) + } else { + // GitHub answers 404 when the App is not installed on the repo. + Response::json( + 404, + &json!({ + "message": "Not Found", + "documentation_url": + "https://docs.github.com/rest/apps/apps#get-a-repository-installation-for-the-authenticated-app", + }), + ) + } + } + + fn list_pulls_response(&mut self, owner: &str, repo: &str, req: &Request) -> Response { + if self.force_unauthorized { + return bad_credentials(); + } + let query = parse_form(req.query.as_bytes()); + let head_filter = form_get(&query, "head").map(|h| branch_of_head(&h)); + let entry = self.repo_entry(owner, repo); + let matches: Vec<_> = entry + .pulls + .iter() + .filter(|p| head_filter.as_deref().is_none_or(|b| p.head == b)) + .map(|p| pull_json(owner, repo, p)) + .collect(); + Response::json(200, &json!(matches)) + } + + fn create_pull_response(&mut self, owner: &str, repo: &str, req: &Request) -> Response { + if self.force_unauthorized { + return bad_credentials(); + } + let payload: serde_json::Value = match serde_json::from_slice(&req.body) { + Ok(v) => v, + Err(_) => { + return Response::json( + 422, + &json!({ "message": "Invalid request body: expected JSON" }), + ); + } + }; + let head = branch_of_head(payload["head"].as_str().unwrap_or_default()); + let base = payload["base"].as_str().unwrap_or("main").to_string(); + let title = payload["title"].as_str().unwrap_or_default().to_string(); + let body = payload["body"].as_str().unwrap_or_default().to_string(); + if head.is_empty() { + return Response::json(422, &json!({ "message": "head is required" })); + } + + let number = self.next_pull_number; + self.next_pull_number += 1; + let html_url = format!("{}/{owner}/{repo}/pull/{number}", self.public_base); + let pull = Pull { + number, + title, + head, + base, + body, + html_url, + }; + let response = Response::json(201, &pull_json(owner, repo, &pull)); + self.repo_entry(owner, repo).pulls.push(pull); + response + } + + fn repo_entry(&mut self, owner: &str, repo: &str) -> &mut RepoFixture { + self.repos + .entry((owner.to_string(), repo.to_string())) + .or_default() + } +} + +/// Reads the step at `*cursor`, advances the cursor (clamping to the last +/// element so the final step is sticky), and returns it — or `None` if the +/// script is empty. +fn step_at(script: &[T], cursor: &mut usize) -> Option { + if script.is_empty() { + return None; + } + let idx = (*cursor).min(script.len() - 1); + if *cursor < script.len() - 1 { + *cursor += 1; + } + Some(script[idx]) +} + +fn pull_json(owner: &str, repo: &str, p: &Pull) -> serde_json::Value { + json!({ + "number": p.number, + "title": p.title, + "body": p.body, + "state": "open", + "html_url": p.html_url, + "head": { "ref": p.head, "label": format!("{owner}:{}", p.head) }, + "base": { "ref": p.base }, + "base_repo": format!("{owner}/{repo}"), + }) +} + +/// GitHub's `head` filter/field is `owner:branch` for cross-repo and `branch` +/// for same-repo; the mock keys detection on the branch ref. +fn branch_of_head(head: &str) -> String { + head.rsplit(':').next().unwrap_or(head).to_string() +} + +fn parse_form(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() +} + +fn form_get(form: &[(String, String)], key: &str) -> Option { + form.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) +} + +fn oauth_error(status: u16, error: &str, description: &str) -> Response { + Response::json( + status, + &json!({ "error": error, "error_description": description }), + ) +} + +fn bad_credentials() -> Response { + Response::json( + 401, + &json!({ + "message": "Bad credentials", + "documentation_url": "https://docs.github.com/rest", + }), + ) +} + +fn not_found(message: &str) -> Response { + Response::json(404, &json!({ "message": message })) +} diff --git a/crates/github/src/testing/http.rs b/crates/github/src/testing/http.rs new file mode 100644 index 000000000..f04310405 --- /dev/null +++ b/crates/github/src/testing/http.rs @@ -0,0 +1,303 @@ +//! A tiny, hand-rolled HTTP/1.1 transport for the mock GitHub server. +//! +//! This is deliberately minimal and dependency-light: it parses one request per +//! connection and writes one response, always closing the connection +//! afterwards (`Connection: close`). That is enough for every client the mock +//! serves — a `reqwest` OAuth/REST caller and the `git` HTTP smart protocol — +//! because both tolerate connection-per-request, and it keeps the parser free of +//! keep-alive/pipelining edge cases (fail-simple, not fail-clever). +//! +//! The parser understands `Content-Length` and `Transfer-Encoding: chunked` +//! request bodies. Responses always carry a server-computed `Content-Length`, so +//! callers never set framing headers themselves. + +use std::io; +use std::sync::Arc; + +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +/// A parsed HTTP request. +pub struct Request { + /// The request method, verbatim (`GET`, `POST`, …). + pub method: String, + /// The request-target path, with any `?query` stripped off. + pub path: String, + /// The raw query string (without the leading `?`); empty when absent. + pub query: String, + /// Header `(name, value)` pairs in wire order; names are matched + /// case-insensitively via [`Request::header`]. + pub headers: Vec<(String, String)>, + /// The decoded request body (empty for bodyless requests). + pub body: Vec, +} + +impl Request { + /// The first header value matching `name` (case-insensitive), if any. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + /// The body decoded as UTF-8, lossily (for form/JSON payloads). + #[must_use] + pub fn body_string(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// The path split into non-empty segments, percent-encoding left intact. + #[must_use] + pub fn segments(&self) -> Vec<&str> { + self.path.split('/').filter(|s| !s.is_empty()).collect() + } +} + +/// An HTTP response to serialize back to the client. +pub struct Response { + /// The numeric status code. + pub status: u16, + /// Header `(name, value)` pairs; `Content-Length` and `Connection` are added + /// by [`Response::write_to`] and must not be set here. + pub headers: Vec<(String, String)>, + /// The response body bytes. + pub body: Vec, +} + +impl Response { + /// A response with the given status and no body or headers yet. + #[must_use] + pub fn new(status: u16) -> Self { + Self { + status, + headers: Vec::new(), + body: Vec::new(), + } + } + + /// Adds a header, returning `self` (builder style). + #[must_use] + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + /// Sets the `Content-Type` and body, returning `self`. + #[must_use] + pub fn with_body(mut self, content_type: &str, body: impl Into>) -> Self { + self.headers + .push(("Content-Type".to_string(), content_type.to_string())); + self.body = body.into(); + self + } + + /// A `text/plain` response. + #[must_use] + pub fn text(status: u16, body: impl Into) -> Self { + Response::new(status).with_body("text/plain; charset=utf-8", body.into().into_bytes()) + } + + /// An `application/json` response serialized from a [`serde_json::Value`]. + #[must_use] + pub fn json(status: u16, value: &serde_json::Value) -> Self { + let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()); + Response::new(status).with_body("application/json; charset=utf-8", body) + } + + /// Serializes the response to `w`, computing `Content-Length` and forcing + /// `Connection: close`. + async fn write_to(&self, w: &mut W) -> io::Result<()> { + let mut head = format!( + "HTTP/1.1 {} {}\r\n", + self.status, + reason_phrase(self.status) + ); + for (name, value) in &self.headers { + // Framing headers are owned by this function; ignore any a handler set. + if name.eq_ignore_ascii_case("content-length") + || name.eq_ignore_ascii_case("connection") + || name.eq_ignore_ascii_case("transfer-encoding") + { + continue; + } + head.push_str(name); + head.push_str(": "); + head.push_str(value); + head.push_str("\r\n"); + } + head.push_str(&format!("Content-Length: {}\r\n", self.body.len())); + head.push_str("Connection: close\r\n\r\n"); + w.write_all(head.as_bytes()).await?; + w.write_all(&self.body).await?; + w.flush().await?; + Ok(()) + } +} + +/// A request handler: given a parsed request, produce a response. +pub trait Handler: Send + Sync + 'static { + /// Handle one request. Implementations must not panic on client input. + fn handle(&self, req: Request) -> impl std::future::Future + Send; +} + +/// Runs the accept loop against `listener`, dispatching each connection to +/// `handler`. Returns only when the listener errors (e.g. it was closed). +pub async fn serve(listener: TcpListener, handler: Arc) { + // Loop ends when `accept` errors (e.g. the listener was closed on drop). + while let Ok((stream, _peer)) = listener.accept().await { + let handler = Arc::clone(&handler); + tokio::spawn(async move { + // Per-connection errors (client hangups, malformed input) are + // expected in a mock and are swallowed rather than logged. + let _ = handle_connection(stream, handler).await; + }); + } +} + +async fn handle_connection(stream: TcpStream, handler: Arc) -> io::Result<()> { + let (rd, mut wr) = stream.into_split(); + let mut reader = BufReader::new(rd); + if let Some(req) = read_request(&mut reader).await? { + let resp = handler.handle(req).await; + resp.write_to(&mut wr).await?; + } + let _ = wr.shutdown().await; + Ok(()) +} + +/// Reads and parses a single request, or `Ok(None)` if the peer closed before +/// sending anything. +async fn read_request( + reader: &mut BufReader, +) -> io::Result> { + let mut line = Vec::new(); + if reader.read_until(b'\n', &mut line).await? == 0 { + return Ok(None); + } + let request_line = String::from_utf8_lossy(trim_crlf(&line)).into_owned(); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let target = parts.next().unwrap_or_default().to_string(); + if method.is_empty() || target.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "malformed request line", + )); + } + + let mut headers = Vec::new(); + loop { + line.clear(); + if reader.read_until(b'\n', &mut line).await? == 0 { + break; + } + let trimmed = trim_crlf(&line); + if trimmed.is_empty() { + break; + } + let header = String::from_utf8_lossy(trimmed); + if let Some((name, value)) = header.split_once(':') { + headers.push((name.trim().to_string(), value.trim().to_string())); + } + } + + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (target, String::new()), + }; + + let body = read_body(reader, &headers).await?; + Ok(Some(Request { + method, + path, + query, + headers, + body, + })) +} + +async fn read_body( + reader: &mut BufReader, + headers: &[(String, String)], +) -> io::Result> { + if let Some(te) = header_value(headers, "transfer-encoding") + && te.eq_ignore_ascii_case("chunked") + { + return read_chunked(reader).await; + } + if let Some(cl) = header_value(headers, "content-length") { + let len: usize = cl.trim().parse().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid content-length header") + })?; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).await?; + return Ok(buf); + } + Ok(Vec::new()) +} + +async fn read_chunked(reader: &mut BufReader) -> io::Result> { + let mut body = Vec::new(); + loop { + let mut size_line = Vec::new(); + if reader.read_until(b'\n', &mut size_line).await? == 0 { + break; + } + let size_text = String::from_utf8_lossy(trim_crlf(&size_line)); + // A chunk size may carry `;ext` parameters after the hex length. + let hex = size_text.split(';').next().unwrap_or("").trim(); + let size = usize::from_str_radix(hex, 16) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid chunk size"))?; + if size == 0 { + // Consume any trailer headers up to the terminating blank line. + loop { + let mut trailer = Vec::new(); + if reader.read_until(b'\n', &mut trailer).await? == 0 { + break; + } + if trim_crlf(&trailer).is_empty() { + break; + } + } + break; + } + let mut chunk = vec![0u8; size]; + reader.read_exact(&mut chunk).await?; + body.extend_from_slice(&chunk); + // Each chunk is followed by a bare CRLF. + let mut crlf = [0u8; 2]; + reader.read_exact(&mut crlf).await?; + } + Ok(body) +} + +fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +fn trim_crlf(line: &[u8]) -> &[u8] { + let mut end = line.len(); + while end > 0 && (line[end - 1] == b'\n' || line[end - 1] == b'\r') { + end -= 1; + } + &line[..end] +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 201 => "Created", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 422 => "Unprocessable Entity", + 500 => "Internal Server Error", + _ => "OK", + } +} diff --git a/crates/github/src/testing/mod.rs b/crates/github/src/testing/mod.rs new file mode 100644 index 000000000..e605e8592 --- /dev/null +++ b/crates/github/src/testing/mod.rs @@ -0,0 +1,328 @@ +//! A programmable, in-process mock GitHub for tests and e2e (`test-support`). +//! +//! [`MockGithub`] binds a loopback TCP port and serves three surfaces off it, +//! all speaking real GitHub wire shapes so the production device-flow, REST, and +//! git clients can be pointed at it through the `MINIMALD_GITHUB_*_BASE_URL` +//! overrides: +//! +//! * the **OAuth device flow** (`/login/device/code`, `/login/oauth/access_token`) +//! with scriptable pending/slow-down/expired steps and refresh-token +//! **rotation** ([`fixtures`]); +//! * the **REST API** (`/user`, repo default branch, App-installation lookup, +//! pull-request list/create with existing-PR-by-head detection); +//! * an **auth-enforcing git smart-HTTP** endpoint backed by `git http-backend` +//! ([`smart_http`]) that rejects unauthenticated fetches — the surface that +//! later proves a credential helper actually fired. +//! +//! Every request is captured (see [`MockGithub::captured`]) so tests can assert +//! on what a client sent. The mock adds no new external runtime dependency: it +//! is hand-rolled on the workspace `tokio` TCP stack ([`http`]). + +pub mod fixtures; +pub mod http; +pub mod smart_http; + +pub use fixtures::{Fixtures, RefreshStep, TokenStep}; +pub use smart_http::GitAuth; + +use std::io; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use url::Url; + +use http::{Handler, Request, Response}; + +/// `127.0.0.1:0` — loopback with an OS-chosen port. +fn loopback_any() -> SocketAddr { + SocketAddr::from((Ipv4Addr::LOCALHOST, 0)) +} + +/// Shared state behind the accept loop: the programmable fixtures, the captured +/// request log, the git fixture root, and the git-auth policy. +struct MockState { + fixtures: Mutex, + captured: Mutex>, + git_root: PathBuf, + git_auth: Mutex, +} + +impl Handler for MockState { + async fn handle(&self, req: Request) -> Response { + self.record(&req); + if req.path.starts_with("/login/") { + // OAuth / device flow — synchronous, guard not held across an await. + let mut fx = self.fixtures.lock().expect("fixtures lock poisoned"); + fx.handle_oauth(&req) + } else if smart_http::is_git_path(&req.path) { + // Snapshot the auth policy, then release the lock before awaiting the + // git http-backend subprocess. + let auth = self + .git_auth + .lock() + .expect("git-auth lock poisoned") + .clone(); + smart_http::serve(&self.git_root, &auth, &req).await + } else { + let mut fx = self.fixtures.lock().expect("fixtures lock poisoned"); + fx.handle_rest(&req) + } + } +} + +impl MockState { + fn record(&self, req: &Request) { + self.captured + .lock() + .expect("captured lock poisoned") + .push(CapturedRequest::from_request(req)); + } +} + +/// A running mock GitHub server. Dropping it stops the accept loop and (when the +/// git root is a temp dir) cleans up the fixture repositories. +pub struct MockGithub { + addr: SocketAddr, + base_url: Url, + state: Arc, + accept: JoinHandle<()>, + /// Kept alive so the fixture-repo temp dir outlives the server; `None` when + /// the caller supplied its own git root. + _git_root_guard: Option, +} + +impl MockGithub { + /// Starts a mock on `127.0.0.1:0` with default fixtures and a fresh temp + /// directory for git fixtures. + pub async fn start() -> io::Result { + let dir = tempfile::tempdir()?; + let root = dir.path().to_path_buf(); + Self::bind(loopback_any(), root, Some(dir)).await + } + + /// Starts a mock whose git fixtures live under `git_root` (created if + /// missing), bound to `127.0.0.1:0`. Used by the `github-mock` binary so a + /// real `git` can be pointed at persistent fixture repos. + pub async fn start_with_git_root(git_root: impl Into) -> io::Result { + Self::start_with_git_root_at(git_root, loopback_any()).await + } + + /// Like [`MockGithub::start_with_git_root`] but bound to an explicit address + /// (pass a port of `0` to let the OS pick one). Used by the `github-mock` + /// binary so an e2e script can pin the listen address when it needs to. + pub async fn start_with_git_root_at( + git_root: impl Into, + addr: SocketAddr, + ) -> io::Result { + let root = git_root.into(); + std::fs::create_dir_all(&root)?; + Self::bind(addr, root, None).await + } + + async fn bind( + addr: SocketAddr, + git_root: PathBuf, + guard: Option, + ) -> io::Result { + let listener = TcpListener::bind(addr).await?; + let addr = listener.local_addr()?; + let base_url = Url::parse(&format!("http://{addr}/")).expect("loopback URL is valid"); + + let mut fixtures = Fixtures::default(); + // Trim the trailing slash so `html_url`s read like `…/owner/repo/pull/1`. + fixtures.set_public_base(base_url.as_str().trim_end_matches('/').to_string()); + + let state = Arc::new(MockState { + fixtures: Mutex::new(fixtures), + captured: Mutex::new(Vec::new()), + git_root, + git_auth: Mutex::new(GitAuth::default()), + }); + + let accept = tokio::spawn(http::serve(listener, Arc::clone(&state))); + Ok(Self { + addr, + base_url, + state, + accept, + _git_root_guard: guard, + }) + } + + /// The base URL to feed every `MINIMALD_GITHUB_*_BASE_URL` override; a single + /// origin serves OAuth, REST, and git. + #[must_use] + pub fn base_url(&self) -> &Url { + &self.base_url + } + + /// The bound loopback address. + #[must_use] + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// The directory holding the bare git fixture repositories. + #[must_use] + pub fn git_root(&self) -> &Path { + &self.state.git_root + } + + /// Mutates the programmable fixtures under the lock. + pub fn configure(&self, f: impl FnOnce(&mut Fixtures)) { + let mut fx = self.state.fixtures.lock().expect("fixtures lock poisoned"); + f(&mut fx); + } + + /// Sets the credential policy enforced by the git smart-HTTP endpoint. + pub fn set_git_auth(&self, auth: GitAuth) { + *self.state.git_auth.lock().expect("git-auth lock poisoned") = auth; + } + + /// Creates a bare git fixture repository served at + /// `{base_url}/{owner}/{repo}.git`, with one commit on `default_branch`, and + /// aligns the REST fixtures (default branch + App installed) to match. + pub fn create_bare_repo( + &self, + owner: &str, + repo: &str, + default_branch: &str, + ) -> io::Result { + let path = smart_http::create_bare_repo(&self.state.git_root, owner, repo, default_branch)?; + self.configure(|fx| { + fx.set_default_branch(owner, repo, default_branch); + fx.set_installed(owner, repo, true); + }); + Ok(path) + } + + /// A snapshot of every request the mock has served so far, in order. + #[must_use] + pub fn captured(&self) -> Vec { + self.state + .captured + .lock() + .expect("captured lock poisoned") + .clone() + } + + /// Clears the captured-request log. + pub fn clear_captured(&self) { + self.state + .captured + .lock() + .expect("captured lock poisoned") + .clear(); + } +} + +impl Drop for MockGithub { + fn drop(&mut self) { + // Stop accepting so the port frees and the temp git root can be removed. + self.accept.abort(); + } +} + +/// A recorded request, for test assertions on what a client sent. +/// +/// The `Authorization` header is captured but kept out of the public header list +/// and redacted in `Debug`, so a token presented to the mock cannot leak through +/// a debug print of the capture log. Read it deliberately via +/// [`CapturedRequest::bearer_token`] / [`CapturedRequest::basic_auth`]. +#[derive(Clone)] +pub struct CapturedRequest { + /// Request method (`GET`, `POST`, …). + pub method: String, + /// Request path (query stripped). + pub path: String, + /// Raw query string (without `?`). + pub query: String, + /// Request body bytes. + pub body: Vec, + headers: Vec<(String, String)>, + authorization: Option, +} + +impl CapturedRequest { + fn from_request(req: &Request) -> Self { + let mut authorization = None; + let mut headers = Vec::new(); + for (name, value) in &req.headers { + if name.eq_ignore_ascii_case("authorization") { + authorization = Some(value.clone()); + } else { + headers.push((name.clone(), value.clone())); + } + } + Self { + method: req.method.clone(), + path: req.path.clone(), + query: req.query.clone(), + body: req.body.clone(), + headers, + authorization, + } + } + + /// The first non-`Authorization` header matching `name` (case-insensitive). + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + /// The body decoded as UTF-8, lossily. + #[must_use] + pub fn body_string(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// Whether an `Authorization` header was present. + #[must_use] + pub fn had_authorization(&self) -> bool { + self.authorization.is_some() + } + + /// The bearer token from an `Authorization: Bearer …`/`token …` header, if + /// present. Deliberately explicit so read sites are greppable. + #[must_use] + pub fn bearer_token(&self) -> Option<&str> { + let value = self.authorization.as_deref()?; + value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .or_else(|| value.strip_prefix("token ")) + .map(str::trim) + } + + /// The `(username, password)` from an `Authorization: Basic …` header, if + /// present. + #[must_use] + pub fn basic_auth(&self) -> Option<(String, String)> { + smart_http::decode_basic_for_test(self.authorization.as_deref()?) + } +} + +impl std::fmt::Debug for CapturedRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CapturedRequest") + .field("method", &self.method) + .field("path", &self.path) + .field("query", &self.query) + .field("headers", &self.headers) + .field( + "authorization", + &self.authorization.as_ref().map(|_| "[REDACTED]"), + ) + .field("body_len", &self.body.len()) + .finish() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/github/src/testing/smart_http.rs b/crates/github/src/testing/smart_http.rs new file mode 100644 index 000000000..21ca757d2 --- /dev/null +++ b/crates/github/src/testing/smart_http.rs @@ -0,0 +1,267 @@ +//! An auth-enforcing git smart-HTTP endpoint backed by `git http-backend`. +//! +//! This is the part of the mock that later *proves the credential helper +//! actually fires*: it serves local **bare** fixture repositories over the git +//! smart-HTTP protocol, but only after the request presents HTTP Basic +//! credentials. An unauthenticated fetch is answered `401` with a +//! `WWW-Authenticate` challenge (exactly as github.com does), so any code path +//! that reaches these repos without injecting credentials fails loudly. +//! +//! Requests that clear the auth gate are handed to `git http-backend` run as a +//! CGI program: the request path/method/query become CGI environment variables, +//! the request body is piped to the child's stdin, and the child's CGI response +//! (status + headers + body) is translated back into an HTTP [`Response`]. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use base64::Engine as _; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use super::http::{Request, Response}; + +/// The credential policy the smart-HTTP endpoint enforces. +#[derive(Debug, Clone, Default)] +pub enum GitAuth { + /// Require *some* non-empty Basic credentials, accepting any username and + /// password. This still proves a credential helper fired. + #[default] + AnyBasic, + /// Require these exact Basic credentials; anything else is rejected `401`. + Exact { + /// Expected Basic-auth username. + username: String, + /// Expected Basic-auth password (the injected token, in practice). + password: String, + }, +} + +/// Serves one git smart-HTTP request out of the bare repositories under +/// `project_root`, enforcing `auth`. +pub(super) async fn serve(project_root: &Path, auth: &GitAuth, req: &Request) -> Response { + match check_auth(auth, req) { + AuthOutcome::Ok(remote_user) => run_http_backend(project_root, req, &remote_user).await, + AuthOutcome::Unauthorized => unauthorized(), + } +} + +enum AuthOutcome { + Ok(String), + Unauthorized, +} + +fn check_auth(auth: &GitAuth, req: &Request) -> AuthOutcome { + let Some((user, pass)) = req.header("authorization").and_then(parse_basic) else { + return AuthOutcome::Unauthorized; + }; + match auth { + GitAuth::AnyBasic => { + if user.is_empty() && pass.is_empty() { + AuthOutcome::Unauthorized + } else { + AuthOutcome::Ok(user) + } + } + GitAuth::Exact { username, password } => { + // Constant-work-ish comparison is not required for a test mock, but + // fail-closed on any mismatch. + if &user == username && &pass == password { + AuthOutcome::Ok(user) + } else { + AuthOutcome::Unauthorized + } + } + } +} + +/// Decodes a captured `Authorization: Basic …` header value into its +/// `(username, password)` parts, for test-side inspection of what a client sent +/// (see [`super::CapturedRequest::basic_auth`]). +pub(super) fn decode_basic_for_test(header: &str) -> Option<(String, String)> { + parse_basic(header) +} + +/// Decodes a `Basic base64(user:pass)` header value into its parts. +fn parse_basic(header: &str) -> Option<(String, String)> { + let b64 = header + .strip_prefix("Basic ") + .or_else(|| header.strip_prefix("basic "))?; + let raw = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .ok()?; + let decoded = String::from_utf8(raw).ok()?; + let (user, pass) = decoded.split_once(':')?; + Some((user.to_string(), pass.to_string())) +} + +async fn run_http_backend(project_root: &Path, req: &Request, remote_user: &str) -> Response { + let mut cmd = Command::new("git"); + cmd.arg("http-backend") + .env_clear() + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("GIT_PROJECT_ROOT", project_root) + .env("PATH_INFO", &req.path) + .env("REQUEST_METHOD", &req.method) + .env("QUERY_STRING", &req.query) + .env("REMOTE_USER", remote_user) + .env("REMOTE_ADDR", "127.0.0.1") + // Keep a PATH so `git` can locate its subprograms after `env_clear`. + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + if let Some(ct) = req.header("content-type") { + cmd.env("CONTENT_TYPE", ct); + } + if !req.body.is_empty() { + cmd.env("CONTENT_LENGTH", req.body.len().to_string()); + } + // Forward the protocol-version negotiation so smart-HTTP v2 works. + if let Some(proto) = req.header("git-protocol") { + cmd.env("GIT_PROTOCOL", proto); + } + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => return Response::text(500, format!("failed to spawn git http-backend: {e}")), + }; + + if let Some(mut stdin) = child.stdin.take() { + // Errors here (child exited early) surface via the output below. + let _ = stdin.write_all(&req.body).await; + let _ = stdin.shutdown().await; + } + + match child.wait_with_output().await { + Ok(output) => cgi_to_response(&output.stdout), + Err(e) => Response::text(500, format!("git http-backend failed: {e}")), + } +} + +/// Translates a CGI response (headers, blank line, body) into an HTTP response. +/// A `Status:` header sets the code; otherwise CGI defaults to `200`. +fn cgi_to_response(raw: &[u8]) -> Response { + let Some(split) = find_header_end(raw) else { + // No header/body separator: treat the whole thing as a 500 body. + return Response::text(500, "malformed CGI response from git http-backend"); + }; + let (head, body) = raw.split_at(split.0); + let body = &body[split.1..]; + + let mut status = 200u16; + let mut headers = Vec::new(); + for line in String::from_utf8_lossy(head).lines() { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim(); + let value = value.trim(); + if name.eq_ignore_ascii_case("status") { + status = value + .split_whitespace() + .next() + .and_then(|c| c.parse().ok()) + .unwrap_or(200); + } else { + headers.push((name.to_string(), value.to_string())); + } + } + + Response { + status, + headers, + body: body.to_vec(), + } +} + +/// Finds the end of the CGI header block, returning `(offset_of_blank_line, +/// separator_len)` for both `\r\n\r\n` and `\n\n` framings. +fn find_header_end(raw: &[u8]) -> Option<(usize, usize)> { + if let Some(pos) = find_subslice(raw, b"\r\n\r\n") { + return Some((pos, 4)); + } + find_subslice(raw, b"\n\n").map(|pos| (pos, 2)) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn unauthorized() -> Response { + Response::new(401) + .with_header("WWW-Authenticate", "Basic realm=\"GitHub\"") + .with_body( + "text/plain; charset=utf-8", + b"authentication required".to_vec(), + ) +} + +/// Whether `path` is a git smart-HTTP request the endpoint should handle. +pub(super) fn is_git_path(path: &str) -> bool { + path.contains("/info/refs") + || path.ends_with("/git-upload-pack") + || path.ends_with("/git-receive-pack") + || path.contains("/objects/") + || path.ends_with("/HEAD") +} + +/// Creates a bare fixture repository at `//.git` with +/// a single initial commit on `default_branch`, and enables push +/// (`http.receivepack`). Returns the path to the bare repo. +/// +/// Runs `git` synchronously; intended for test/e2e setup, not the hot path. +pub(super) fn create_bare_repo( + project_root: &Path, + owner: &str, + repo: &str, + default_branch: &str, +) -> std::io::Result { + use std::process::Command as SyncCommand; + + let bare = project_root.join(owner).join(format!("{repo}.git")); + std::fs::create_dir_all(bare.parent().expect("bare path has a parent"))?; + + // Build the history in a throwaway working tree, then clone it bare. This is + // the simplest way to get a bare repo that already advertises a branch. + let seed = tempfile::tempdir()?; + let work = seed.path(); + let git = |args: &[&str], cwd: &Path| -> std::io::Result<()> { + let status = SyncCommand::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "mock") + .env("GIT_AUTHOR_EMAIL", "mock@example.com") + .env("GIT_COMMITTER_NAME", "mock") + .env("GIT_COMMITTER_EMAIL", "mock@example.com") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("git {args:?} failed"))) + } + }; + + git(&["init", "-q", "-b", default_branch], work)?; + std::fs::write(work.join("README.md"), b"mock fixture repo\n")?; + git(&["add", "README.md"], work)?; + git(&["commit", "-q", "-m", "initial commit"], work)?; + git( + &[ + "clone", + "-q", + "--bare", + work.to_str().expect("temp path is utf-8"), + bare.to_str().expect("bare path is utf-8"), + ], + work, + )?; + // Allow authenticated pushes to the bare repo over http. + git(&["config", "http.receivepack", "true"], &bare)?; + + Ok(bare) +} diff --git a/crates/github/src/testing/tests.rs b/crates/github/src/testing/tests.rs new file mode 100644 index 000000000..7aae70c28 --- /dev/null +++ b/crates/github/src/testing/tests.rs @@ -0,0 +1,531 @@ +//! Self-tests for the mock GitHub server. +//! +//! These drive the mock the way the production clients will: OAuth/REST over a +//! tiny raw HTTP/1.1 client (so the tests carry no client-crate dependency), and +//! the git smart-HTTP surface with a real `git` process — which is the only +//! honest way to prove the auth gate actually blocks an unauthenticated fetch. + +use std::net::SocketAddr; +use std::process::Stdio; + +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::process::Command; + +use super::{Fixtures, GitAuth, MockGithub, RefreshStep, TokenStep}; + +// --- a minimal raw HTTP/1.1 client ----------------------------------------- + +/// A parsed HTTP response from the mock. +struct HttpResponse { + status: u16, + headers: Vec<(String, String)>, + body: Vec, +} + +impl HttpResponse { + fn json(&self) -> Value { + serde_json::from_slice(&self.body).expect("response body is valid JSON") + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } +} + +/// Sends one request and reads the full response (the mock always closes the +/// connection, so read-to-EOF yields the whole message). +async fn request( + addr: SocketAddr, + method: &str, + path: &str, + headers: &[(&str, &str)], + content_type: Option<&str>, + body: &[u8], +) -> HttpResponse { + let mut stream = TcpStream::connect(addr).await.expect("connect to mock"); + let mut head = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n"); + for (name, value) in headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + if let Some(ct) = content_type { + head.push_str(&format!("Content-Type: {ct}\r\n")); + } + head.push_str(&format!("Content-Length: {}\r\n", body.len())); + head.push_str("Connection: close\r\n\r\n"); + stream + .write_all(head.as_bytes()) + .await + .expect("write request head"); + stream.write_all(body).await.expect("write request body"); + stream.flush().await.expect("flush request"); + + let mut raw = Vec::new(); + stream + .read_to_end(&mut raw) + .await + .expect("read response to EOF"); + parse_response(&raw) +} + +fn parse_response(raw: &[u8]) -> HttpResponse { + let split = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .expect("response has a header/body separator"); + let head = std::str::from_utf8(&raw[..split]).expect("response head is UTF-8"); + let body = raw[split + 4..].to_vec(); + + let mut lines = head.lines(); + let status_line = lines.next().expect("status line present"); + let status = status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .expect("numeric status code"); + let headers = lines + .filter_map(|line| line.split_once(':')) + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + .collect(); + + HttpResponse { + status, + headers, + body, + } +} + +async fn get(addr: SocketAddr, path: &str, headers: &[(&str, &str)]) -> HttpResponse { + request(addr, "GET", path, headers, None, &[]).await +} + +async fn post_form(addr: SocketAddr, path: &str, form: &str) -> HttpResponse { + request( + addr, + "POST", + path, + &[], + Some("application/x-www-form-urlencoded"), + form.as_bytes(), + ) + .await +} + +async fn post_json(addr: SocketAddr, path: &str, body: &Value) -> HttpResponse { + request( + addr, + "POST", + path, + &[], + Some("application/json"), + serde_json::to_vec(body) + .expect("serialize JSON body") + .as_slice(), + ) + .await +} + +/// Polls the device-code token endpoint once. +async fn poll_device_token(addr: SocketAddr, device_code: &str) -> HttpResponse { + let form = format!( + "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code={device_code}", + ); + post_form(addr, "/login/oauth/access_token", &form).await +} + +/// Exchanges a refresh token once. +async fn refresh(addr: SocketAddr, refresh_token: &str) -> HttpResponse { + let form = format!("grant_type=refresh_token&refresh_token={refresh_token}"); + post_form(addr, "/login/oauth/access_token", &form).await +} + +// --- device flow ------------------------------------------------------------ + +#[tokio::test] +async fn scripted_device_flow_pending_then_slowdown_then_approve() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx: &mut Fixtures| { + fx.set_device_codes("dev-abc", "WXYZ-1234", "https://github.test/login/device"); + fx.set_device_interval(5); + fx.script_device_exchange([TokenStep::Pending, TokenStep::SlowDown, TokenStep::Approve]); + }); + let addr = mock.addr(); + + // Step 1: request a device code. + let start = post_form(addr, "/login/device/code", "client_id=Iv1.mock").await; + assert_eq!(start.status, 200); + let start = start.json(); + assert_eq!(start["device_code"], "dev-abc"); + assert_eq!(start["user_code"], "WXYZ-1234"); + assert_eq!( + start["verification_uri"], + "https://github.test/login/device" + ); + assert_eq!(start["interval"], 5); + + // Step 2: first poll is pending. + let pending = poll_device_token(addr, "dev-abc").await; + assert_eq!(pending.status, 200); + assert_eq!(pending.json()["error"], "authorization_pending"); + + // Step 3: second poll asks us to slow down (bumped interval). + let slow = poll_device_token(addr, "dev-abc").await.json(); + assert_eq!(slow["error"], "slow_down"); + assert_eq!(slow["interval"], 10); + + // Step 4: third poll is approved with an access + refresh token pair. + let ok = poll_device_token(addr, "dev-abc").await.json(); + assert!(ok["access_token"].as_str().unwrap().starts_with("ghu_")); + assert!(ok["refresh_token"].as_str().unwrap().starts_with("ghr_")); + assert_eq!(ok["token_type"], "bearer"); + + // Sticky-on-last: a further poll stays approved. + let again = poll_device_token(addr, "dev-abc").await.json(); + assert!(again["access_token"].as_str().unwrap().starts_with("ghu_")); +} + +#[tokio::test] +async fn scripted_device_flow_expired_and_denied() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.script_device_exchange([TokenStep::Expired])); + let expired = poll_device_token(mock.addr(), "mock_device_code") + .await + .json(); + assert_eq!(expired["error"], "expired_token"); + + mock.configure(|fx| fx.script_device_exchange([TokenStep::AccessDenied])); + let denied = poll_device_token(mock.addr(), "mock_device_code") + .await + .json(); + assert_eq!(denied["error"], "access_denied"); +} + +// --- refresh with rotation -------------------------------------------------- + +#[tokio::test] +async fn refresh_rotates_the_refresh_token_every_call() { + let mock = MockGithub::start().await.expect("start mock"); + let addr = mock.addr(); + + let first = refresh(addr, "ghr_seed").await.json(); + let second = refresh( + addr, + first["refresh_token"].as_str().expect("refresh token"), + ) + .await + .json(); + + // Rotation: both the access token and the refresh token change each call. + assert_ne!(first["access_token"], second["access_token"]); + assert_ne!(first["refresh_token"], second["refresh_token"]); + assert!(first["refresh_token_expires_in"].as_u64().unwrap() > 0); +} + +#[tokio::test] +async fn scripted_refresh_invalid_grant_triggers_reauth() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.script_refresh([RefreshStep::InvalidGrant])); + let resp = refresh(mock.addr(), "ghr_anything").await.json(); + assert_eq!(resp["error"], "invalid_grant"); +} + +#[tokio::test] +async fn strict_refresh_rejects_a_stale_rotated_token() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.set_strict_refresh(true)); + let addr = mock.addr(); + + // Approve via the device flow to seed the first (tracked) refresh token. + let granted = poll_device_token(addr, "mock_device_code").await.json(); + let r1 = granted["refresh_token"].as_str().expect("r1").to_string(); + + // Rotating with r1 succeeds and yields r2; r1 is now stale. + let rotated = refresh(addr, &r1).await.json(); + let r2 = rotated["refresh_token"].as_str().expect("r2").to_string(); + assert_ne!(r1, r2); + + // Presenting the stale r1 again is rejected — the grant is bricked. + let stale = refresh(addr, &r1).await.json(); + assert_eq!(stale["error"], "invalid_grant"); + + // The freshest token still works. + let ok = refresh(addr, &r2).await.json(); + assert!(ok["access_token"].as_str().unwrap().starts_with("ghu_")); +} + +// --- REST ------------------------------------------------------------------- + +#[tokio::test] +async fn get_user_reports_the_configured_login() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.set_user("norrie", 4242)); + let resp = get(mock.addr(), "/user", &[]).await; + assert_eq!(resp.status, 200); + assert_eq!( + resp.header("Content-Type"), + Some("application/json; charset=utf-8") + ); + let user = resp.json(); + assert_eq!(user["login"], "norrie"); + assert_eq!(user["id"], 4242); +} + +#[tokio::test] +async fn forced_unauthorized_answers_401_bad_credentials() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.set_force_unauthorized(true)); + let resp = get(mock.addr(), "/user", &[]).await; + assert_eq!(resp.status, 401); + assert_eq!(resp.json()["message"], "Bad credentials"); +} + +#[tokio::test] +async fn installation_lookup_reports_installed_and_not_installed() { + let mock = MockGithub::start().await.expect("start mock"); + let addr = mock.addr(); + + // Default is installed (200). + let installed = get(addr, "/repos/octocat/hello/installation", &[]).await; + assert_eq!(installed.status, 200); + assert_eq!(installed.json()["app_slug"], "minimal"); + + // Flip to not-installed → 404 (spec R1.5), so the client can guide to install. + mock.configure(|fx| fx.set_installed("octocat", "hello", false)); + let missing = get(addr, "/repos/octocat/hello/installation", &[]).await; + assert_eq!(missing.status, 404); + assert_eq!(missing.json()["message"], "Not Found"); +} + +#[tokio::test] +async fn repo_reports_its_default_branch() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| fx.set_default_branch("octocat", "hello", "trunk")); + let resp = get(mock.addr(), "/repos/octocat/hello", &[]).await; + assert_eq!(resp.status, 200); + let repo = resp.json(); + assert_eq!(repo["default_branch"], "trunk"); + assert_eq!(repo["full_name"], "octocat/hello"); +} + +#[tokio::test] +async fn pull_create_then_list_by_head_detects_the_existing_pr() { + let mock = MockGithub::start().await.expect("start mock"); + let addr = mock.addr(); + + // No PRs yet for this head. + let empty = get(addr, "/repos/octocat/hello/pulls?head=octocat:feat/x", &[]).await; + assert_eq!(empty.status, 200); + assert_eq!(empty.json().as_array().unwrap().len(), 0); + + // Create a PR from feat/x → main. + let created = post_json( + addr, + "/repos/octocat/hello/pulls", + &serde_json::json!({ + "title": "Add x", + "head": "feat/x", + "base": "main", + "body": "the body", + }), + ) + .await; + assert_eq!(created.status, 201); + let created = created.json(); + let number = created["number"].as_u64().expect("pr number"); + assert!(created["html_url"].as_str().unwrap().contains("/pull/")); + + // List-by-head now finds exactly that PR (existing-PR detection, R4.5). + let found = get(addr, "/repos/octocat/hello/pulls?head=octocat:feat/x", &[]).await; + let arr = found.json(); + let arr = arr.as_array().expect("array of pulls"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["number"], number); + assert_eq!(arr[0]["head"]["ref"], "feat/x"); + + // A different head sees none. + let other = get( + addr, + "/repos/octocat/hello/pulls?head=octocat:feat/other", + &[], + ) + .await; + assert_eq!(other.json().as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn seeded_pull_is_detectable_by_head() { + let mock = MockGithub::start().await.expect("start mock"); + mock.configure(|fx| { + fx.add_pull("octocat", "hello", "feat/seed", "main"); + }); + let found = get( + mock.addr(), + "/repos/octocat/hello/pulls?head=octocat:feat/seed", + &[], + ) + .await; + assert_eq!(found.json().as_array().unwrap().len(), 1); +} + +// --- request capture -------------------------------------------------------- + +#[tokio::test] +async fn requests_are_captured_and_the_token_is_redacted() { + let mock = MockGithub::start().await.expect("start mock"); + let addr = mock.addr(); + + get( + addr, + "/user", + &[ + ("Authorization", "Bearer ghu_supersecret"), + ("Accept", "application/vnd.github+json"), + ], + ) + .await; + + let captured = mock.captured(); + assert_eq!(captured.len(), 1); + let req = &captured[0]; + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/user"); + assert!(req.had_authorization()); + assert_eq!(req.bearer_token(), Some("ghu_supersecret")); + // The non-secret header is retrievable... + assert_eq!(req.header("Accept"), Some("application/vnd.github+json")); + // ...but Authorization is not exposed through the header list... + assert_eq!(req.header("Authorization"), None); + // ...and never appears in Debug output. + let debug = format!("{req:?}"); + assert!( + !debug.contains("ghu_supersecret"), + "token leaked in Debug: {debug}" + ); + assert!(debug.contains("[REDACTED]")); + + // The capture log clears on demand. + mock.clear_captured(); + assert!(mock.captured().is_empty()); +} + +#[tokio::test] +async fn captured_body_and_query_are_recorded() { + let mock = MockGithub::start().await.expect("start mock"); + post_form( + mock.addr(), + "/login/oauth/access_token", + "grant_type=refresh_token&refresh_token=r", + ) + .await; + let captured = mock.captured(); + let last = captured.last().expect("a captured request"); + assert_eq!(last.method, "POST"); + assert!(last.body_string().contains("grant_type=refresh_token")); +} + +// --- git smart-HTTP (the credential-helper proof) --------------------------- + +/// Runs `git` with terminal prompts disabled (so a missing credential fails +/// instead of hanging) and returns `(success, combined_output)`. +async fn run_git(args: &[&str]) -> (bool, String) { + let output = Command::new("git") + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", "/nonexistent-mock-home") + .stdin(Stdio::null()) + .output() + .await + .expect("spawn git"); + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + (output.status.success(), combined) +} + +fn repo_url(mock: &MockGithub, userinfo: Option<&str>, owner: &str, repo: &str) -> String { + let host = mock.addr(); + match userinfo { + Some(creds) => format!("http://{creds}@{host}/{owner}/{repo}.git"), + None => format!("http://{host}/{owner}/{repo}.git"), + } +} + +#[tokio::test] +async fn smart_http_rejects_unauthenticated_and_accepts_basic_auth() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octocat", "hello", "main") + .expect("create bare fixture repo"); + + // Unauthenticated fetch is rejected at the gate. + let (ok, out) = run_git(&["ls-remote", &repo_url(&mock, None, "octocat", "hello")]).await; + assert!(!ok, "unauthenticated ls-remote should fail; got:\n{out}"); + + // With Basic credentials (any username/password under the default policy) the + // ref advertisement succeeds and the seeded branch is visible. + let (ok, out) = run_git(&[ + "ls-remote", + &repo_url(&mock, Some("x-access-token:ghu_token"), "octocat", "hello"), + ]) + .await; + assert!(ok, "authenticated ls-remote should succeed; got:\n{out}"); + assert!( + out.contains("refs/heads/main"), + "missing default branch ref:\n{out}" + ); +} + +#[tokio::test] +async fn smart_http_exact_auth_policy_checks_credentials() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octocat", "hello", "main") + .expect("create bare fixture repo"); + mock.set_git_auth(GitAuth::Exact { + username: "x-access-token".to_string(), + password: "correct-horse".to_string(), + }); + + let (bad, _) = run_git(&[ + "ls-remote", + &repo_url(&mock, Some("x-access-token:wrong"), "octocat", "hello"), + ]) + .await; + assert!(!bad, "wrong password must be rejected"); + + let (good, out) = run_git(&[ + "ls-remote", + &repo_url( + &mock, + Some("x-access-token:correct-horse"), + "octocat", + "hello", + ), + ]) + .await; + assert!(good, "correct credentials must be accepted; got:\n{out}"); +} + +#[tokio::test] +async fn smart_http_clone_transfers_the_repository() { + let mock = MockGithub::start().await.expect("start mock"); + mock.create_bare_repo("octocat", "hello", "main") + .expect("create bare fixture repo"); + let dest = tempfile::tempdir().expect("clone dest"); + let dest_path = dest.path().join("hello"); + + let (ok, out) = run_git(&[ + "clone", + "-q", + &repo_url(&mock, Some("x-access-token:ghu_token"), "octocat", "hello"), + dest_path.to_str().expect("utf-8 dest"), + ]) + .await; + assert!(ok, "authenticated clone should succeed; got:\n{out}"); + assert!( + dest_path.join("README.md").is_file(), + "clone did not fetch the tree" + ); +} diff --git a/crates/github/src/types.rs b/crates/github/src/types.rs new file mode 100644 index 000000000..b6faf5488 --- /dev/null +++ b/crates/github/src/types.rs @@ -0,0 +1,404 @@ +//! Core domain value types: [`RepoSpec`], [`BranchSpec`], [`GrantId`], and +//! [`AuthChoice`]. +//! +//! Parsing is strict and fail-closed: malformed input is rejected at the +//! boundary so the rest of the system only ever handles well-formed values. + +use std::fmt; +use std::str::FromStr; + +use crate::error::Error; + +/// A repository to pre-prime in a session, parsed from `owner/repo[@branch[:base]]` +/// (spec R2.1–R2.2). +/// +/// The optional branch — and the base branch it may be created from — are +/// modelled together in [`BranchSpec`] so that a base branch without a working +/// branch is unrepresentable. +/// +/// ``` +/// use github::RepoSpec; +/// let spec: RepoSpec = "octocat/hello@feat/x:main".parse().unwrap(); +/// assert_eq!(spec.owner(), "octocat"); +/// assert_eq!(spec.repo(), "hello"); +/// assert_eq!(spec.branch(), Some("feat/x")); +/// assert_eq!(spec.base(), Some("main")); +/// assert_eq!(spec.to_string(), "octocat/hello@feat/x:main"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RepoSpec { + owner: String, + repo: String, + branch: Option, +} + +/// A working branch and, optionally, the base branch it is created from when it +/// does not yet exist on the remote (checkout-or-create, spec R2.2). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BranchSpec { + name: String, + base: Option, +} + +impl RepoSpec { + /// Builds a [`RepoSpec`] from already-validated parts, validating each + /// component. Prefer parsing from a string via [`str::parse`]. + pub fn new( + owner: impl Into, + repo: impl Into, + branch: Option, + ) -> Result { + let owner = owner.into(); + let repo = repo.into(); + validate_owner(&owner)?; + validate_repo(&repo)?; + Ok(Self { + owner, + repo, + branch, + }) + } + + /// The repository owner (user or org). + #[must_use] + pub fn owner(&self) -> &str { + &self.owner + } + + /// The repository name. + #[must_use] + pub fn repo(&self) -> &str { + &self.repo + } + + /// The working branch, if one was requested. + #[must_use] + pub fn branch(&self) -> Option<&str> { + self.branch.as_ref().map(|b| b.name()) + } + + /// The base branch the working branch is created from, if specified. + #[must_use] + pub fn base(&self) -> Option<&str> { + self.branch.as_ref().and_then(BranchSpec::base) + } + + /// The full `BranchSpec`, if a branch was requested. + #[must_use] + pub fn branch_spec(&self) -> Option<&BranchSpec> { + self.branch.as_ref() + } +} + +impl BranchSpec { + /// Builds a [`BranchSpec`], validating the branch and (optional) base as git + /// ref names. + pub fn new(name: impl Into, base: Option) -> Result { + let name = name.into(); + validate_ref(&name)?; + if let Some(base) = &base { + validate_ref(base)?; + } + Ok(Self { name, base }) + } + + /// The working branch name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// The base branch name, if specified. + #[must_use] + pub fn base(&self) -> Option<&str> { + self.base.as_deref() + } +} + +impl fmt::Display for RepoSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.owner, self.repo)?; + if let Some(branch) = &self.branch { + write!(f, "@{branch}")?; + } + Ok(()) + } +} + +impl fmt::Display for BranchSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name)?; + if let Some(base) = &self.base { + write!(f, ":{base}")?; + } + Ok(()) + } +} + +impl FromStr for RepoSpec { + type Err = Error; + + fn from_str(s: &str) -> Result { + let reject = |reason: &str| Error::InvalidRepoSpec { + input: s.to_string(), + reason: reason.to_string(), + }; + + // Split owner/repo from the optional `@branch[:base]` suffix on the + // first `@`; `@` is not permitted inside a ref name (see validate_ref), + // so this split is unambiguous. + let (repo_part, branch_part) = match s.split_once('@') { + Some((repo_part, branch_part)) => (repo_part, Some(branch_part)), + None => (s, None), + }; + + let (owner, repo) = repo_part + .split_once('/') + .ok_or_else(|| reject("expected `owner/repo`"))?; + if repo.contains('/') { + return Err(reject("owner/repo must contain exactly one `/`")); + } + validate_owner(owner).map_err(|_| reject("owner has invalid characters"))?; + validate_repo(repo).map_err(|_| reject("repo has invalid characters"))?; + + let branch = match branch_part { + None => None, + Some(bp) => { + // `:` separates branch from base; a ref name never contains `:`, + // so the first `:` is the separator. + let (name, base) = match bp.split_once(':') { + Some((name, base)) => (name, Some(base)), + None => (bp, None), + }; + if name.is_empty() { + return Err(reject("branch is empty after `@`")); + } + validate_ref(name).map_err(|_| reject("branch is not a valid ref name"))?; + let base = match base { + None => None, + Some(base) => { + if base.is_empty() { + return Err(reject("base is empty after `:`")); + } + validate_ref(base).map_err(|_| reject("base is not a valid ref name"))?; + Some(base.to_string()) + } + }; + Some(BranchSpec { + name: name.to_string(), + base, + }) + } + }; + + Ok(Self { + owner: owner.to_string(), + repo: repo.to_string(), + branch, + }) + } +} + +/// Opaque identifier of a stored authentication grant (spec R6.4 reuse-or-mint). +/// +/// A grant id is not itself secret — it names a grant, it is not the token — so +/// it is safe in logs and `attrs`. Non-empty by construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GrantId(String); + +impl GrantId { + /// Wraps a non-empty grant id. + pub fn new(id: impl Into) -> Result { + let id = id.into(); + if id.trim().is_empty() { + return Err(Error::InvalidGrantId { + reason: "grant id must not be empty".to_string(), + }); + } + Ok(Self(id)) + } + + /// The grant id as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for GrantId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for GrantId { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +/// The user's reuse-or-mint decision when a subsequent sandbox is created (spec +/// R6.4): reuse an existing authentication grant, or mint a fresh, +/// separately-scoped one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthChoice { + /// Reuse the existing stored grant. + Reuse, + /// Mint a fresh grant with its own scoping. + Mint, +} + +/// Validates a GitHub owner (user or org) login: 1–39 chars of ASCII +/// alphanumerics and hyphens, no leading/trailing hyphen, no `--` run. +fn validate_owner(owner: &str) -> Result<(), Error> { + let reject = || Error::InvalidRepoSpec { + input: owner.to_string(), + reason: "invalid owner".to_string(), + }; + if owner.is_empty() || owner.len() > 39 { + return Err(reject()); + } + if owner.starts_with('-') || owner.ends_with('-') || owner.contains("--") { + return Err(reject()); + } + if !owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + return Err(reject()); + } + Ok(()) +} + +/// Validates a GitHub repository name: 1–100 chars of ASCII alphanumerics, `-`, +/// `_`, `.`; not `.` or `..`. +fn validate_repo(repo: &str) -> Result<(), Error> { + let reject = || Error::InvalidRepoSpec { + input: repo.to_string(), + reason: "invalid repo".to_string(), + }; + if repo.is_empty() || repo.len() > 100 || repo == "." || repo == ".." { + return Err(reject()); + } + if !repo + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + return Err(reject()); + } + Ok(()) +} + +/// Validates a git ref name (branch or base) strictly enough that it is safe to +/// use unquoted as a git argument and as a `,`-separated `attrs` element. +/// +/// Rejects: empty; any whitespace or ASCII control; any of `~^:?*[\@,` and space; +/// a `..` run; leading/trailing `/` or `.`; a trailing `.lock`. This is stricter +/// than git itself (which permits `@` and `,`), which is intentional: fail-closed +/// keeps the value shell-safe and unambiguous in the codecs that use `@`, `:`, +/// and `,` as delimiters. +fn validate_ref(name: &str) -> Result<(), Error> { + let reject = || Error::InvalidRepoSpec { + input: name.to_string(), + reason: "invalid ref name".to_string(), + }; + if name.is_empty() + || name.contains("..") + || name.starts_with('/') + || name.ends_with('/') + || name.starts_with('.') + || name.ends_with('.') + || name.ends_with(".lock") + { + return Err(reject()); + } + let forbidden = |c: char| { + c.is_whitespace() + || c.is_control() + || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\' | '@' | ',') + }; + if name.chars().any(forbidden) { + return Err(reject()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_all_shapes() { + for input in [ + "octocat/hello", + "octocat/hello@feat/x", + "octocat/hello@feat/x:main", + "my-org/my.repo_name@release/1.2:develop", + ] { + let spec: RepoSpec = input.parse().unwrap(); + assert_eq!(spec.to_string(), input, "round-trip for {input}"); + } + } + + #[test] + fn parses_components() { + let spec: RepoSpec = "octocat/hello@feat/x:main".parse().unwrap(); + assert_eq!(spec.owner(), "octocat"); + assert_eq!(spec.repo(), "hello"); + assert_eq!(spec.branch(), Some("feat/x")); + assert_eq!(spec.base(), Some("main")); + + let bare: RepoSpec = "octocat/hello".parse().unwrap(); + assert_eq!(bare.branch(), None); + assert_eq!(bare.base(), None); + } + + #[test] + fn rejects_malformed() { + let bad = [ + "", // empty + "noslash", // missing repo + "owner/", // empty repo + "/repo", // empty owner + "a/b/c", // too many slashes + "owner/repo@", // empty branch + "owner/repo@feat:", // empty base + "-owner/repo", // leading hyphen owner + "owner-/repo", // trailing hyphen owner + "ow--ner/repo", // double hyphen owner + "own er/repo", // space in owner + "owner/re po", // space in repo + "owner/repo@fe at", // space in branch + "owner/repo@feat~x", // tilde in branch + "owner/repo@..", // dotdot branch + "owner/repo@/feat", // leading slash branch + "owner/.", // repo is a dot + "owner/repo@feat@more", // second @ becomes branch char, rejected + ]; + for input in bad { + assert!( + input.parse::().is_err(), + "should reject {input:?}", + ); + } + } + + #[test] + fn base_without_branch_is_unrepresentable() { + // There is no constructor and no parse path that yields a base without a + // branch: base lives inside BranchSpec, which requires a name. + let spec = RepoSpec::new("o", "r", None).unwrap(); + assert_eq!(spec.base(), None); + } + + #[test] + fn grant_id_round_trip_and_rejects_empty() { + let id: GrantId = "grant-123".parse().unwrap(); + assert_eq!(id.as_str(), "grant-123"); + assert_eq!(id.to_string(), "grant-123"); + assert!("".parse::().is_err()); + assert!(" ".parse::().is_err()); + } +} diff --git a/crates/mfile/Cargo.toml b/crates/mfile/Cargo.toml index 8e15429e9..e6d9fb536 100644 --- a/crates/mfile/Cargo.toml +++ b/crates/mfile/Cargo.toml @@ -13,6 +13,7 @@ tempfile.workspace = true [dependencies] args.workspace = true common.workspace = true +github.workspace = true paths.workspace = true sessions.workspace = true diff --git a/crates/mfile/src/lib.rs b/crates/mfile/src/lib.rs index 2c897ce59..bfafbe471 100644 --- a/crates/mfile/src/lib.rs +++ b/crates/mfile/src/lib.rs @@ -397,6 +397,22 @@ pub struct Session { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lifecycle_hooks: Vec, + /// GitHub repo pre-priming and scope configuration for sessions + /// activated on this project (spec 10: R2.1 repo list, R5.2 scope + /// override, R7.1 task-spec surface). + /// + /// Unlike the other fields on [`Session`], this block is **not** + /// materialized into the project's loadout/composable — there is + /// no GitHub-shaped primitive in + /// [`sessions::core::loadout::Loadout`]. It is read directly by + /// the `min` client at `activate` time (repo cloning, branch + /// checkout-or-create, scope consent) and carried into the + /// `CreateSession`/`ConfigureLoadout` RPC sequence via + /// `SessionConfig.attrs`, not sent as part of this struct. See + /// [`Session::is_empty`] for how this affects emptiness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + /// Any fields which are not understood by this version of minimal. #[serde(flatten)] pub extra: HashMap, @@ -412,13 +428,19 @@ impl Session { /// Returns true iff no primitive-carrying field on the session /// is populated. Used by downstream callers (e.g. daemon-side /// composable construction) to decide whether a synthesized - /// [`Session`] has anything worth materializing. + /// [`Session`] has anything worth materializing into a + /// [`sessions::core::loadout::Loadout`]-shaped composable. /// /// `extra` is ignored — unknown fields don't count as - /// contributions. + /// contributions. `github` is also ignored for this specific + /// contract: it is never materialized into the composable (see + /// the field doc on [`Session::github`]) — it is read directly by + /// the `min` client at activate time, so a session block that + /// carries only a `github` block genuinely has nothing for the + /// composable to materialize. /// /// The exhaustive `let Self { ... } = self` destructure in the - /// body is what makes this drift-proof: if a sixth primitive + /// body is what makes this drift-proof: if another primitive /// lands on [`Session`], the pattern fails to compile until it's /// added here. Naming the method is orthogonal — kept as /// `is_empty` because that's the idiomatic Rust name for the @@ -431,6 +453,7 @@ impl Session { vars_lenient, patches, lifecycle_hooks, + github: _, extra: _, } = self; packages.is_empty() @@ -441,6 +464,135 @@ impl Session { } } +/// The `[session.github]` block: repo pre-priming and an optional +/// session-wide scope override (spec 10: R2.1, R5.2, R7.1). +/// +/// Fields here store **raw strings**, not the parsed `github` crate +/// types: this keeps `mfile` deserialization back-compat-friendly (a +/// malformed repo spec doesn't fail to parse the whole `minimal.toml` +/// until something actually asks for the validated form) and keeps +/// exactly one grammar for `owner/repo[@branch[:base]]` and scope +/// strings — the one implemented by [`github::RepoSpec`] and +/// [`github::ScopeSet`]. Use [`SessionGithub::scope_set`] and +/// [`GithubRepo::repo_spec`]/[`GithubRepo::scope_set`] to validate. +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct SessionGithub { + /// Repositories to pre-prime into the session (spec R2.1), + /// declared as `[[session.github.repos]]` entries. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repos: Vec, + + /// Session-wide scope override, e.g. `"contents:rw,issues:read"` + /// (spec R5.2). Applies to every repo that doesn't set its own + /// `scopes`. Absent means: apply [`github::ScopeSet::defaults`] + /// and prompt (spec R5.2/R5.3). Parsed with the same grammar as + /// [`GithubRepo::scopes`] via [`SessionGithub::scope_set`]; the + /// `workflows` permission is rejected there (spec NG6), never + /// silently dropped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option, + + /// Any fields which are not understood by this version of minimal. + #[serde(flatten)] + pub extra: HashMap, +} + +impl SessionGithub { + /// Parses [`SessionGithub::scopes`] into a validated + /// [`github::ScopeSet`], if a session-wide override was set. + /// + /// Returns `Ok(None)` when no override is present — callers should + /// fall back to [`github::ScopeSet::defaults`] per spec R5.2. + /// Rejects unknown scopes, including `workflows` (spec NG6), and + /// malformed `scope:permission` entries. + pub fn scope_set(&self) -> Result, github::Error> { + self.scopes + .as_deref() + .map(github::ScopeSet::from_attr_value) + .transpose() + } +} + +/// A single `[[session.github.repos]]` entry (spec R2.1/R2.2/R5.2). +/// +/// `repo`, `branch`, and `base` store raw strings so a `minimal.toml` +/// with a malformed entry still parses as TOML; call +/// [`GithubRepo::repo_spec`] to validate and get the typed +/// [`github::RepoSpec`] the rest of the system uses. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct GithubRepo { + /// The repository, as `owner/name`. Required: unlike the other + /// fields on this entry, there is no sensible default, so a + /// missing `repo` fails TOML deserialization immediately with a + /// `missing field` error rather than deferring to + /// [`GithubRepo::repo_spec`]. + pub repo: String, + /// The working branch to check out or create (spec R2.2). `None` + /// defers to adopt-local's current-branch default when this repo + /// is reconciled against an existing checkout. + #[serde(default)] + pub branch: Option, + /// The base branch a new `branch` is created from, if `branch` + /// doesn't already exist on the remote (spec R2.2). Only + /// meaningful alongside `branch`; a `base` without a `branch` is + /// rejected by [`GithubRepo::repo_spec`] — it is not + /// representable in [`github::RepoSpec`] by construction. + #[serde(default)] + pub base: Option, + /// Per-repo scope override (spec R5.2), taking precedence over + /// [`SessionGithub::scopes`] for this repo. Same grammar, + /// validated by [`GithubRepo::scope_set`]. + #[serde(default)] + pub scopes: Option, + + /// Any fields which are not understood by this version of minimal. + #[serde(flatten)] + pub extra: HashMap, +} + +impl GithubRepo { + /// Validates and parses `repo`/`branch`/`base` into a + /// [`github::RepoSpec`], the single canonical grammar used + /// everywhere else in the codebase. + /// + /// Builds the canonical `owner/repo[@branch[:base]]` string and + /// delegates to [`github::RepoSpec`]'s `FromStr` rather than + /// re-implementing validation, so this and the `attrs` codec in + /// the `github` crate can never drift apart on what's valid. + pub fn repo_spec(&self) -> Result { + use std::str::FromStr; + + if self.branch.is_none() && self.base.is_some() { + return Err(github::Error::InvalidRepoSpec { + input: self.repo.clone(), + reason: "`base` requires `branch` to also be set".to_string(), + }); + } + + let mut encoded = self.repo.clone(); + if let Some(branch) = &self.branch { + encoded.push('@'); + encoded.push_str(branch); + if let Some(base) = &self.base { + encoded.push(':'); + encoded.push_str(base); + } + } + github::RepoSpec::from_str(&encoded) + } + + /// Parses this entry's scope override, if any, per + /// [`SessionGithub::scope_set`]'s contract (rejects `workflows` + /// and malformed entries; `Ok(None)` means "no per-repo override, + /// fall back to the session-wide scopes or the defaults"). + pub fn scope_set(&self) -> Result, github::Error> { + self.scopes + .as_deref() + .map(github::ScopeSet::from_attr_value) + .transpose() + } +} + /// Describes the specific type of output being generated, along with any /// fields which are only meaningful for that output type. /// @@ -950,6 +1102,29 @@ impl File { ); was_unknown_fields = true; } + if let Some(session) = &self.session + && let Some(github) = &session.github + { + if !github.extra.is_empty() { + tracing::warn!( + "unknown fields in [session.github] section of {}: {}", + MFILE_NAME, + github.extra.keys().cloned().collect::>().join(",") + ); + was_unknown_fields = true; + } + for (i, repo) in github.repos.iter().enumerate() { + if !repo.extra.is_empty() { + tracing::warn!( + "unknown fields in [[session.github.repos]] entry {} of {}: {}", + i, + MFILE_NAME, + repo.extra.keys().cloned().collect::>().join(",") + ); + was_unknown_fields = true; + } + } + } for (task_name, task) in &self.tasks { if !task.extra.is_empty() { @@ -1783,6 +1958,181 @@ mod tests { assert_eq!(session.lifecycle_hooks.len(), 1); } + /// A `[session]` block with no `github` key at all — the shape + /// every pre-spec-10 `minimal.toml` has — parses with + /// `session.github: None` unchanged (NG7 back-compat): adding the + /// optional field doesn't disturb an mfile that predates it. + #[test] + fn session_github_absent_is_back_compat() { + let mf: File = toml::from_str(indoc! { + r#" + [session] + packages = ["rustc"] + "# + }) + .unwrap(); + let session = mf.session.expect("session block parses"); + assert!(session.github.is_none()); + } + + /// A full `[session.github]` block — a session-wide `scopes` + /// override plus two `[[session.github.repos]]` entries, the + /// second with its own per-repo `scopes` override — parses, and + /// the validating accessors (`repo_spec`/`scope_set`) resolve to + /// the `github` crate's typed values, the single grammar used + /// everywhere else (spec 10: R2.1, R2.2, R5.2, R7.1). + #[test] + fn session_github_full_block_parses() { + let mf: File = toml::from_str(indoc! { + r#" + [session.github] + scopes = "contents:rw,pull_requests:rw" + + [[session.github.repos]] + repo = "octocat/hello" + branch = "feat/x" + base = "main" + + [[session.github.repos]] + repo = "my-org/api" + branch = "feat/y" + scopes = "issues:read" + "# + }) + .unwrap(); + + let session = mf.session.expect("session block parses"); + let github = session.github.expect("github block parses"); + + assert_eq!( + github.scope_set().unwrap(), + Some( + github::ScopeSet::empty() + .with(github::Scope::Contents, github::Permission::Write) + .with(github::Scope::PullRequests, github::Permission::Write) + ) + ); + + assert_eq!(github.repos.len(), 2); + + let first = &github.repos[0]; + let spec = first.repo_spec().unwrap(); + assert_eq!(spec.owner(), "octocat"); + assert_eq!(spec.repo(), "hello"); + assert_eq!(spec.branch(), Some("feat/x")); + assert_eq!(spec.base(), Some("main")); + assert_eq!(first.scope_set().unwrap(), None); + + let second = &github.repos[1]; + assert_eq!(second.repo_spec().unwrap().to_string(), "my-org/api@feat/y"); + assert_eq!( + second.scope_set().unwrap(), + Some(github::ScopeSet::empty().with(github::Scope::Issues, github::Permission::Read)) + ); + } + + /// The `workflows` GitHub permission is unrepresentable in + /// [`github::Scope`] (spec NG6): the raw TOML parses fine — + /// `scopes` is stored as an opaque string on this struct — but the + /// validating accessor rejects it, both as a session-wide override + /// and as a per-repo override, rather than silently accepting or + /// dropping it. + #[test] + fn session_github_workflows_scope_rejected_at_validation() { + let mf: File = toml::from_str(indoc! { + r#" + [session.github] + scopes = "contents:rw,workflows:rw" + + [[session.github.repos]] + repo = "octocat/hello" + scopes = "workflows:read" + "# + }) + .unwrap(); + let github = mf.session.unwrap().github.unwrap(); + + let err = github + .scope_set() + .expect_err("session-wide workflows scope must be rejected"); + assert!(err.to_string().contains("workflows"), "error was: {err}"); + + let err = github.repos[0] + .scope_set() + .expect_err("per-repo workflows scope must be rejected too"); + assert!(err.to_string().contains("workflows"), "error was: {err}"); + } + + /// A `base` branch without a working `branch` is unrepresentable + /// in [`github::RepoSpec`] by construction; [`GithubRepo::repo_spec`] + /// rejects it explicitly (with an actionable reason) instead of + /// silently dropping `base` or guessing a branch. + #[test] + fn github_repo_base_without_branch_is_rejected() { + let repo = GithubRepo { + repo: "octocat/hello".to_string(), + branch: None, + base: Some("main".to_string()), + scopes: None, + extra: HashMap::new(), + }; + let err = repo + .repo_spec() + .expect_err("base without branch must be rejected"); + assert!(err.to_string().contains("base"), "error was: {err}"); + } + + /// Unknown fields inside `[session.github]` and inside a + /// `[[session.github.repos]]` entry land in their respective + /// `extra` maps for `warn_unknown_fields` to report, matching how + /// every other mfile section handles forward-compat. + #[test] + fn session_github_unknown_fields_land_in_extra() { + let mf: File = toml::from_str(indoc! { + r#" + [session.github] + scopes = "contents:rw" + future_toplevel_field = "x" + + [[session.github.repos]] + repo = "octocat/hello" + future_repo_field = "y" + "# + }) + .unwrap(); + let github = mf.session.unwrap().github.unwrap(); + assert!(github.extra.contains_key("future_toplevel_field")); + assert!(github.repos[0].extra.contains_key("future_repo_field")); + } + + /// A session whose only content is a `github` block is still + /// `is_empty() == true`: `github` is consumed by the `min` client + /// at activate time, not materialized into the project's + /// composable, so there is genuinely nothing for the composable to + /// build from. Guards the deliberate exclusion documented on + /// [`Session::is_empty`] and [`Session::github`]. + #[test] + fn session_is_empty_ignores_github_block() { + let with_github_only = Session { + github: Some(SessionGithub { + repos: vec![GithubRepo { + repo: "octocat/hello".to_string(), + branch: Some("feat/x".to_string()), + base: None, + scopes: None, + extra: HashMap::new(), + }], + scopes: None, + extra: HashMap::new(), + }), + ..Session::default() + }; + assert!( + with_github_only.is_empty(), + "a github-only session block has nothing for the composable to materialize" + ); + } + /// Unknown fields inside `[session]` land in `extra` for the /// `warn_unknown_fields` pass to report at load-time, matching /// how `[stack]` and `[defaults]` handle forward-compat. diff --git a/crates/minimal/Cargo.toml b/crates/minimal/Cargo.toml index 137177591..7624517b8 100644 --- a/crates/minimal/Cargo.toml +++ b/crates/minimal/Cargo.toml @@ -46,6 +46,7 @@ chrono.workspace = true constcat.workspace = true diagnostics.workspace = true dirs.workspace = true +github.workspace = true indicatif.workspace = true nix.workspace = true mctx.workspace = true diff --git a/crates/minimal/src/github.rs b/crates/minimal/src/github.rs new file mode 100644 index 000000000..8c10aba35 --- /dev/null +++ b/crates/minimal/src/github.rs @@ -0,0 +1,579 @@ +//! `min github login|status|logout` — the CLI surface for the daemon-held +//! GitHub App device-flow auth (spec 10, R1.4/R7.2). +//! +//! All three commands are thin RPC callers: `minimald` owns the device flow, +//! the token store, and the App-installation/scope bookkeeping (see +//! `crates/github` and `crates/minimald/src/github/`). This module never +//! sees a token — the wire types it talks to (`GithubBeginLogin`, +//! `GithubPollLogin`, `GithubStatus`, `GithubListAuths`, `GithubLogout`) carry +//! login/grant-id metadata only. + +use std::io::IsTerminal as _; +use std::time::Duration; + +use anyhow::{Context as _, bail}; +use clap::{Args, Subcommand}; + +use crate::{GlobalArgs, client, confirm, connect_daemon, ensure_daemon, resolve_session}; + +/// `min github` — sign in and inspect GitHub auth/session status. +#[derive(Debug, Args)] +pub struct GithubArgs { + #[command(subcommand)] + pub command: GithubCommand, +} + +#[derive(Debug, Subcommand)] +pub enum GithubCommand { + /// Sign in to GitHub via the device flow (R1.1) + Login(LoginArgs), + /// Show GitHub identity, token validity, App installation, and session + /// repo status (R1.4/R8.2) + Status(StatusArgs), + /// Forget a stored GitHub authentication grant + Logout(LogoutArgs), +} + +#[derive(Debug, Args)] +pub struct LoginArgs { + /// Request an explicit permission scope (`scope:permission`, e.g. + /// `contents:rw`). Repeatable. When omitted, the daemon applies its + /// least-privilege defaults (R5.1); `workflows` is never requested + /// (NG6) and is rejected by the daemon if named here. + #[arg(long = "scope", value_name = "SCOPE:PERMISSION")] + pub scope: Vec, +} + +#[derive(Debug, Args)] +pub struct StatusArgs { + /// Also report the repo/branch/scope table for this session (UUID or name) + #[arg(long)] + pub session: Option, + /// Additional `owner/repo` to report GitHub App installation state for, + /// beyond any the session declares. Repeatable. + #[arg(long = "repo", value_name = "OWNER/REPO")] + pub repo: Vec, +} + +#[derive(Debug, Args)] +pub struct LogoutArgs { + /// The grant to forget (see `min github status`). May be omitted when + /// exactly one GitHub authentication grant is stored. + pub grant_id: Option, + /// Skip the confirmation prompt + #[arg(long, short)] + pub force: bool, +} + +/// Dispatch a `min github ` invocation. +pub async fn cmd_github(global: &GlobalArgs, args: GithubArgs) -> Result<(), anyhow::Error> { + match args.command { + GithubCommand::Login(args) => cmd_github_login(global, args).await, + GithubCommand::Status(args) => cmd_github_status(global, args).await, + GithubCommand::Logout(args) => cmd_github_logout(global, args).await, + } +} + +/// `min github login`: start the device flow, show the verification URL and +/// code, then poll until the user approves (or the code expires) (R1.1). +pub async fn cmd_github_login(global: &GlobalArgs, args: LoginArgs) -> Result<(), anyhow::Error> { + ensure_daemon(global)?; + let mut client = connect_daemon(global).await?; + + let begin_resp = client + .oneshot_rpc::(minimald_rpc::GithubBeginLoginRequest::new( + args.scope, + )) + .await + .context("GithubBeginLogin RPC failed")?; + + let begin = match begin_resp { + minimald_rpc::Errorable::Ok(begin) => begin, + minimald_rpc::Errorable::Err { error } => bail!("{error}"), + }; + + println!( + "Open {} and enter code {}", + begin.verification_uri, begin.user_code + ); + + let outcome = poll_login_until_done( + &mut client, + &begin.login_id, + Duration::from_secs(begin.poll_interval_secs.max(1)), + Duration::from_secs(begin.expires_in_secs.max(1)), + ) + .await?; + + match outcome { + minimald_rpc::GithubPollLoginResponse::Complete { login, grant_id } => { + println!("Logged in to GitHub as {login} (grant {grant_id})."); + Ok(()) + } + minimald_rpc::GithubPollLoginResponse::Failed { message } => { + bail!("GitHub login failed: {message}") + } + minimald_rpc::GithubPollLoginResponse::Expired => { + bail!("GitHub login code expired; run `min github login` again") + } + minimald_rpc::GithubPollLoginResponse::Pending => { + // poll_login_until_done never returns Pending; kept exhaustive + // against the wire enum's #[non_exhaustive] growth. + bail!("unexpected pending state from the daemon") + } + _ => bail!("unrecognized login state from the daemon"), + } +} + +/// Poll `GithubPollLogin` at `interval` until a terminal state, the code +/// expires, or the user hits Ctrl-C. Drives a stderr spinner while waiting +/// when stderr is a terminal. +async fn poll_login_until_done( + client: &mut client::Client, + login_id: &str, + interval: Duration, + expires_in: Duration, +) -> Result { + let spinner = std::io::stderr().is_terminal().then(|| { + let bar = indicatif::ProgressBar::new_spinner(); + bar.enable_steady_tick(Duration::from_millis(120)); + bar.set_message("waiting for approval in the browser (Ctrl-C to cancel)..."); + bar + }); + + let deadline = tokio::time::Instant::now() + expires_in; + + let result = loop { + if tokio::time::Instant::now() >= deadline { + break Ok(minimald_rpc::GithubPollLoginResponse::Expired); + } + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + break Err(anyhow::anyhow!("login cancelled")); + } + () = tokio::time::sleep(interval) => {} + } + + let poll_resp = client + .oneshot_rpc::( + minimald_rpc::GithubPollLoginRequest::new(login_id.to_string()), + ) + .await + .context("GithubPollLogin RPC failed"); + + match poll_resp { + Err(e) => break Err(e), + Ok(minimald_rpc::Errorable::Err { error }) => break Err(anyhow::anyhow!("{error}")), + Ok(minimald_rpc::Errorable::Ok(minimald_rpc::GithubPollLoginResponse::Pending)) => { + continue; + } + Ok(minimald_rpc::Errorable::Ok(state)) => break Ok(state), + } + }; + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + result +} + +/// `min github status`: identity, token validity/expiry, per-repo App +/// installation + guidance URL, and (when `--session` is given) the +/// session's repo/branch/scope table (R1.4/R1.5/R8.2). +pub async fn cmd_github_status(global: &GlobalArgs, args: StatusArgs) -> Result<(), anyhow::Error> { + ensure_daemon(global)?; + let mut client = connect_daemon(global).await?; + + let repos = args + .repo + .iter() + .map(|raw| { + raw.parse::() + .map(|spec| format!("{}/{}", spec.owner(), spec.repo())) + .map_err(|e| anyhow::anyhow!("invalid --repo '{raw}': {e}")) + }) + .collect::, _>>()?; + + let session_id = match &args.session { + Some(s) => Some(resolve_session(&mut client, s).await?.id), + None => None, + }; + + let resp = client + .oneshot_rpc::(minimald_rpc::GithubStatusRequest::new( + session_id, repos, + )) + .await + .context("GithubStatus RPC failed")?; + + let status = match resp { + minimald_rpc::Errorable::Ok(status) => status, + minimald_rpc::Errorable::Err { error } => bail!("{error}"), + }; + + print!("{}", render_status(&status)); + Ok(()) +} + +/// Render a [`minimald_rpc::GithubStatusResponse`] as the `min github status` +/// human-readable report. Split out from [`cmd_github_status`] so canned +/// responses can be rendered in tests without an RPC round-trip. +/// +/// Every `writeln!` below targets a plain `String`, whose `Write` impl never +/// returns `Err`; `.expect(...)` documents that invariant instead of silently +/// discarding a `Result` (never a `let _ = ...` swallow). +fn render_status(status: &minimald_rpc::GithubStatusResponse) -> String { + use std::fmt::Write as _; + + const INFALLIBLE: &str = "writing to a String cannot fail"; + + let mut out = String::new(); + + match &status.identity { + Some(identity) => { + writeln!( + out, + "Logged in as {} (grant {})", + identity.login, identity.grant_id + ) + .expect(INFALLIBLE); + } + None => { + writeln!(out, "Not logged in. Run `min github login`.").expect(INFALLIBLE); + } + } + + match (status.token_valid, &status.token_expires_at) { + (true, Some(expires_at)) => { + writeln!(out, "Token valid until {expires_at}").expect(INFALLIBLE); + } + (true, None) => { + writeln!(out, "Token valid").expect(INFALLIBLE); + } + (false, _) => { + writeln!(out, "Token invalid or expired; run `min github login`.").expect(INFALLIBLE); + } + } + + if !status.installations.is_empty() { + writeln!(out, "\nRepository App installation:").expect(INFALLIBLE); + for install in &status.installations { + if install.installed { + writeln!(out, " {} - installed", install.repo).expect(INFALLIBLE); + } else { + match &install.install_url { + Some(url) => { + writeln!(out, " {} - NOT installed; install at {url}", install.repo) + .expect(INFALLIBLE); + } + None => { + writeln!(out, " {} - NOT installed", install.repo).expect(INFALLIBLE); + } + } + } + } + } + + if !status.session_repos.is_empty() { + writeln!(out, "\nSession repos:").expect(INFALLIBLE); + for row in &status.session_repos { + let base = row.base.as_deref().unwrap_or("-"); + writeln!( + out, + " {} @ {} (base {base}) - scopes: {}", + row.repo, + row.branch, + render_scopes(&row.scopes) + ) + .expect(INFALLIBLE); + } + } + + out +} + +/// Render a row's plain-label scopes (e.g. `["contents:rw", "metadata:read"]`) +/// in the canonical consent order via [`github::ScopeSet`]. Falls back to a +/// raw join when the labels don't parse (defensive: never fail a status +/// render over an unexpected daemon-side value). +fn render_scopes(scopes: &[String]) -> String { + if scopes.is_empty() { + return "-".to_string(); + } + match github::ScopeSet::from_attr_value(&scopes.join(",")) { + Ok(set) if !set.is_empty() => set.render_for_consent(), + _ => scopes.join(", "), + } +} + +/// `min github logout`: forget a stored auth grant, after confirmation +/// (R6.4's reuse-or-mint substrate; this is the "forget" side of it). +pub async fn cmd_github_logout(global: &GlobalArgs, args: LogoutArgs) -> Result<(), anyhow::Error> { + ensure_daemon(global)?; + let mut client = connect_daemon(global).await?; + + let grant_id = match args.grant_id { + Some(id) => id, + None => resolve_sole_grant(&mut client).await?, + }; + + if !args.force { + if !std::io::stdin().is_terminal() { + bail!("refusing to log out without confirmation; pass --force"); + } + if !confirm( + &format!("Forget GitHub authentication grant {grant_id}?"), + false, + )? { + println!("Aborted."); + return Ok(()); + } + } + + let resp = client + .oneshot_rpc::(minimald_rpc::GithubLogoutRequest::new( + grant_id.clone(), + )) + .await + .context("GithubLogout RPC failed")?; + + match resp { + minimald_rpc::Errorable::Ok(r) if r.removed => { + println!("Removed GitHub authentication grant {grant_id}."); + Ok(()) + } + minimald_rpc::Errorable::Ok(_) => { + bail!("no GitHub authentication grant matched {grant_id}") + } + minimald_rpc::Errorable::Err { error } => bail!("{error}"), + } +} + +/// Resolve the sole stored grant when `min github logout` is called without a +/// `grant_id`. Fails closed (asks the user to disambiguate) when zero or more +/// than one grant is stored, rather than guessing. +async fn resolve_sole_grant(client: &mut client::Client) -> Result { + let resp = client + .oneshot_rpc::(minimald_rpc::GithubListAuthsRequest::new()) + .await + .context("GithubListAuths RPC failed")?; + + let grants = match resp { + minimald_rpc::Errorable::Ok(r) => r.grants, + minimald_rpc::Errorable::Err { error } => bail!("{error}"), + }; + + match grants.as_slice() { + [] => bail!("no GitHub authentication grants are stored"), + [grant] => Ok(grant.grant_id.clone()), + many => { + let ids: Vec<_> = many + .iter() + .map(|g| format!("{} ({})", g.grant_id, g.login)) + .collect(); + bail!( + "multiple GitHub authentication grants are stored; specify one: {}", + ids.join(", ") + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Cli, Command}; + use clap::Parser as _; + + fn parse(args: &[&str]) -> Cli { + let mut full = vec!["min"]; + full.extend_from_slice(args); + Cli::try_parse_from(full).expect("clap parse") + } + + #[test] + fn login_parses_with_no_flags() { + let cli = parse(&["github", "login"]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Login(args), + })) = cli.command + else { + panic!("expected Command::Github(Login)"); + }; + assert!(args.scope.is_empty()); + } + + #[test] + fn login_collects_repeated_scope_flags() { + let cli = parse(&[ + "github", + "login", + "--scope", + "contents:rw", + "--scope", + "issues:rw", + ]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Login(args), + })) = cli.command + else { + panic!("expected Command::Github(Login)"); + }; + assert_eq!(args.scope, vec!["contents:rw", "issues:rw"]); + } + + #[test] + fn status_parses_session_and_repeated_repo_flags() { + let cli = parse(&[ + "github", + "status", + "--session", + "my-session", + "--repo", + "octocat/hello", + "--repo", + "octocat/world", + ]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Status(args), + })) = cli.command + else { + panic!("expected Command::Github(Status)"); + }; + assert_eq!(args.session.as_deref(), Some("my-session")); + assert_eq!(args.repo, vec!["octocat/hello", "octocat/world"]); + } + + #[test] + fn status_parses_with_no_flags() { + let cli = parse(&["github", "status"]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Status(args), + })) = cli.command + else { + panic!("expected Command::Github(Status)"); + }; + assert!(args.session.is_none()); + assert!(args.repo.is_empty()); + } + + #[test] + fn logout_parses_grant_id_and_force() { + let cli = parse(&["github", "logout", "grant-123", "--force"]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Logout(args), + })) = cli.command + else { + panic!("expected Command::Github(Logout)"); + }; + assert_eq!(args.grant_id.as_deref(), Some("grant-123")); + assert!(args.force); + } + + #[test] + fn logout_parses_without_grant_id() { + let cli = parse(&["github", "logout"]); + let Some(Command::Github(GithubArgs { + command: GithubCommand::Logout(args), + })) = cli.command + else { + panic!("expected Command::Github(Logout)"); + }; + assert!(args.grant_id.is_none()); + assert!(!args.force); + } + + fn sample_identity() -> minimald_rpc::GithubIdentity { + minimald_rpc::GithubIdentity::new("octocat".to_string(), "grant-1".to_string()) + } + + #[test] + fn render_status_reports_identity_and_token_validity() { + let status = minimald_rpc::GithubStatusResponse::new( + Some(sample_identity()), + true, + None, + vec![], + vec![], + ); + let rendered = render_status(&status); + assert!(rendered.contains("Logged in as octocat (grant grant-1)")); + assert!(rendered.contains("Token valid")); + } + + #[test] + fn render_status_reports_no_identity() { + let status = minimald_rpc::GithubStatusResponse::new(None, false, None, vec![], vec![]); + let rendered = render_status(&status); + assert!(rendered.contains("Not logged in")); + assert!(rendered.contains("Token invalid or expired")); + } + + #[test] + fn render_status_reports_missing_app_installation_with_guidance_url() { + let status = minimald_rpc::GithubStatusResponse::new( + Some(sample_identity()), + true, + None, + vec![minimald_rpc::RepoInstallationStatus::new( + "octocat/hello".to_string(), + false, + Some("https://github.com/apps/minimal/installations/new".to_string()), + )], + vec![], + ); + let rendered = render_status(&status); + assert!(rendered.contains("octocat/hello - NOT installed")); + assert!(rendered.contains("install at https://github.com/apps/minimal/installations/new")); + } + + #[test] + fn render_status_reports_installed_repo() { + let status = minimald_rpc::GithubStatusResponse::new( + Some(sample_identity()), + true, + None, + vec![minimald_rpc::RepoInstallationStatus::new( + "octocat/hello".to_string(), + true, + None, + )], + vec![], + ); + let rendered = render_status(&status); + assert!(rendered.contains("octocat/hello - installed")); + } + + #[test] + fn render_status_reports_session_repo_rows_with_ordered_scopes() { + let status = minimald_rpc::GithubStatusResponse::new( + Some(sample_identity()), + true, + None, + vec![], + vec![minimald_rpc::SessionRepoStatus::new( + "octocat/hello".to_string(), + "feat/x".to_string(), + Some("main".to_string()), + vec!["metadata:read".to_string(), "contents:rw".to_string()], + )], + ); + let rendered = render_status(&status); + assert!(rendered.contains("octocat/hello @ feat/x (base main)")); + // ScopeSet renders in canonical consent order regardless of input order. + assert!(rendered.contains("scopes: contents:rw, metadata:read")); + } + + #[test] + fn render_scopes_falls_back_to_raw_join_on_unparsable_labels() { + let rendered = render_scopes(&["not-a-scope".to_string()]); + assert_eq!(rendered, "not-a-scope"); + } + + #[test] + fn render_scopes_reports_dash_when_empty() { + assert_eq!(render_scopes(&[]), "-"); + } +} diff --git a/crates/minimal/src/lib.rs b/crates/minimal/src/lib.rs index ad0910c0d..9e31b6fd3 100644 --- a/crates/minimal/src/lib.rs +++ b/crates/minimal/src/lib.rs @@ -5,7 +5,6 @@ use clap::{ArgGroup, Args, Parser, Subcommand}; use clap_complete::Shell; use std::io::IsTerminal as _; use std::io::Write as _; -use std::os::unix::process::CommandExt as _; use std::path::PathBuf; use tokio::io::AsyncWriteExt as _; @@ -19,6 +18,7 @@ pub mod diag; pub mod dirs; mod file_upload; pub mod git_remote; +pub mod github; pub mod loadouts; pub mod prompt; pub mod theme; @@ -58,6 +58,8 @@ pub enum Command { /// Session management subcommands #[command(visible_alias = "sessions")] Session(SessionArgs), + /// GitHub authentication: sign in, inspect status, and sign out (R1.4/R7.2) + Github(github::GithubArgs), /// Loadout management subcommands #[command(visible_alias = "loadouts")] Loadout(LoadoutArgs), @@ -659,6 +661,7 @@ async fn run_command(cli: Cli) -> Result<(), anyhow::Error> { SessionCommand::Rename(args) => cmd_rename(&cli.global_args, args).await, SessionCommand::Policy(args) => cmd_session_policy(&cli.global_args, args).await, }, + Some(Command::Github(args)) => github::cmd_github(&cli.global_args, args).await, Some(Command::Loadout(LoadoutArgs { command: LoadoutCommand::List(args), })) => loadouts::cmd_loadout_list(args, &cli.global_args), @@ -1751,6 +1754,10 @@ fn host_key_opts(known_hosts: &std::path::Path) -> [String; 2] { /// shell out to `ssh` — the daemon's shell_request handler mints a PTY-backed /// session shell, and ssh handles termios/PTY management for us. /// +/// On the success path this does not return: once the ssh child finishes, +/// the process exits with the child's own outcome ([`attach_exit_code`]), +/// exactly as when attach `exec()`d ssh in place. +/// /// When `args.session` is `None`, the session is resolved from the current /// working directory (or the only existing session), opening an interactive /// picker when the choice is ambiguous; see [`attach::resolve_for_attach`] @@ -1782,7 +1789,14 @@ pub async fn cmd_attach(global: &GlobalArgs, args: AttachArgs) -> Result<(), any "found session" ); - attach_to_session(&sock, id, args.command).await + // Close the control connection before handing the terminal to ssh: the + // attach traffic runs over ssh's own connection (via the `proxy` + // subcommand), and when attach `exec()`d ssh this RPC connection died + // with the replaced process image. + drop(client); + + let status = attach_to_session(&sock, id, args.command).await?; + std::process::exit(attach_exit_code(status)); } /// Resolve a session to attach to when the user supplied no explicit session @@ -1848,11 +1862,20 @@ fn ensure_interactive_attach_tty(stdin_is_tty: bool) -> Result<(), anyhow::Error } } -/// Shell out to `ssh` to attach to `id`. Both the interactive (no `command`) -/// and `--command` (non-interactive exec) paths route through here; the -/// daemon's shell_request handler mints a PTY-backed shell, and ssh handles +/// Shell out to `ssh` to attach to `id`, wait for it, and return its exit +/// status. Both the interactive (no `command`) and `--command` +/// (non-interactive exec) paths route through here; the daemon's +/// shell_request handler mints a PTY-backed shell, and ssh handles /// termios/PTY management. /// +/// ssh runs as a waited-on child rather than an `exec()` replacement so that +/// control returns to the client after the session shell exits — the seam +/// post-attach flows (e.g. an exit prompt) hook into. While the child runs, +/// keyboard signals are ignored in this process (see [`keyboard_signals`]) +/// so a Ctrl-C reaches the foreground ssh / in-session process instead of +/// killing the waiting client; callers propagate the returned status via +/// [`attach_exit_code`] so the observable outcome matches the old `exec()`. +/// /// Split from [`cmd_attach`] so the activate-then-attach chain and the /// smart-resolution picker can attach without re-resolving an entry they /// already hold. @@ -1860,7 +1883,7 @@ async fn attach_to_session( sock: &std::path::Path, id: sessions::SessionId, command: Option, -) -> Result<(), anyhow::Error> { +) -> Result { // ProxyCommand points at our own `proxy` subcommand so we don't // depend on socat or nc being installed. let exe = std::env::current_exe().context("cannot determine current exe")?; @@ -1934,9 +1957,124 @@ async fn attach_to_session( ssh.arg(cmd); } - let err = ssh.exec(); - // exec() only returns on failure - bail!("failed to exec ssh: {err}"); + // Spawn (inheriting our stdio, so ssh owns the terminal) and wait via + // tokio so the runtime thread is not blocked for the whole session. + let mut child = tokio::process::Command::from(ssh) + .spawn() + .context("failed to spawn ssh (is an OpenSSH client installed and on PATH?)")?; + + // Only ignore keyboard signals once the child exists: [`keyboard_signals`] + // explains both the why and the ordering constraint. + let _guard = keyboard_signals::Ignored::install(); + child.wait().await.context("failed waiting for ssh") +} + +/// The exit code `min` reports after an attach, given the finished ssh +/// child's status. +/// +/// When attach `exec()`d ssh, the shell observed ssh's own wait status. +/// Mirror it: a normal exit propagates its code verbatim, and death by +/// signal N maps to the shell's `128 + N` convention — the same `$?` an +/// exec()'d ssh produced — since a plain exit code cannot express signal +/// death. +fn attach_exit_code(status: std::process::ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt as _; + match status.code() { + Some(code) => code, + // `wait()` only returns codeless for a signal death, so the fallback + // arm is unreachable; `1` keeps the mapping total without masking a + // failure as success. + None => 128 + status.signal().unwrap_or(1), + } +} + +/// Keyboard-generated signals (`SIGINT` from `Ctrl-C`, `SIGQUIT` from +/// `Ctrl-\`) are delivered to the whole foreground process group, which during +/// [`attach_to_session`]'s wait contains both the `ssh` child and the waiting +/// `min` client. When attach `exec()`d ssh there was no waiting parent: the +/// keystroke reached ssh (or, through the PTY, the in-session process) alone. +/// Preserve that by ignoring both signals in the parent for exactly as long +/// as the child runs — the discipline POSIX `system(3)` mandates for its +/// waiting parent — and restoring the saved dispositions afterwards so any +/// later interactive code gets normal Ctrl-C behaviour back. +/// +/// Bound directly to POSIX `signal(2)`: this crate has no libc-crate +/// dependency, and `tokio::signal` cannot express "ignore" (its handlers +/// stay registered for the life of the process, so the disposition could +/// never be restored). +mod keyboard_signals { + use std::os::raw::c_int; + + /// C's `sighandler_t` is a function pointer, but only the integral + /// sentinels below ever cross this boundary, so a plain machine word is + /// enough (the libc crate declares it the same way on these platforms). + type SigHandler = usize; + + const SIG_IGN: SigHandler = 1; + const SIG_ERR: SigHandler = usize::MAX; + + /// POSIX-mandated signal numbers, identical on Linux and macOS. + pub(super) const SIGINT: c_int = 2; + pub(super) const SIGQUIT: c_int = 3; + + unsafe extern "C" { + fn signal(signum: c_int, handler: SigHandler) -> SigHandler; + } + + /// RAII: ignores SIGINT and SIGQUIT on construction and restores the + /// previous dispositions on drop. + pub(super) struct Ignored { + saved: [(c_int, SigHandler); 2], + } + + impl Ignored { + /// Install `SIG_IGN` for both keyboard signals. + /// + /// Must be called AFTER spawning the child: an ignored disposition + /// (unlike a caught handler) survives `exec`, so installing it first + /// would make the spawned ssh itself ignore Ctrl-C. + pub(super) fn install() -> Self { + let saved = [SIGINT, SIGQUIT].map(|signum| { + // SAFETY: `SIG_IGN` is a valid disposition for both signals, + // both signal numbers are valid on every supported platform, + // and no handler function is involved, so no callback safety + // invariants arise. + let prev = unsafe { signal(signum, SIG_IGN) }; + (signum, prev) + }); + Ignored { saved } + } + } + + impl Drop for Ignored { + fn drop(&mut self) { + for (signum, prev) in self.saved { + // SIG_ERR means the install itself failed (not possible for + // these two well-known signals); "restoring" it would be + // meaningless, so leave the ignore in place — fail closed. + if prev != SIG_ERR { + // SAFETY: `prev` is a disposition previously returned by + // `signal(2)` for this same signal, so handing it back + // is valid by construction. + unsafe { signal(signum, prev) }; + } + } + } + } + + /// Test-only probe: whether `signum` is currently ignored. Reads the + /// disposition the only way `signal(2)` allows — by replacing it — and + /// immediately restores what it saw. + #[cfg(test)] + pub(super) fn is_ignored(signum: c_int) -> bool { + // SAFETY: same argument as `Ignored::install`; the second call + // restores the exact value the first returned. + let prev = unsafe { signal(signum, SIG_IGN) }; + if prev != SIG_ERR && prev != SIG_IGN { + unsafe { signal(signum, prev) }; + } + prev == SIG_IGN + } } /// Print the effective networking policy for a session as JSON. @@ -2746,6 +2884,49 @@ mod tests { ensure_interactive_attach_tty(true).expect("a real terminal must pass the guard"); } + /// A normally-exited ssh child propagates its exit code verbatim — the + /// same `$?` the shell observed when attach `exec()`d ssh in place. + #[test] + fn attach_exit_code_mirrors_a_normal_exit() { + use std::os::unix::process::ExitStatusExt as _; + // Raw wait statuses: exit code lives in bits 8–15. + let ok = std::process::ExitStatus::from_raw(0); + assert_eq!(attach_exit_code(ok), 0); + let ssh_err = std::process::ExitStatus::from_raw(255 << 8); + assert_eq!(attach_exit_code(ssh_err), 255); + } + + /// A signal-killed ssh child maps to the shell's `128 + N` convention, + /// matching the `$?` a shell computed for the exec()'d ssh's signal death. + #[test] + fn attach_exit_code_maps_signal_death_to_128_plus_n() { + use std::os::unix::process::ExitStatusExt as _; + // Raw wait status: death by signal N carries N in the low 7 bits. + let sigint = std::process::ExitStatus::from_raw(2); + assert_eq!(attach_exit_code(sigint), 130); + let sigterm = std::process::ExitStatus::from_raw(15); + assert_eq!(attach_exit_code(sigterm), 143); + } + + /// The attach wait must ignore keyboard signals only while the guard is + /// held: install → both ignored (a Ctrl-C reaches the foreground ssh + /// alone); drop → prior dispositions restored (later interactive code + /// needs its Ctrl-C back). + #[test] + fn keyboard_signal_guard_ignores_while_held_and_restores_on_drop() { + // Baseline: nothing in the test process ignores these signals. + assert!(!keyboard_signals::is_ignored(keyboard_signals::SIGINT)); + assert!(!keyboard_signals::is_ignored(keyboard_signals::SIGQUIT)); + + let guard = keyboard_signals::Ignored::install(); + assert!(keyboard_signals::is_ignored(keyboard_signals::SIGINT)); + assert!(keyboard_signals::is_ignored(keyboard_signals::SIGQUIT)); + + drop(guard); + assert!(!keyboard_signals::is_ignored(keyboard_signals::SIGINT)); + assert!(!keyboard_signals::is_ignored(keyboard_signals::SIGQUIT)); + } + /// A bare `min` must be inert: it prints the top-level help and succeeds, /// touching no daemon and creating no session. The `Cli` command tree has /// to stay renderable for that (a malformed clap definition panics in diff --git a/crates/minimald-rpc/src/lib.rs b/crates/minimald-rpc/src/lib.rs index 98d28b8f4..373c04661 100644 --- a/crates/minimald-rpc/src/lib.rs +++ b/crates/minimald-rpc/src/lib.rs @@ -42,11 +42,18 @@ pub trait OneshotSshRpc { } /// A convinence wrapper to let a response type be able to carry an error. +/// +/// `Err` is declared **first** on purpose: the enum is `#[serde(untagged)]`, so +/// serde tries variants in declaration order, and most response types here +/// default every field. With `Ok` first, an `{"error": ".."}` payload decodes +/// into `Ok(Default::default())` and the client sees a blank success instead of +/// the daemon's failure. No response type carries an `error` field, so trying +/// `Err` first is unambiguous. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] pub enum Errorable { - Ok(S), Err { error: String }, + Ok(S), } impl Errorable { @@ -681,6 +688,778 @@ impl OneshotSshRpc for GetMeshStatus { type Response = MeshStatus; } +// --------------------------------------------------------------------------- +// GitHub-integrated sessions (spec 10): daemon-held auth, mediated repo access, +// PR on exit. +// +// SECURITY (hard rule): no request or response type in this section may be +// capable of carrying GitHub token material. Only the daemon ever holds the +// user/refresh token; it never crosses this wire. Fields here carry logins, +// grant *identifiers*, branch/base names, public URLs (PR links, install +// links), scope labels, and human-readable *scrubbed* status/error lines — but +// never a token, and no field that could be spliced into a credentialed URL. +// +// Repositories and scopes cross the wire as PLAIN STRINGS so this crate gains +// no new dependency. The single grammars live in the `github` crate +// (`github::RepoSpec` for `owner/repo[@branch[:base]]`, `github::Scope` for +// permission labels such as `contents:write`); both sides parse at the edges. +// Parsing is deliberately NOT duplicated here. +// +// Every type is `#[non_exhaustive]` and `#[serde(default)]` per field so new +// fields can be added without breaking old peers, and old-shape payloads +// (missing the new fields) still decode. Method responses are `Errorable`- +// wrapped like the other RPCs, so transport/daemon errors surface as +// `Errorable::Err { error }` distinct from the in-band domain states. +// --------------------------------------------------------------------------- + +/// An RPC that begins the GitHub App **device flow** on the daemon (R1.1). +/// +/// The daemon requests a device+user code from GitHub and returns the +/// verification URL and `user_code` for the `min` CLI to display, plus a +/// `login_id` handle the client polls with [`GithubPollLogin`]. When no GitHub +/// App client id is configured on the daemon, this answers with +/// `Errorable::Err` carrying an actionable, non-secret message. +pub struct GithubBeginLogin; + +/// Request for the [`GithubBeginLogin`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubBeginLoginRequest { + /// Requested permission scopes as plain labels (e.g. `contents:write`), + /// parsed at the edges via `github::Scope`. Empty applies the daemon's + /// least-privilege defaults (R5.1). + #[serde(default)] + pub scopes: Vec, +} + +impl GithubBeginLoginRequest { + #[must_use] + pub fn new(scopes: Vec) -> Self { + Self { scopes } + } +} + +/// Response for the [`GithubBeginLogin`] RPC: the device-flow prompt data. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubBeginLoginResponse { + /// The URL the user opens in a browser to enter their `user_code`. + #[serde(default)] + pub verification_uri: String, + /// The short code the user types at `verification_uri`. + #[serde(default)] + pub user_code: String, + /// Opaque handle identifying this in-flight login; passed back to + /// [`GithubPollLogin`]. + #[serde(default)] + pub login_id: String, + /// Minimum seconds the client must wait between [`GithubPollLogin`] calls, + /// per GitHub's device-flow `interval`. + #[serde(default)] + pub poll_interval_secs: u64, + /// Seconds until the device code expires and the login must be restarted. + #[serde(default)] + pub expires_in_secs: u64, +} + +impl GithubBeginLoginResponse { + #[must_use] + pub fn new( + verification_uri: String, + user_code: String, + login_id: String, + poll_interval_secs: u64, + expires_in_secs: u64, + ) -> Self { + Self { + verification_uri, + user_code, + login_id, + poll_interval_secs, + expires_in_secs, + } + } +} + +impl OneshotSshRpc for GithubBeginLogin { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubBeginLogin"); + type Request<'a> = GithubBeginLoginRequest; + type Response = Errorable; +} + +/// An RPC to poll an in-flight device-flow login for completion (R1.1). +pub struct GithubPollLogin; + +/// Request for the [`GithubPollLogin`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubPollLoginRequest { + /// The [`GithubBeginLoginResponse::login_id`] to poll. + #[serde(default)] + pub login_id: String, +} + +impl GithubPollLoginRequest { + #[must_use] + pub fn new(login_id: String) -> Self { + Self { login_id } + } +} + +/// In-band domain state of a polled device-flow login. +/// +/// These are the flow's own states, distinct from transport errors (which the +/// `Errorable` wrapper carries). `Pending` means keep polling; `Complete` +/// means the daemon stored a grant; `Failed`/`Expired` are terminal. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GithubPollLoginResponse { + /// The user has not yet approved; poll again after the interval. + Pending, + /// The login completed and a grant was stored. Carries the authenticated + /// GitHub `login` and the daemon-assigned `grant_id` (never a token). + Complete { + #[serde(default)] + login: String, + #[serde(default)] + grant_id: String, + }, + /// The login failed terminally (e.g. access denied). `message` is an + /// actionable, non-secret description. + Failed { + #[serde(default)] + message: String, + }, + /// The device code expired before approval; the user must restart login. + Expired, +} + +impl OneshotSshRpc for GithubPollLogin { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubPollLogin"); + type Request<'a> = GithubPollLoginRequest; + type Response = Errorable; +} + +/// An RPC for `min github status` self-diagnosis (R1.4 / R8.2): identity, +/// token validity/expiry, per-repo App-installation state, and the per-session +/// repo/branch/scope table. +pub struct GithubStatus; + +/// Request for the [`GithubStatus`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubStatusRequest { + /// When set, include the per-session repo/branch/scope table for this + /// session in the response. + #[serde(default)] + pub session_id: Option, + /// Additional `owner/repo` strings to report App-installation state for, + /// beyond any the session declares. Parsed at the edges via + /// `github::RepoSpec`. + #[serde(default)] + pub repos: Vec, +} + +impl GithubStatusRequest { + #[must_use] + pub fn new(session_id: Option, repos: Vec) -> Self { + Self { session_id, repos } + } +} + +/// The authenticated GitHub identity behind a grant (metadata only). +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubIdentity { + /// The authenticated GitHub login (username). + #[serde(default)] + pub login: String, + /// The daemon-assigned grant identifier (never a token). + #[serde(default)] + pub grant_id: String, +} + +impl GithubIdentity { + #[must_use] + pub fn new(login: String, grant_id: String) -> Self { + Self { login, grant_id } + } +} + +/// Per-repo GitHub App installation state (R1.5): whether the App is installed +/// on the repo/org, and if not, where to install it. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepoInstallationStatus { + /// The `owner/repo` this entry describes. + #[serde(default)] + pub repo: String, + /// Whether the GitHub App is installed with access to this repo. + #[serde(default)] + pub installed: bool, + /// Where to install/configure the App when `installed` is false; a public + /// github.com URL, never credentialed. + #[serde(default)] + pub install_url: Option, +} + +impl RepoInstallationStatus { + #[must_use] + pub fn new(repo: String, installed: bool, install_url: Option) -> Self { + Self { + repo, + installed, + install_url, + } + } +} + +/// One row of the per-session repo/branch/scope table (R8.2). +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionRepoStatus { + /// The `owner/repo` this row describes. + #[serde(default)] + pub repo: String, + /// The working branch prepared for this repo in the session. + #[serde(default)] + pub branch: String, + /// The base branch the working branch was created from, if applicable. + #[serde(default)] + pub base: Option, + /// The resolved permission scopes for this repo, as plain labels. + #[serde(default)] + pub scopes: Vec, +} + +impl SessionRepoStatus { + #[must_use] + pub fn new(repo: String, branch: String, base: Option, scopes: Vec) -> Self { + Self { + repo, + branch, + base, + scopes, + } + } +} + +/// Response for the [`GithubStatus`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubStatusResponse { + /// The active identity, or `None` when the daemon holds no usable grant. + #[serde(default)] + pub identity: Option, + /// Whether the current user token is valid (refreshable counts as valid). + #[serde(default)] + pub token_valid: bool, + /// When the current user token expires, if known. + #[serde(default)] + pub token_expires_at: Option>, + /// Per-repo App-installation state for the requested/known repos. + #[serde(default)] + pub installations: Vec, + /// The per-session repo/branch/scope table, when a session was requested. + #[serde(default)] + pub session_repos: Vec, +} + +impl GithubStatusResponse { + #[must_use] + pub fn new( + identity: Option, + token_valid: bool, + token_expires_at: Option>, + installations: Vec, + session_repos: Vec, + ) -> Self { + Self { + identity, + token_valid, + token_expires_at, + installations, + session_repos, + } + } +} + +impl OneshotSshRpc for GithubStatus { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubStatus"); + type Request<'a> = GithubStatusRequest; + type Response = Errorable; +} + +/// An RPC to list stored auth grants as METADATA only — the reuse-or-mint +/// substrate (R1.3 / R6.4). No token material crosses the wire. +pub struct GithubListAuths; + +/// Request for the [`GithubListAuths`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubListAuthsRequest {} + +impl GithubListAuthsRequest { + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +/// Metadata describing one stored auth grant (never a token). +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GrantMetadata { + /// The daemon-assigned grant identifier. + #[serde(default)] + pub grant_id: String, + /// The GitHub login this grant authenticates as. + #[serde(default)] + pub login: String, + /// When the grant was first stored, if known. + #[serde(default)] + pub created_at: Option>, + /// The permission scopes bound to this grant, as plain labels. + #[serde(default)] + pub scopes: Vec, + /// The `owner/repo` strings this grant is scoped to. + #[serde(default)] + pub repos: Vec, + /// Whether the grant's token is currently valid (refreshable counts). + #[serde(default)] + pub token_valid: bool, + /// When the grant's user token expires, if known. + #[serde(default)] + pub expires_at: Option>, +} + +impl GrantMetadata { + #[must_use] + pub fn new( + grant_id: String, + login: String, + created_at: Option>, + scopes: Vec, + repos: Vec, + token_valid: bool, + expires_at: Option>, + ) -> Self { + Self { + grant_id, + login, + created_at, + scopes, + repos, + token_valid, + expires_at, + } + } +} + +/// Response for the [`GithubListAuths`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubListAuthsResponse { + /// The stored grants, metadata only. + #[serde(default)] + pub grants: Vec, +} + +impl GithubListAuthsResponse { + #[must_use] + pub fn new(grants: Vec) -> Self { + Self { grants } + } +} + +impl OneshotSshRpc for GithubListAuths { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubListAuths"); + type Request<'a> = GithubListAuthsRequest; + type Response = Errorable; +} + +/// An RPC to forget a stored auth grant (`min github logout`). +pub struct GithubLogout; + +/// Request for the [`GithubLogout`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubLogoutRequest { + /// The grant to forget. The daemon rejects an empty id rather than + /// guessing (fail-closed). + #[serde(default)] + pub grant_id: String, +} + +impl GithubLogoutRequest { + #[must_use] + pub fn new(grant_id: String) -> Self { + Self { grant_id } + } +} + +/// Response for the [`GithubLogout`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubLogoutResponse { + /// Whether a grant was found and removed (`false` = nothing matched). + #[serde(default)] + pub removed: bool, +} + +impl GithubLogoutResponse { + #[must_use] + pub fn new(removed: bool) -> Self { + Self { removed } + } +} + +impl OneshotSshRpc for GithubLogout { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubLogout"); + type Request<'a> = GithubLogoutRequest; + type Response = Errorable; +} + +/// An RPC to pre-prime one or more repos into a session's workspace (R2): +/// clone + checkout-or-create each branch using the daemon-held token. +pub struct GithubPrimeRepos; + +/// Request for the [`GithubPrimeRepos`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubPrimeReposRequest { + /// The session whose workspace receives the primed repos. + #[serde(default = "SessionId::nil")] + pub session_id: SessionId, + /// The repos to prepare, as `owner/repo[@branch[:base]]` strings parsed at + /// the edges via `github::RepoSpec`. + #[serde(default)] + pub repos: Vec, + /// The grant whose token authorizes the clone/fetch. + #[serde(default)] + pub grant_id: String, +} + +impl GithubPrimeReposRequest { + #[must_use] + pub fn new(session_id: SessionId, repos: Vec, grant_id: String) -> Self { + Self { + session_id, + repos, + grant_id, + } + } +} + +/// How a single repo's branch was prepared (R2.2 checkout-or-create), or why +/// it failed. `#[non_exhaustive]` so new outcomes can be added later. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepoPrimeOutcome { + /// The requested branch existed on the remote and was checked out. + CheckedOut, + /// The requested branch did not exist and was created from the base. + Created, + /// Priming this repo failed; `message` is actionable and non-secret and + /// the repo's directory was rolled back (R2.6). + Failed { + #[serde(default)] + message: String, + }, +} + +/// The result of priming a single repo. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepoPrimeResult { + /// The `owner/repo` this result describes. + #[serde(default)] + pub repo: String, + /// The working branch that was prepared. + #[serde(default)] + pub branch: String, + /// The base branch used when the working branch had to be created. + #[serde(default)] + pub base: Option, + /// What happened for this repo. + pub outcome: RepoPrimeOutcome, +} + +impl RepoPrimeResult { + #[must_use] + pub fn new( + repo: String, + branch: String, + base: Option, + outcome: RepoPrimeOutcome, + ) -> Self { + Self { + repo, + branch, + base, + outcome, + } + } +} + +/// Response for the [`GithubPrimeRepos`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubPrimeReposResponse { + /// Per-repo results, in request order. + #[serde(default)] + pub results: Vec, +} + +impl GithubPrimeReposResponse { + #[must_use] + pub fn new(results: Vec) -> Self { + Self { results } + } +} + +impl OneshotSshRpc for GithubPrimeRepos { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubPrimeRepos"); + type Request<'a> = GithubPrimeReposRequest; + type Response = Errorable; +} + +/// An RPC for an explicit, never-automatic push through the daemon (R3.4). +pub struct GithubPush; + +/// Request for the [`GithubPush`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubPushRequest { + /// The session whose workspace holds the repo. + #[serde(default = "SessionId::nil")] + pub session_id: SessionId, + /// The `owner/repo` within the session to push. + #[serde(default)] + pub repo: String, + /// The branch to push; `None` pushes the repo's current branch. + #[serde(default)] + pub branch: Option, +} + +impl GithubPushRequest { + #[must_use] + pub fn new(session_id: SessionId, repo: String, branch: Option) -> Self { + Self { + session_id, + repo, + branch, + } + } +} + +/// Response for the [`GithubPush`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubPushResponse { + /// The `owner/repo` that was pushed. + #[serde(default)] + pub repo: String, + /// The branch that was pushed. + #[serde(default)] + pub branch: String, + /// Whether any refs were updated (`false` = already up to date). + #[serde(default)] + pub pushed: bool, + /// A human-readable, token-scrubbed summary of what git reported. + #[serde(default)] + pub summary: String, +} + +impl GithubPushResponse { + #[must_use] + pub fn new(repo: String, branch: String, pushed: bool, summary: String) -> Self { + Self { + repo, + branch, + pushed, + summary, + } + } +} + +impl OneshotSshRpc for GithubPush { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubPush"); + type Request<'a> = GithubPushRequest; + type Response = Errorable; +} + +/// An RPC to open (or surface an existing) pull request (R4): the daemon +/// creates it with the user token so it is authored by the real user. +pub struct GithubCreatePr; + +/// Request for the [`GithubCreatePr`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubCreatePrRequest { + /// The session whose workspace holds the repo. + #[serde(default = "SessionId::nil")] + pub session_id: SessionId, + /// The `owner/repo` to open the PR against. + #[serde(default)] + pub repo: String, + /// The head branch; `None` uses the repo's current branch. + #[serde(default)] + pub head: Option, + /// The base branch; `None` uses the branch's bound base / repo default. + #[serde(default)] + pub base: Option, + /// The PR title. + #[serde(default)] + pub title: String, + /// The PR body (may be pre-populated from a repo PR template client-side). + #[serde(default)] + pub body: String, + /// Whether to open the PR as a draft. + #[serde(default)] + pub draft: bool, +} + +impl GithubCreatePrRequest { + #[must_use] + pub fn new( + session_id: SessionId, + repo: String, + head: Option, + base: Option, + title: String, + body: String, + draft: bool, + ) -> Self { + Self { + session_id, + repo, + head, + base, + title, + body, + draft, + } + } +} + +/// Response for the [`GithubCreatePr`] RPC: the created-or-existing PR (R4.5). +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GithubCreatePrResponse { + /// The public PR URL (github.com), never credentialed. + #[serde(default)] + pub url: String, + /// The PR number. + #[serde(default)] + pub number: u64, + /// Whether an open PR already existed for this head (surfaced, not + /// duplicated). + #[serde(default)] + pub already_existed: bool, +} + +impl GithubCreatePrResponse { + #[must_use] + pub fn new(url: String, number: u64, already_existed: bool) -> Self { + Self { + url, + number, + already_existed, + } + } +} + +impl OneshotSshRpc for GithubCreatePr { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GithubCreatePr"); + type Request<'a> = GithubCreatePrRequest; + type Response = Errorable; +} + +/// An RPC to read a session's per-repo git state for the exit-PR prompt (R4.6): +/// each repo's branch/base, commits ahead of base, and any existing PR URL. +pub struct GetSessionGitState; + +/// Request for the [`GetSessionGitState`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GetSessionGitStateRequest { + /// The session to inspect. + #[serde(default = "SessionId::nil")] + pub session_id: SessionId, +} + +impl GetSessionGitStateRequest { + #[must_use] + pub fn new(session_id: SessionId) -> Self { + Self { session_id } + } +} + +/// The git state of one repo in a session (R4.6). +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepoGitState { + /// The `owner/repo` this entry describes. + #[serde(default)] + pub repo: String, + /// The current working branch. + #[serde(default)] + pub branch: String, + /// The base branch the working branch tracks, if known. + #[serde(default)] + pub base: Option, + /// Commits on `branch` ahead of `base` — i.e. PR-able work. + #[serde(default)] + pub ahead: u32, + /// The public URL of an already-open PR for this branch, if any (R4.5). + #[serde(default)] + pub existing_pr_url: Option, +} + +impl RepoGitState { + #[must_use] + pub fn new( + repo: String, + branch: String, + base: Option, + ahead: u32, + existing_pr_url: Option, + ) -> Self { + Self { + repo, + branch, + base, + ahead, + existing_pr_url, + } + } +} + +/// Response for the [`GetSessionGitState`] RPC. +#[non_exhaustive] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GetSessionGitStateResponse { + /// Per-repo git state, one entry per primed repo. + #[serde(default)] + pub repos: Vec, +} + +impl GetSessionGitStateResponse { + #[must_use] + pub fn new(repos: Vec) -> Self { + Self { repos } + } +} + +impl OneshotSshRpc for GetSessionGitState { + const NAME: &'static str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "GetSessionGitState"); + type Request<'a> = GetSessionGitStateRequest; + type Response = Errorable; +} + // --------------------------------------------------------------------------- // Diagnostic bundle (`min bug`). // --------------------------------------------------------------------------- @@ -946,4 +1725,343 @@ mod tests { let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains(r#""kind":"pending""#), "got: {json}"); } + + // ----------------------------------------------------------------------- + // GitHub-integrated sessions (spec 10) RPC wire types. + // ----------------------------------------------------------------------- + + fn sid() -> SessionId { + SessionId::parse_str("00000000-0000-0000-0000-000000000001").unwrap() + } + + #[test] + fn github_begin_login_round_trips() { + let req = GithubBeginLoginRequest::new(vec!["contents:write".into()]); + assert_eq!(round_trip(&req), req); + + let resp: Errorable = + Errorable::Ok(GithubBeginLoginResponse::new( + "https://github.com/login/device".into(), + "ABCD-1234".into(), + "login-abc".into(), + 5, + 900, + )); + assert_eq!(round_trip(&resp), resp); + + // The client-id-unset failure surfaces via the `Errorable` wrapper, and + // must survive the wire: every field of `GithubBeginLoginResponse` is + // `#[serde(default)]`, so an `{"error":..}` payload would decode as a + // blank `Ok` if `Errorable` tried `Ok` first (see its declaration). + let err: Errorable = + Err::("GitHub App client id is not configured").into(); + assert_eq!(round_trip(&err), err); + let json = serde_json::to_string(&err).unwrap(); + assert!(json.contains("client id is not configured"), "got: {json}"); + assert!(err.err().is_some()); + } + + /// A daemon-side failure must decode as `Err` even when the response type + /// defaults every field — otherwise `{"error":..}` lands as a blank `Ok` + /// and the client reports success for a refused RPC. + #[test] + fn errorable_decodes_an_error_payload_as_err_not_a_blank_ok() { + let raw = r#"{"error":"grant_id must not be empty"}"#; + let resp: Errorable = serde_json::from_str(raw).expect("deserialize"); + assert_eq!(resp.err().as_deref(), Some("grant_id must not be empty")); + + // The success shape still decodes as `Ok`. + let ok: Errorable = + serde_json::from_str(r#"{"removed":true}"#).expect("deserialize"); + assert!(ok.ok().is_some_and(|r| r.removed)); + } + + /// A daemon that predates the `expires_in_secs` field still decodes. + #[test] + fn github_begin_login_response_accepts_old_shape() { + let raw = serde_json::json!({ + "verification_uri": "https://github.com/login/device", + "user_code": "ABCD-1234", + "login_id": "login-abc", + "poll_interval_secs": 5, + }); + let resp: GithubBeginLoginResponse = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(resp.expires_in_secs, 0); + } + + /// Wholly empty payloads decode to defaults (missing everything). + #[test] + fn github_begin_login_request_accepts_empty() { + let req: GithubBeginLoginRequest = serde_json::from_str("{}").expect("deserialize"); + assert!(req.scopes.is_empty()); + } + + #[test] + fn github_poll_login_states_round_trip() { + for resp in [ + GithubPollLoginResponse::Pending, + GithubPollLoginResponse::Complete { + login: "octocat".into(), + grant_id: "grant-1".into(), + }, + GithubPollLoginResponse::Failed { + message: "access denied".into(), + }, + GithubPollLoginResponse::Expired, + ] { + let wrapped: Errorable = Errorable::Ok(resp.clone()); + assert_eq!(round_trip(&wrapped), wrapped); + } + + let json = serde_json::to_string(&GithubPollLoginResponse::Pending).unwrap(); + assert!(json.contains(r#""kind":"pending""#), "got: {json}"); + } + + /// A `complete` variant emitted before `grant_id` existed still decodes. + #[test] + fn github_poll_complete_accepts_old_shape() { + let raw = serde_json::json!({ "kind": "complete", "login": "octocat" }); + let resp: GithubPollLoginResponse = serde_json::from_value(raw).expect("deserialize"); + match resp { + GithubPollLoginResponse::Complete { login, grant_id } => { + assert_eq!(login, "octocat"); + assert_eq!(grant_id, ""); + } + other => panic!("unexpected variant: {other:?}"), + } + } + + #[test] + fn github_status_round_trips() { + let req = GithubStatusRequest::new(Some(sid()), vec!["octocat/api".into()]); + assert_eq!(round_trip(&req), req); + + let resp: Errorable = Errorable::Ok(GithubStatusResponse::new( + Some(GithubIdentity::new("octocat".into(), "grant-1".into())), + true, + Some(Utc::now()), + vec![RepoInstallationStatus::new( + "octocat/api".into(), + false, + Some("https://github.com/apps/minimal/installations/new".into()), + )], + vec![SessionRepoStatus::new( + "octocat/api".into(), + "feat/x".into(), + Some("main".into()), + vec!["contents:write".into(), "pull_requests:write".into()], + )], + )); + assert_eq!(round_trip(&resp), resp); + } + + /// Old status payloads without the newer collections still parse. + #[test] + fn github_status_response_accepts_old_shape() { + let raw = serde_json::json!({ "token_valid": false }); + let resp: GithubStatusResponse = serde_json::from_value(raw).expect("deserialize"); + assert!(resp.identity.is_none()); + assert!(!resp.token_valid); + assert!(resp.installations.is_empty()); + assert!(resp.session_repos.is_empty()); + assert!(resp.token_expires_at.is_none()); + } + + /// Requesting status without a session id defaults it to `None`. + #[test] + fn github_status_request_accepts_missing_session() { + let req: GithubStatusRequest = serde_json::from_str("{}").expect("deserialize"); + assert!(req.session_id.is_none()); + assert!(req.repos.is_empty()); + } + + #[test] + fn github_list_auths_round_trips() { + let req = GithubListAuthsRequest::new(); + assert_eq!(round_trip(&req), req); + + let resp: Errorable = + Errorable::Ok(GithubListAuthsResponse::new(vec![GrantMetadata::new( + "grant-1".into(), + "octocat".into(), + Some(Utc::now()), + vec!["contents:write".into()], + vec!["octocat/api".into()], + true, + Some(Utc::now()), + )])); + assert_eq!(round_trip(&resp), resp); + } + + /// Grant metadata must carry no token material; a legacy shape with only + /// the identifier and login still decodes. + #[test] + fn grant_metadata_accepts_old_shape() { + let raw = serde_json::json!({ "grant_id": "grant-1", "login": "octocat" }); + let m: GrantMetadata = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(m.grant_id, "grant-1"); + assert!(m.scopes.is_empty()); + assert!(m.repos.is_empty()); + assert!(!m.token_valid); + } + + #[test] + fn github_logout_round_trips() { + let req = GithubLogoutRequest::new("grant-1".into()); + assert_eq!(round_trip(&req), req); + let resp: Errorable = Errorable::Ok(GithubLogoutResponse::new(true)); + assert_eq!(round_trip(&resp), resp); + } + + #[test] + fn github_prime_repos_round_trips() { + let req = GithubPrimeReposRequest::new( + sid(), + vec![ + "octocat/api@feat/x:main".into(), + "octocat/web@feat/x".into(), + ], + "grant-1".into(), + ); + assert_eq!(round_trip(&req), req); + + let resp: Errorable = + Errorable::Ok(GithubPrimeReposResponse::new(vec![ + RepoPrimeResult::new( + "octocat/api".into(), + "feat/x".into(), + Some("main".into()), + RepoPrimeOutcome::Created, + ), + RepoPrimeResult::new( + "octocat/web".into(), + "feat/x".into(), + None, + RepoPrimeOutcome::CheckedOut, + ), + RepoPrimeResult::new( + "octocat/broken".into(), + "feat/x".into(), + None, + RepoPrimeOutcome::Failed { + message: "App not installed on octocat/broken".into(), + }, + ), + ])); + assert_eq!(round_trip(&resp), resp); + } + + /// A prime request that predates `grant_id` still decodes (empty grant). + #[test] + fn github_prime_repos_request_accepts_old_shape() { + let raw = serde_json::json!({ + "session_id": "00000000-0000-0000-0000-000000000001", + "repos": ["octocat/api@feat/x"], + }); + let req: GithubPrimeReposRequest = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(req.session_id, sid()); + assert_eq!(req.grant_id, ""); + } + + #[test] + fn github_push_round_trips() { + let req = GithubPushRequest::new(sid(), "octocat/api".into(), Some("feat/x".into())); + assert_eq!(round_trip(&req), req); + let resp: Errorable = Errorable::Ok(GithubPushResponse::new( + "octocat/api".into(), + "feat/x".into(), + true, + "feat/x -> feat/x (fast-forward)".into(), + )); + assert_eq!(round_trip(&resp), resp); + } + + #[test] + fn github_create_pr_round_trips() { + let req = GithubCreatePrRequest::new( + sid(), + "octocat/api".into(), + Some("feat/x".into()), + Some("main".into()), + "Add feature x".into(), + "Body from template".into(), + true, + ); + assert_eq!(round_trip(&req), req); + let resp: Errorable = Errorable::Ok(GithubCreatePrResponse::new( + "https://github.com/octocat/api/pull/42".into(), + 42, + false, + )); + assert_eq!(round_trip(&resp), resp); + } + + /// An older create-pr request without `draft`/`base` still decodes. + #[test] + fn github_create_pr_request_accepts_old_shape() { + let raw = serde_json::json!({ + "session_id": "00000000-0000-0000-0000-000000000001", + "repo": "octocat/api", + "title": "t", + "body": "b", + }); + let req: GithubCreatePrRequest = serde_json::from_value(raw).expect("deserialize"); + assert!(!req.draft); + assert!(req.base.is_none()); + assert!(req.head.is_none()); + } + + #[test] + fn get_session_git_state_round_trips() { + let req = GetSessionGitStateRequest::new(sid()); + assert_eq!(round_trip(&req), req); + let resp: Errorable = + Errorable::Ok(GetSessionGitStateResponse::new(vec![RepoGitState::new( + "octocat/api".into(), + "feat/x".into(), + Some("main".into()), + 3, + Some("https://github.com/octocat/api/pull/42".into()), + )])); + assert_eq!(round_trip(&resp), resp); + } + + /// Unknown fields are tolerated (no `deny_unknown_fields`), so a newer peer + /// can add fields without breaking an older decoder. + #[test] + fn github_types_tolerate_unknown_fields() { + let raw = serde_json::json!({ + "verification_uri": "https://github.com/login/device", + "user_code": "ABCD-1234", + "login_id": "login-abc", + "poll_interval_secs": 5, + "expires_in_secs": 900, + "some_future_field": {"nested": true}, + }); + let resp: GithubBeginLoginResponse = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(resp.user_code, "ABCD-1234"); + + let raw = serde_json::json!({ + "session_id": "00000000-0000-0000-0000-000000000001", + "repo": "octocat/api", + "ahead_by": 99, + }); + let req: GetSessionGitStateRequest = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(req.session_id, sid()); + } + + /// The RPC subsystem names are stable and carry the shared prefix. A drift + /// here is a wire-compat break. + #[test] + fn github_rpc_names_are_stable() { + assert_eq!(GithubBeginLogin::NAME, "minimald-v1-GithubBeginLogin"); + assert_eq!(GithubPollLogin::NAME, "minimald-v1-GithubPollLogin"); + assert_eq!(GithubStatus::NAME, "minimald-v1-GithubStatus"); + assert_eq!(GithubListAuths::NAME, "minimald-v1-GithubListAuths"); + assert_eq!(GithubLogout::NAME, "minimald-v1-GithubLogout"); + assert_eq!(GithubPrimeRepos::NAME, "minimald-v1-GithubPrimeRepos"); + assert_eq!(GithubPush::NAME, "minimald-v1-GithubPush"); + assert_eq!(GithubCreatePr::NAME, "minimald-v1-GithubCreatePr"); + assert_eq!(GetSessionGitState::NAME, "minimald-v1-GetSessionGitState"); + } } diff --git a/crates/minimald/Cargo.toml b/crates/minimald/Cargo.toml index 8f11ef912..a6aa0126f 100644 --- a/crates/minimald/Cargo.toml +++ b/crates/minimald/Cargo.toml @@ -57,6 +57,7 @@ tracing.workspace = true tracing-appender.workspace = true mlog.workspace = true tracing-subscriber.workspace = true +url.workspace = true # WireGuard mesh peer (Unit 4, R4.1/R4.7): compiled only under `networking-wg`. base64 = { workspace = true, optional = true } @@ -68,6 +69,10 @@ async-dialog.workspace = true check.workspace = true common.workspace = true diagnostics.workspace = true +# `client` pulls in the device-flow/refresh/REST HTTP stack (spec 10); the +# default (pure-types) feature set alone can't drive GitHub, and this is the +# daemon — the one process that is allowed to hold a token. +github = { workspace = true, features = ["client"] } graph.workspace = true mctx.workspace = true mfile.workspace = true diff --git a/crates/minimald/src/env.rs b/crates/minimald/src/env.rs index ca709baaa..25cff28cb 100644 --- a/crates/minimald/src/env.rs +++ b/crates/minimald/src/env.rs @@ -34,6 +34,7 @@ use std::sync::{Arc, Mutex}; use std::task::Poll; use camino::{Utf8Path, Utf8PathBuf}; +use github::facade::VERB_GIT; use graph::{BuildSpecRef, Graph, SetupForPackages, Transitives}; use mctx::{AddDepMode, Context, Error}; use mfile::{EnvPatches, EnvVarValue}; @@ -47,6 +48,8 @@ use tempfile::TempDir; use tokio::sync::mpsc; use tokio::task::{JoinHandle, spawn_blocking}; +use crate::github::facade::{NO_GITHUB_AUTH, SessionGithub}; + /// The min helper script installed at `/usr/bin/min` inside the sandbox. const MIN_SCRIPT: &str = include_str!("env_min_helper.sh"); @@ -82,6 +85,10 @@ pub struct EnvArgs { network_mode: NetworkMode, own_ip_tap: Option, own_ip_dns: Option, + /// The session's GitHub facade state (spec R3), enabling the in-sandbox + /// `min git` verb on the command channel. `None` — the default — makes + /// every `git%` request fail closed with [`NO_GITHUB_AUTH`]. + github: Option, /// Weak handle to the owning session actor, wired into the command channel /// so in-sandbox `min` commands can drive session side-ops (e.g. builds). /// Every session env has one — this `Env` is always session-scoped. @@ -120,11 +127,25 @@ impl EnvArgs { network_mode: NetworkMode::HostNet, own_ip_tap: None, own_ip_dns: None, + github: None, session, include_package_attr_wiring: true, } } + /// Wires the session's GitHub facade (spec R3): the daemon-held pieces + /// that let the channel actor authorize and run in-sandbox `min git` + /// requests. Without this, every `git%` request is refused. + // Not yet called by the production launcher — the `session_host` wiring + // lands with the activation tasks of the spec-10 DAG; the channel tests + // below are the usage proof in the meantime (mirrors `github::authz`). + #[allow(dead_code)] + #[must_use] + pub(crate) fn with_github(mut self, github: SessionGithub) -> Self { + self.github = Some(github); + self + } + /// Opts out of consuming `env_vars` / `fs_mappings` from /// `SetupForPackages`. Callers on this path must funnel package /// contributions through `.with_resolved_env_vars` / @@ -384,6 +405,7 @@ impl Env { home: args.home.clone(), has_packages: transitives.keys().copied().collect(), ot: args.ot.clone(), + github: args.github, session: args.session.clone(), ctx, graph, @@ -519,6 +541,11 @@ struct SessionChannel { /// Packages already materialized into the rootfs. has_packages: HashSet, ot: Option, + /// The session's GitHub facade state (spec R3). `None` fails every `git%` + /// request closed. Because this actor is aborted when the [`Env`] — and + /// with it the sandbox — is dropped, facade access is bound to the + /// sandbox lifetime with no extra mechanism (spec R3.5/R6.3). + github: Option, /// Weak handle to the owning session actor, used by session-scoped commands /// (e.g. `min build`) to drive side-ops. session: crate::session::WeakSessionHandle, @@ -596,6 +623,10 @@ impl SessionChannel { self.run_task(stream, name, rest).await; None } + Some((VERB_GIT, argv)) => { + self.git_facade(argv, stream).await; + None + } Some(("build", args)) => { self.run_build(stream, args).await; None @@ -916,6 +947,20 @@ impl SessionChannel { } } + /// Implements the in-sandbox `min git` facade verb (spec R3.1): proxies + /// the request to the daemon-held GitHub state, which authorizes it and + /// runs the authenticated operation in the session's workspace. A session + /// with no GitHub facade wired is refused fail-closed — the sandbox never + /// holds a credential either way (spec G5/R6.1). + async fn git_facade(&self, argv: &str, stream: &mut UnixStream) { + match &self.github { + Some(github) => github.handle_git(argv, &self.working, stream).await, + None => { + let _ = writeln!(stream, "error: {NO_GITHUB_AUTH}"); + } + } + } + /// Implements `min build [--verbose] [--rebuild] pkgs...`: kicks off /// a session side-op build and streams its progress back to the client /// until the build completes. @@ -1409,6 +1454,7 @@ mod tests { .unwrap(), has_packages: HashSet::new(), ot: None, + github: None, session: crate::session::WeakSessionHandle::dangling(), ctx, graph, @@ -1827,4 +1873,1292 @@ mod tests { let env = HashMap::from([("WEIRD".to_string(), "/state/foo/bar".to_string())]); assert!(state_dirs_from_env_vars(&env).is_empty()); } + + /// Channel-level tests for the `git%` facade verb (spec R3): drive real + /// `method%data` lines through [`SessionChannel::handle`] against + /// `file://` fixture remotes, with the session record held in a real + /// store actor so the facade's live-record reads are exercised. + mod git_facade { + use std::collections::BTreeMap; + use std::path::Path; + use std::process::{Command, Stdio}; + + use github::attrs::GithubAttrs; + use github::{GrantId, RepoSpec, ScopeSet}; + use paths::HostAbsPath; + use sessions::{NetworkMode, Record, SessionId, SessionPolicy, SessionStatus}; + use url::Url; + + use super::*; + use crate::store::{SessionRecordHandle, Store}; + + /// A token planted in the facade for every test; nothing in any + /// streamed line may ever contain it. + const TEST_TOKEN: &str = "ghu_channel_test_secret_0xF00"; + + /// Runs `git` with a fixed identity, asserting success — fixture + /// plumbing only, never the code under test. + fn git_run(args: &[&str], cwd: &Path) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "tester") + .env("GIT_AUTHOR_EMAIL", "tester@example.com") + .env("GIT_COMMITTER_NAME", "tester") + .env("GIT_COMMITTER_EMAIL", "tester@example.com") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed in {cwd:?}"); + } + + /// Creates a bare fixture remote for `owner/repo` under + /// `//.git` with one commit on `main`, laid out + /// exactly where the facade's `//.git` + /// derivation will look for it. + fn make_bare_remote(remotes: &Path, owner: &str, repo: &str) { + let seed = remotes.join(format!(".seed-{owner}-{repo}")); + std::fs::create_dir_all(&seed).expect("seed dir"); + git_run(&["init", "-q", "-b", "main", "."], &seed); + std::fs::write(seed.join("README.md"), b"seed\n").expect("write readme"); + git_run(&["add", "README.md"], &seed); + git_run(&["commit", "-q", "-m", "initial"], &seed); + + let bare = remotes.join(owner).join(format!("{repo}.git")); + std::fs::create_dir_all(bare.parent().unwrap()).expect("owner dir"); + git_run( + &[ + "clone", + "-q", + "--bare", + seed.to_str().expect("utf-8"), + bare.to_str().expect("utf-8"), + ], + remotes, + ); + } + + /// The `file://` git base whose `/.git` join lands on + /// the fixtures created by [`make_bare_remote`]. + fn git_base(remotes: &Path) -> Url { + Url::parse(&format!("file://{}/", remotes.display())).expect("fixture base url") + } + + /// Spawns a store actor holding one record with the given GitHub + /// attrs, returning the live handle the facade will re-read from. + async fn seeded_record(state: &Path, attrs: Option<&GithubAttrs>) -> SessionRecordHandle { + let store = Store::init( + DaemonAbsPath::try_new(Utf8PathBuf::try_from(state.to_path_buf()).unwrap()) + .unwrap(), + ) + .await + .expect("store init"); + let mut record_attrs = BTreeMap::new(); + if let Some(attrs) = attrs { + attrs.encode_into(&mut record_attrs); + } + store + .create(Record { + id: SessionId::nil(), + name: Some("facade-test".to_string()), + username: None, + project_path: HostAbsPath::try_new("/tmp/facade-test-project").unwrap(), + network: NetworkMode::default(), + policy: SessionPolicy::default(), + status: SessionStatus::default(), + attrs: record_attrs, + }) + .await + .expect("create record") + } + + /// GitHub attrs binding a grant, declaring `repos`, with the default + /// scope set. + fn bound_attrs(repos: &[&str]) -> GithubAttrs { + GithubAttrs { + grant_id: Some(GrantId::new("grant-1").unwrap()), + repos: repos + .iter() + .map(|r| r.parse::().unwrap()) + .collect(), + scopes: Some(ScopeSet::defaults()), + } + } + + /// Drives one request line through the channel and collects the + /// response lines. + async fn drive(chan: &mut SessionChannel, line: &str) -> Vec { + let (mut ours, theirs) = UnixStream::pair().unwrap(); + chan.handle(line, &mut ours).await; + drop(ours); + read_lines(&theirs) + } + + fn assert_no_error(lines: &[String]) { + assert!( + !lines.iter().any(|l| l.starts_with("error:")), + "expected success, got: {lines:?}" + ); + } + + fn error_line(lines: &[String]) -> &str { + lines + .iter() + .find(|l| l.starts_with("error:")) + .unwrap_or_else(|| panic!("expected an error: line, got: {lines:?}")) + } + + /// No streamed line — success or failure — may carry token bytes. + fn assert_no_token(lines: &[String]) { + for line in lines { + assert!( + !line.contains(TEST_TOKEN), + "token bytes leaked to the sandbox: {line}" + ); + } + } + + /// The workspace root for a github fixture channel. It lives one level + /// under `fixtures` so that its *parent* — where the facade places its + /// daemon-only `.gh-mirror` — is unique per test (no cross-test mirror + /// collision) and, in production, is the session's own state dir. + fn workspace_dir(fixtures: &Path) -> std::path::PathBuf { + fixtures.join("workspace") + } + + /// The primed `octo/hello` working tree inside the fixture workspace. + fn worktree_dir(fixtures: &Path) -> std::path::PathBuf { + workspace_dir(fixtures).join("hello") + } + + /// Full fixture: bare remote for `octo/hello`, a primed working + /// clone in the channel's workspace on branch `feat/x` with one + /// unpushed commit, and a channel whose facade is wired to a live + /// record carrying `attrs`. + async fn github_channel( + attrs: Option<&GithubAttrs>, + ) -> (TempDir, TempDir, TempDir, TempDir, SessionChannel) { + let (state, rootfs, cwd, mut chan) = setup_channel(); + let fixtures = tempdir().unwrap(); + let remotes = fixtures.path().join("remotes"); + std::fs::create_dir_all(&remotes).unwrap(); + make_bare_remote(&remotes, "octo", "hello"); + + // Prime the workspace clone the way activation would: cloned from + // the *derived* URL, then a local branch with unpushed work. The + // sandbox may later rewrite this tree's `origin`; the facade never + // reads it on the token-bearing leg (see the facade module docs). + let base = git_base(&remotes); + let clone_url = base.join("octo/hello.git").unwrap(); + let workspace = workspace_dir(fixtures.path()); + std::fs::create_dir_all(&workspace).unwrap(); + let work = worktree_dir(fixtures.path()); + git_run( + &[ + "clone", + "-q", + clone_url.as_str(), + work.to_str().expect("utf-8"), + ], + fixtures.path(), + ); + git_run(&["checkout", "-q", "-b", "feat/x"], &work); + std::fs::write(work.join("work.txt"), b"session work\n").unwrap(); + git_run(&["add", "work.txt"], &work); + git_run(&["commit", "-q", "-m", "session work"], &work); + + // Point the channel's workspace at the fixture workspace root so + // the facade primes/mirrors under `fixtures/` (unique per test). + chan.working = DaemonAbsPath::try_new( + Utf8PathBuf::try_from(workspace).expect("utf-8 workspace path"), + ) + .expect("absolute workspace path"); + + let record = seeded_record(fixtures.path(), attrs).await; + chan.github = Some(SessionGithub::for_tests(TEST_TOKEN, base, record)); + (state, rootfs, cwd, fixtures, chan) + } + + /// The bare fixture remote for `octo/hello` under `fixtures`. + fn bare_repo(fixtures: &Path) -> std::path::PathBuf { + fixtures.join("remotes").join("octo").join("hello.git") + } + + /// The bare fixture remote for `owner/repo` under `fixtures`. + fn bare_repo_named(fixtures: &Path, owner: &str, repo: &str) -> std::path::PathBuf { + fixtures + .join("remotes") + .join(owner) + .join(format!("{repo}.git")) + } + + /// Whether the fixture remote has a branch named `branch`. + fn remote_has_branch(fixtures: &Path, branch: &str) -> bool { + Command::new("git") + .args([ + "--git-dir", + bare_repo(fixtures).to_str().unwrap(), + "show-ref", + "--verify", + &format!("refs/heads/{branch}"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("show-ref") + .success() + } + + /// The core success arc: an authorized `git%push` streams `msg:` + /// lines, lands the branch on the remote, and leaks no token bytes. + #[tokio::test] + async fn authorized_push_succeeds_and_streams() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + assert!(!remote_has_branch(fixtures.path(), "feat/x")); + + let lines = drive(&mut chan, "git%push").await; + + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + lines + .iter() + .any(|l| l.starts_with("msg:") && l.contains("pushed `feat/x`")), + "expected a streamed push summary, got: {lines:?}" + ); + assert!( + remote_has_branch(fixtures.path(), "feat/x"), + "the push must actually land on the remote" + ); + } + + /// An explicit selector naming the (single) declared repo works too. + #[tokio::test] + async fn push_with_matching_selector_succeeds() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%push octo/hello").await; + + assert_no_error(&lines); + assert!(remote_has_branch(fixtures.path(), "feat/x")); + } + + /// A repo outside the session's declared allow-list is denied by the + /// authz choke point, before any git process runs. + #[tokio::test] + async fn undeclared_repo_is_denied() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%push evil/other").await; + + let err = error_line(&lines); + assert!( + err.contains("repo evil/other not declared for this session"), + "expected the canonical RepoNotDeclared denial: {err}" + ); + assert!(!remote_has_branch(fixtures.path(), "feat/x")); + } + + /// A subcommand outside the allowlist is rejected by the argv gate. + #[tokio::test] + async fn disallowed_subcommand_is_denied() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, _fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%rev-parse HEAD").await; + assert!( + error_line(&lines).contains("not available through `min git`"), + "got: {lines:?}" + ); + } + + /// An allowlisted subcommand with a smuggled option is rejected — + /// and nothing reaches the remote. + #[tokio::test] + async fn smuggled_option_is_denied() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + for argv in [ + "git%push --force", + "git%-c credential.helper=evil push", + "git%fetch --upload-pack=/tmp/evil", + "git%--git-dir=/tmp/elsewhere push", + ] { + let lines = drive(&mut chan, argv).await; + assert!( + error_line(&lines).contains("not permitted through `min git`"), + "{argv} must be rejected: {lines:?}" + ); + } + assert!(!remote_has_branch(fixtures.path(), "feat/x")); + } + + /// A session whose record carries no GitHub attrs is refused with + /// the canonical no-auth message. + #[tokio::test] + async fn session_without_github_attrs_is_refused() { + let (_state, _rootfs, _cwd, _fixtures, mut chan) = github_channel(None).await; + + let lines = drive(&mut chan, "git%push").await; + assert!( + error_line(&lines).contains("this session has no GitHub authentication"), + "got: {lines:?}" + ); + } + + /// A channel with no facade wired at all (every session today) is + /// refused with the same message — fail closed, byte-compatible. + #[tokio::test] + async fn unwired_channel_is_refused() { + let (_state, _rootfs, _cwd, mut chan) = setup_channel(); + + let lines = drive(&mut chan, "git%push").await; + assert_eq!( + error_line(&lines), + "error: this session has no GitHub authentication" + ); + } + + /// Insufficient scope (contents:read only) denies a push per the + /// authz choke point. + #[tokio::test] + async fn missing_scope_denies_push() { + let mut attrs = bound_attrs(&["octo/hello@feat/x:main"]); + attrs.scopes = + Some(ScopeSet::empty().with(github::Scope::Contents, github::Permission::Read)); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%push").await; + assert!( + error_line(&lines).contains("contents:rw"), + "the denial must name the missing permission: {lines:?}" + ); + assert!(!remote_has_branch(fixtures.path(), "feat/x")); + } + + /// Once the session record is deleted (destroy), the facade + /// authorizes nothing (spec R3.5/R6.3). + #[tokio::test] + async fn deleted_record_ends_facade_access() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + // Delete through a clone of the same live handle. + if let Some(github) = &chan.github { + github.record_handle_for_tests().delete().await.unwrap(); + } + + let lines = drive(&mut chan, "git%push").await; + assert!( + error_line(&lines).contains("session record unavailable"), + "a destroyed session must not authorize: {lines:?}" + ); + assert!(!remote_has_branch(fixtures.path(), "feat/x")); + } + + /// A sandbox-rewritten `origin` does **not** redirect the token-bearing + /// push: the credentialed leg ignores the worktree config entirely and + /// pushes to the daemon-derived canonical remote, so the push still + /// lands there (and never contacts the attacker URL). + #[tokio::test] + async fn tampered_origin_does_not_redirect_the_push() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let work = worktree_dir(fixtures.path()); + git_run( + &[ + "remote", + "set-url", + "origin", + "https://evil.example/steal.git", + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + remote_has_branch(fixtures.path(), "feat/x"), + "the push must reach the canonical remote, not the tampered origin: {lines:?}" + ); + } + + /// Whether the bare repo at `bare` has a branch named `branch`. + fn bare_has_branch(bare: &Path, branch: &str) -> bool { + Command::new("git") + .args([ + "--git-dir", + bare.to_str().unwrap(), + "show-ref", + "--verify", + &format!("refs/heads/{branch}"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("show-ref") + .success() + } + + /// Asserts the outcome shared by every exfil test: the push landed on + /// the canonical remote, no token bytes leaked to the sandbox, and the + /// attacker remote received nothing. + fn assert_push_only_reached_canonical(fixtures: &Path, lines: &[String], attacker: &Path) { + assert_no_error(lines); + assert_no_token(lines); + assert!( + remote_has_branch(fixtures, "feat/x"), + "the push must reach the canonical remote: {lines:?}" + ); + assert!( + !bare_has_branch(attacker, "feat/x"), + "nothing may reach the attacker remote: {lines:?}" + ); + } + + /// No file anywhere under the daemon-only mirror root or the sandbox + /// worktree may ever contain token bytes (the env-only injection + /// invariant, proven end-to-end through the mirror machinery). + fn assert_no_token_on_disk(fixtures: &Path) { + fn scan(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.file_type().is_symlink() { + continue; + } + if meta.is_dir() { + scan(&path); + } else if meta.is_file() { + let bytes = std::fs::read(&path).unwrap_or_default(); + assert!( + !bytes + .windows(TEST_TOKEN.len()) + .any(|w| w == TEST_TOKEN.as_bytes()), + "token bytes found on disk in {}", + path.display() + ); + } + } + } + scan(&workspace_dir(fixtures)); + scan(&fixtures.join(".gh-mirror")); + } + + /// A `pushurl` (git's real push target) planted in the worktree config + /// does not redirect the token leg: the push reaches the canonical + /// remote and the attacker pushurl receives nothing. + #[tokio::test] + async fn pushurl_redirect_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + let attacker_url = format!("file://{}", attacker.display()); + + let work = worktree_dir(fixtures.path()); + git_run( + &["remote", "set-url", "--push", "origin", &attacker_url], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + assert_no_token_on_disk(fixtures.path()); + } + + /// A second `remote.origin.url` value (multi-valued `url`) gives git a + /// second target in the worktree config; the token leg ignores it. + #[tokio::test] + async fn extra_origin_url_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + let attacker_url = format!("file://{}", attacker.display()); + + let work = worktree_dir(fixtures.path()); + git_run( + &["config", "--add", "remote.origin.url", &attacker_url], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + } + + /// An `url..insteadOf` rewrite retargets even a byte-identical + /// origin url — but only for a git that reads the worktree config, which + /// the token leg never does. The push reaches the canonical remote. + #[tokio::test] + async fn insteadof_rewrite_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + + let work = worktree_dir(fixtures.path()); + let remotes = fixtures.path().join("remotes"); + // Rewrite the canonical base prefix to the attacker's repo. + git_run( + &[ + "config", + &format!("url.file://{}/.insteadOf", attacker.display()), + &format!("file://{}/octo/hello.git", remotes.display()), + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + } + + /// A `pushInsteadOf` rewrite (push-only redirect) planted in the + /// worktree config is likewise never consulted on the token leg. + #[tokio::test] + async fn push_insteadof_rewrite_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + + let work = worktree_dir(fixtures.path()); + let remotes = fixtures.path().join("remotes"); + git_run( + &[ + "config", + &format!("url.file://{}/.pushInsteadOf", attacker.display()), + &format!("file://{}/octo/hello.git", remotes.display()), + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + } + + /// A `[include]` directive in the worktree config — which pulls in an + /// arbitrary attacker-authored file that redirects `origin` — cannot + /// steer the token leg, which never reads the worktree config. + #[tokio::test] + async fn include_directive_redirect_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + let attacker_url = format!("file://{}", attacker.display()); + + // An attacker-authored config the worktree pulls in via `[include]`. + let evil_cfg = fixtures.path().join("evil.gitconfig"); + std::fs::write( + &evil_cfg, + format!( + "[remote \"origin\"]\n\turl = {attacker_url}\n\tpushurl = {attacker_url}\n" + ), + ) + .unwrap(); + + let work = worktree_dir(fixtures.path()); + git_run( + &["config", "include.path", evil_cfg.to_str().expect("utf-8")], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + } + + /// An `[includeIf "gitdir:…"]` directive (resolved only at git runtime, + /// so unparseable ahead of time) is equally powerless against the token + /// leg's config isolation. + #[tokio::test] + async fn includeif_directive_redirect_does_not_reach_attacker() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + make_bare_remote(&fixtures.path().join("remotes"), "attacker", "steal"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "steal"); + let attacker_url = format!("file://{}", attacker.display()); + + let evil_cfg = fixtures.path().join("evil-if.gitconfig"); + std::fs::write( + &evil_cfg, + format!( + "[remote \"origin\"]\n\turl = {attacker_url}\n\tpushurl = {attacker_url}\n" + ), + ) + .unwrap(); + + let work = worktree_dir(fixtures.path()); + // Match any gitdir so the include always fires for this worktree. + git_run( + &[ + "config", + "includeIf.gitdir:/.path", + evil_cfg.to_str().expect("utf-8"), + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_push_only_reached_canonical(fixtures.path(), &lines, &attacker); + } + + /// A malicious `credential.helper` in the worktree config is never + /// invoked: no token-bearing git ever reads the worktree config, and + /// the local legs never authenticate. The helper would create a sink + /// file if it fired; it must not exist afterwards. + #[tokio::test] + async fn worktree_credential_helper_never_fires() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let sink = fixtures.path().join("cred-helper-fired"); + let work = worktree_dir(fixtures.path()); + git_run( + &[ + "config", + "credential.helper", + &format!("!f() {{ echo fired > {}; }}; f", sink.display()), + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + remote_has_branch(fixtures.path(), "feat/x"), + "the push must still succeed to the canonical remote: {lines:?}" + ); + assert!( + !sink.exists(), + "the worktree credential.helper must never be invoked" + ); + } + + /// An `http.extraHeader` planted in the worktree config (a header-splice + /// vector) never reaches the token leg's git, which reads only the + /// daemon-authored mirror config; the push still reaches the canonical + /// remote and no token leaks. + #[tokio::test] + async fn http_extraheader_in_worktree_is_ignored() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let work = worktree_dir(fixtures.path()); + git_run( + &[ + "config", + "http.https://github.com/.extraHeader", + "Authorization: Basic ZXZpbA==", + ], + &work, + ); + + let lines = drive(&mut chan, "git%push").await; + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + remote_has_branch(fixtures.path(), "feat/x"), + "the push must reach the canonical remote: {lines:?}" + ); + } + + /// Writes an executable sink script at `path` that touches `sink` when + /// run, so a test can detect whether any daemon git executed a hostile + /// worktree config directive (the daemon-RCE the rework closes). + fn write_sink_script(path: &Path, sink: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::write( + path, + format!("#!/bin/sh\necho fired > {}\n", sink.display()), + ) + .expect("write sink script"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("chmod sink script"); + } + + /// Drives an authorized `git%push` with a hostile directive planted in + /// the worktree config by `plant`, and asserts the facade neither + /// executed the directive (the sink never appears) nor leaked the + /// token: the push still reaches only the canonical remote and no token + /// bytes touch the sandbox or disk. This is the daemon-RCE / token-exfil + /// regression — the fixed facade never lets a daemon git read the + /// sandbox-writable worktree config, where `core.fsmonitor`, + /// `core.sshCommand`, `filter.*.process`, hooks, and `[include]`d files + /// would otherwise run arbitrary code as the daemon. + async fn assert_hostile_worktree_config_is_inert(plant: impl FnOnce(&Path, &Path)) { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let work = worktree_dir(fixtures.path()); + let sink = fixtures.path().join("daemon-rce-fired"); + plant(&work, &sink); + + let lines = drive(&mut chan, "git%push").await; + + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + remote_has_branch(fixtures.path(), "feat/x"), + "the push must still reach the canonical remote: {lines:?}" + ); + assert!( + !sink.exists(), + "a daemon git executed sandbox-writable worktree config (RCE): {lines:?}" + ); + assert_no_token_on_disk(fixtures.path()); + } + + /// A `core.fsmonitor` hook (which git runs to service any index refresh) + /// planted in the worktree config is never executed by the daemon: no + /// daemon git reads the worktree config or touches its index. + #[tokio::test] + async fn worktree_core_fsmonitor_never_runs_as_the_daemon() { + assert_hostile_worktree_config_is_inert(|work, sink| { + let script = work.parent().unwrap().join("fsmon.sh"); + write_sink_script(&script, sink); + git_run( + &["config", "core.fsmonitor", script.to_str().unwrap()], + work, + ); + }) + .await; + } + + /// A `core.sshCommand` planted in the worktree config (git would run it + /// as the ssh transport) never fires: the credentialed leg runs only in + /// the daemon-authored mirror and never reads the worktree config. + #[tokio::test] + async fn worktree_core_sshcommand_never_runs_as_the_daemon() { + assert_hostile_worktree_config_is_inert(|work, sink| { + let script = work.parent().unwrap().join("ssh.sh"); + write_sink_script(&script, sink); + git_run( + &["config", "core.sshCommand", script.to_str().unwrap()], + work, + ); + }) + .await; + } + + /// A `filter..process`/`smudge` driver assigned via `.gitattributes` + /// (git runs it on checkout) planted in the worktree config never fires: + /// the daemon never checks out in the sandbox-writable tree. + #[tokio::test] + async fn worktree_filter_process_never_runs_as_the_daemon() { + assert_hostile_worktree_config_is_inert(|work, sink| { + let script = work.parent().unwrap().join("filter.sh"); + write_sink_script(&script, sink); + git_run( + &["config", "filter.evil.process", script.to_str().unwrap()], + work, + ); + git_run( + &["config", "filter.evil.smudge", script.to_str().unwrap()], + work, + ); + std::fs::write(work.join(".gitattributes"), "* filter=evil\n").unwrap(); + }) + .await; + } + + /// An `[include]` directive that pulls in an attacker-authored file + /// setting `core.fsmonitor` — the include is resolved only at git + /// runtime, defeating any config-text scanner — is equally powerless: + /// no daemon git reads the worktree config at all, so the include is + /// never followed and the hook never runs. + #[tokio::test] + async fn worktree_include_setting_fsmonitor_never_runs_as_the_daemon() { + assert_hostile_worktree_config_is_inert(|work, sink| { + let script = work.parent().unwrap().join("inc-fsmon.sh"); + write_sink_script(&script, sink); + let evil_cfg = work.parent().unwrap().join("evil-exec.gitconfig"); + std::fs::write( + &evil_cfg, + format!("[core]\n\tfsmonitor = {}\n", script.display()), + ) + .unwrap(); + git_run( + &["config", "include.path", evil_cfg.to_str().unwrap()], + work, + ); + }) + .await; + } + + /// No verb — push, fetch, or status — lets a planted `core.fsmonitor` + /// run as the daemon, because the daemon runs git only in the mirror and + /// moves objects/refs by alternate + plain files. + #[tokio::test] + async fn no_verb_lets_worktree_fsmonitor_run_as_the_daemon() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let work = worktree_dir(fixtures.path()); + let sink = fixtures.path().join("fsmon-any-verb-fired"); + let script = fixtures.path().join("fsmon-any.sh"); + write_sink_script(&script, &sink); + git_run( + &["config", "core.fsmonitor", script.to_str().unwrap()], + &work, + ); + + for verb in ["git%push", "git%fetch", "git%status"] { + let lines = drive(&mut chan, verb).await; + assert_no_token(&lines); + assert!( + !sink.exists(), + "{verb} executed the worktree fsmonitor as the daemon (RCE): {lines:?}" + ); + } + } + + /// A dash-leading current branch (planted via low-level ref surgery, + /// which git's porcelain resists) is refused before `git push` could + /// parse it as an option — the argv-injection hardening. + #[tokio::test] + async fn dash_leading_branch_is_refused() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let work = worktree_dir(fixtures.path()); + git_run(&["update-ref", "refs/heads/-oEvil", "HEAD"], &work); + git_run(&["symbolic-ref", "HEAD", "refs/heads/-oEvil"], &work); + + let lines = drive(&mut chan, "git%push").await; + assert!( + error_line(&lines).contains("unsafe name"), + "a dash-leading branch must be refused: {lines:?}" + ); + assert!(!remote_has_branch(fixtures.path(), "-oEvil")); + } + + /// `status` and `remote -v` work without contacting the remote and + /// stream `msg:` lines. + #[tokio::test] + async fn local_only_verbs_report_without_a_remote() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, _fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%status").await; + assert_no_error(&lines); + assert!( + lines.iter().any(|l| l.contains("on branch `feat/x`")), + "got: {lines:?}" + ); + assert!( + lines.iter().any(|l| l.contains("no upstream")), + "an unpushed branch must report no upstream: {lines:?}" + ); + + let lines = drive(&mut chan, "git%remote -v").await; + assert_no_error(&lines); + assert!( + lines + .iter() + .any(|l| l.starts_with("msg:origin\t") && l.contains("octo/hello.git")), + "got: {lines:?}" + ); + assert_no_token(&lines); + } + + /// A declared-but-unprimed repo gets an actionable error naming the + /// fix. + #[tokio::test] + async fn unprimed_repo_is_actionable() { + // Declare a second repo that has no working tree yet; naming it + // makes the facade look for its primed dir. + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second"]); + let (_state, _rootfs, _cwd, _fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%push octo/second").await; + assert!( + error_line(&lines).contains("not primed in this session's workspace"), + "got: {lines:?}" + ); + } + + /// With several repos declared, an unselected operation must name + /// one. + #[tokio::test] + async fn multi_repo_requires_a_selector() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second"]); + let (_state, _rootfs, _cwd, _fixtures, mut chan) = github_channel(Some(&attrs)).await; + + let lines = drive(&mut chan, "git%push").await; + assert!( + error_line(&lines).contains("name one explicitly"), + "got: {lines:?}" + ); + } + + /// `clone` of a declared repo lands it in the workspace via the + /// daemon-private mirror and checks out its declared branch (created + /// from base — never pushed). The finished tree presents the clean + /// canonical URL as `origin`, and no token bytes ever touch the disk. + #[tokio::test] + async fn clone_of_declared_repo_primes_the_workspace() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second@feat/y"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let remotes = fixtures.path().join("remotes"); + make_bare_remote(&remotes, "octo", "second"); + + let lines = drive(&mut chan, "git%clone octo/second").await; + + assert_no_error(&lines); + assert_no_token(&lines); + let work = workspace_dir(fixtures.path()).join("second"); + assert!( + work.join(".git").exists(), + "clone must land in the workspace" + ); + assert!( + lines.iter().any(|l| l.contains("created branch `feat/y`")), + "got: {lines:?}" + ); + assert_eq!(current_branch_of(&work), "feat/y"); + // The clone was routed through the daemon-private mirror (the + // isolation regression: no token-bearing git may ever run in the + // sandbox-writable destination, so the network work happens here). + assert!( + fixtures + .path() + .join(".gh-mirror") + .join("octo__second.git") + .join("HEAD") + .exists(), + "the clone must route through the daemon-private mirror" + ); + // The worktree's origin is the clean canonical URL (R6.1) … + assert_eq!( + origin_url_of(&work), + git_base(&remotes).join("octo/second.git").unwrap().as_str() + ); + // … and neither the workspace nor the mirror holds token bytes. + assert_no_token_on_disk(fixtures.path()); + // R2.5: branch creation must not push. + assert!( + !Command::new("git") + .args([ + "--git-dir", + fixtures + .path() + .join("remotes") + .join("octo") + .join("second.git") + .to_str() + .unwrap(), + "show-ref", + "--verify", + "refs/heads/feat/y", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + ); + } + + /// `clone` of a declared branch that already exists on the remote + /// checks it out as a tracking branch (the checkout-not-create arc), + /// with the network work done in the mirror, not the worktree. + #[tokio::test] + async fn clone_checks_out_existing_remote_branch_as_tracking() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second@feat/y"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let remotes = fixtures.path().join("remotes"); + make_bare_remote(&remotes, "octo", "second"); + let bare = bare_repo_named(fixtures.path(), "octo", "second"); + git_run(&["branch", "feat/y", "main"], &bare); + + let lines = drive(&mut chan, "git%clone octo/second").await; + + assert_no_error(&lines); + assert_no_token(&lines); + assert!( + lines + .iter() + .any(|l| l.contains("checked out branch `feat/y`")), + "an existing remote branch must be checked out, not created: {lines:?}" + ); + let work = workspace_dir(fixtures.path()).join("second"); + assert_eq!(current_branch_of(&work), "feat/y"); + assert_eq!( + upstream_of(&work, "feat/y"), + "origin/feat/y", + "an existing remote branch must come out tracking its origin ref" + ); + } + + /// The clone-leg isolation regression (the reviewed TOCTOU): even a + /// pre-tampered mirror — origin rewritten to an attacker remote, as if + /// a prior compromise had persisted state — is re-authored by the + /// daemon before the credentialed fetch, so the clone's token leg + /// contacts only the canonical remote and the worktree contains only + /// canonical content. + #[tokio::test] + async fn pre_tampered_mirror_is_reauthored_before_the_token_leg() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second@feat/y"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let remotes = fixtures.path().join("remotes"); + make_bare_remote(&remotes, "octo", "second"); + make_bare_remote(&remotes, "attacker", "second"); + let attacker = bare_repo_named(fixtures.path(), "attacker", "second"); + // Distinguish the attacker's content from the canonical seed. + let attacker_tip = advance_main_of(fixtures.path(), &attacker, "stolen.txt"); + + // Plant the mirror ahead of time, origin pointed at the attacker. + let mirror = fixtures.path().join(".gh-mirror").join("octo__second.git"); + std::fs::create_dir_all(mirror.parent().unwrap()).unwrap(); + git_run( + &["init", "-q", "--bare", mirror.to_str().unwrap()], + fixtures.path(), + ); + let attacker_url = format!("file://{}", attacker.display()); + git_run(&["config", "remote.origin.url", &attacker_url], &mirror); + git_run( + &[ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/heads/*", + ], + &mirror, + ); + + let lines = drive(&mut chan, "git%clone octo/second").await; + + assert_no_error(&lines); + assert_no_token(&lines); + let work = workspace_dir(fixtures.path()).join("second"); + assert!( + !work.join("stolen.txt").exists(), + "the clone must carry canonical content, not the tampered mirror's target" + ); + let canonical_tip = rev_of(&bare_repo_named(fixtures.path(), "octo", "second"), "main"); + assert_eq!( + rev_of(&work, "refs/remotes/origin/main"), + canonical_tip, + "the mirror must have been re-pointed at the canonical remote: {lines:?}" + ); + assert_ne!(rev_of(&work, "refs/remotes/origin/main"), attacker_tip); + } + + /// A sandbox-planted clone destination — carrying a hostile origin and + /// a credential-helper trap — is refused outright: the facade never + /// adopts, reads, or runs git in a pre-existing destination (the + /// fail-closed side of the clone TOCTOU fix). + #[tokio::test] + async fn attacker_planted_clone_destination_fails_closed() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second@feat/y"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let remotes = fixtures.path().join("remotes"); + make_bare_remote(&remotes, "octo", "second"); + + let planted = workspace_dir(fixtures.path()).join("second"); + std::fs::create_dir_all(&planted).unwrap(); + git_run(&["init", "-q", "-b", "main", "."], &planted); + let sink = fixtures.path().join("planted-helper-fired"); + git_run( + &[ + "config", + "credential.helper", + &format!("!f() {{ echo fired > {}; }}; f", sink.display()), + ], + &planted, + ); + git_run( + &[ + "config", + "remote.origin.url", + "https://evil.example/steal.git", + ], + &planted, + ); + + let lines = drive(&mut chan, "git%clone octo/second").await; + + assert!( + error_line(&lines).contains("already exists"), + "a pre-existing destination must be refused: {lines:?}" + ); + assert_no_token(&lines); + assert!( + !sink.exists(), + "no git may ever run inside a sandbox-planted destination" + ); + } + + /// A declared base branch missing on the remote fails cleanly (R2.6): + /// an actionable error, no half-primed directory in the workspace, and + /// no staging leftovers under the mirror root. + #[tokio::test] + async fn clone_with_missing_base_fails_clean_and_leaves_no_state() { + let attrs = bound_attrs(&["octo/hello@feat/x:main", "octo/second@feat/y:nope"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let remotes = fixtures.path().join("remotes"); + make_bare_remote(&remotes, "octo", "second"); + + let lines = drive(&mut chan, "git%clone octo/second").await; + + assert!( + error_line(&lines).contains("base branch `nope` not found"), + "got: {lines:?}" + ); + assert!( + !workspace_dir(fixtures.path()).join("second").exists(), + "a failed clone must leave nothing in the workspace" + ); + let leftovers: Vec = std::fs::read_dir(fixtures.path().join(".gh-mirror")) + .expect("mirror root exists") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".tmp")) + .collect(); + assert!( + leftovers.is_empty(), + "staging temp dirs must be cleaned up: {leftovers:?}" + ); + } + + /// The commit `refname` resolves to in `dir` (empty on failure). + fn rev_of(dir: &Path, refname: &str) -> String { + let out = Command::new("git") + .args(["rev-parse", refname]) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("rev-parse"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// The branch `dir`'s HEAD is on (empty on failure). + fn current_branch_of(dir: &Path) -> String { + let out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("rev-parse HEAD"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// The configured `remote.origin.url` of `dir` (empty on failure). + fn origin_url_of(dir: &Path) -> String { + let out = Command::new("git") + .args(["config", "remote.origin.url"]) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("git config"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// The upstream `branch` tracks in `dir` (empty when it has none). + fn upstream_of(dir: &Path, branch: &str) -> String { + let out = Command::new("git") + .args([ + "rev-parse", + "--abbrev-ref", + &format!("{branch}@{{upstream}}"), + ]) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("rev-parse upstream"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// Pushes one distinguishing commit (adding `marker`) to `bare`'s + /// `main` from a throwaway clone, returning the new tip sha. + fn advance_main_of(fixtures: &Path, bare: &Path, marker: &str) -> String { + let tmp = fixtures.join(format!(".advance-{}", marker.replace('/', "_"))); + git_run( + &["clone", "-q", bare.to_str().unwrap(), tmp.to_str().unwrap()], + fixtures, + ); + std::fs::write(tmp.join(marker), b"marker\n").unwrap(); + git_run(&["add", marker], &tmp); + git_run(&["commit", "-q", "-m", "marker"], &tmp); + git_run(&["push", "-q", "origin", "main"], &tmp); + rev_of(&tmp, "HEAD") + } + + /// Advances the canonical `octo/hello` remote's `main` by one commit + /// (pushed from a throwaway clone), returning the new tip sha. Models + /// upstream work that a later `fetch`/`pull` must bring in. + fn advance_canonical_main(fixtures: &Path) -> String { + advance_main_of(fixtures, &bare_repo(fixtures), "upstream.txt") + } + + /// An authorized `fetch` brings the canonical remote's new commits into + /// the worktree's remote-tracking refs via the daemon-owned mirror, + /// streams cleanly, and leaks no token. + #[tokio::test] + async fn authorized_fetch_updates_tracking() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + let new_tip = advance_canonical_main(fixtures.path()); + + let work = worktree_dir(fixtures.path()); + assert_ne!( + rev_of(&work, "refs/remotes/origin/main"), + new_tip, + "precondition: the worktree has not seen the upstream commit yet" + ); + + let lines = drive(&mut chan, "git%fetch").await; + assert_no_error(&lines); + assert_no_token(&lines); + assert_eq!( + rev_of(&work, "refs/remotes/origin/main"), + new_tip, + "fetch must advance the worktree's origin/main via the mirror: {lines:?}" + ); + } + + /// An authorized `pull` fast-forwards the checked-out branch from the + /// canonical remote through the mirror, without a token in the sandbox. + #[tokio::test] + async fn authorized_pull_fast_forwards_current_branch() { + let attrs = bound_attrs(&["octo/hello@feat/x:main"]); + let (_state, _rootfs, _cwd, fixtures, mut chan) = github_channel(Some(&attrs)).await; + + // Put the worktree on `main` (tracking the canonical remote) and + // then advance the remote so there is something to fast-forward. + let work = worktree_dir(fixtures.path()); + git_run(&["checkout", "-q", "main"], &work); + let new_tip = advance_canonical_main(fixtures.path()); + assert_ne!(rev_of(&work, "HEAD"), new_tip); + + let lines = drive(&mut chan, "git%pull").await; + assert_no_error(&lines); + assert_no_token(&lines); + assert_eq!( + rev_of(&work, "HEAD"), + new_tip, + "pull must fast-forward the current branch to the upstream tip: {lines:?}" + ); + } + + /// The verb constant the helper script speaks and the dispatch arm + /// match on are the same crate-shared constant. + #[test] + fn dispatch_matches_the_shared_verb_constant() { + assert_eq!(VERB_GIT, "git"); + } + } } diff --git a/crates/minimald/src/github/authz.rs b/crates/minimald/src/github/authz.rs new file mode 100644 index 000000000..1119f8257 --- /dev/null +++ b/crates/minimald/src/github/authz.rs @@ -0,0 +1,491 @@ +//! The GitHub authorization choke point (spec R5.4). +//! +//! This module holds: +//! +//! * typed read/write of the `github.*` `attrs` on a session's +//! [`minimald_rpc::SessionConfig`] / [`sessions::Record`] — thin wrappers +//! over the shared [`github::attrs::GithubAttrs`] codec, so no other module +//! in `minimald` hand-rolls the `attrs` key names or string encodings; +//! * [`Permission`], the small typed vocabulary of "what an operation needs" +//! (a [`Scope`] plus the [`github::Permission`] level), so call sites read +//! as intent (`Permission::push()`) rather than a bare scope/level pair; +//! * [`authorize`], the single choke point every authenticated GitHub +//! operation (`min git`/`min api` facade verbs, repo pre-priming, push, PR +//! creation) MUST call before touching the network or a token. +//! +//! `authorize` is a pure decision function over a session's `attrs`: it never +//! performs I/O and never sees a token. See its doc comment for the +//! mandatory "caller must hold a live-session handle" contract. +//! +//! Sibling modules `prime`, `push_pr`, and `facade` (spec 10's fan-out — +//! see `super`'s module docs) are the intended callers of everything public +//! here, but land as separate tasks and are still doc-only stubs in this +//! tree. Until they call in, nothing in this crate reaches `authorize`, +//! `Permission`, or `AuthzError`, so `dead_code` is allowed at the module +//! level rather than left to fail the clippy gate for a fact of DAG +//! ordering, not a real defect; the exhaustive test suite below is this +//! module's actual usage proof in the meantime. +#![allow(dead_code)] + +use std::fmt; + +use github::attrs::GithubAttrs; +use github::{GrantId, RepoSpec, Scope}; +use sessions::Record; + +/// A GitHub permission requirement: a [`Scope`] plus the minimum +/// [`github::Permission`] level an operation needs. This is the single +/// currency [`authorize`] checks a session's resolved scope set against +/// (spec R5.4). +/// +/// Named constructors spell out the concrete operations the daemon performs +/// (spec R5.1's default scope set), so a call site reads as "this is a push" +/// rather than a bare `(Scope, Permission)` pair; [`Permission::new`] covers +/// anything not named below. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Permission { + scope: Scope, + level: github::Permission, +} + +impl Permission { + /// Builds a requirement from an explicit scope/level pair. + #[must_use] + pub fn new(scope: Scope, level: github::Permission) -> Self { + Self { scope, level } + } + + /// `contents:read` — required to clone or fetch (spec R2.3, R2.4). + #[must_use] + pub fn read_contents() -> Self { + Self::new(Scope::Contents, github::Permission::Read) + } + + /// `contents:write` — required to push commits or branches (spec R3.4). + #[must_use] + pub fn push() -> Self { + Self::new(Scope::Contents, github::Permission::Write) + } + + /// `pull_requests:read` — required to detect an existing PR (spec R4.5). + #[must_use] + pub fn read_pull_requests() -> Self { + Self::new(Scope::PullRequests, github::Permission::Read) + } + + /// `pull_requests:write` — required to open or update a PR (spec R4.3). + #[must_use] + pub fn create_pr() -> Self { + Self::new(Scope::PullRequests, github::Permission::Write) + } + + /// `issues:read` — required for a GitHub MCP issue read. + #[must_use] + pub fn read_issues() -> Self { + Self::new(Scope::Issues, github::Permission::Read) + } + + /// `issues:write` — required for a GitHub MCP issue mutation. + #[must_use] + pub fn write_issues() -> Self { + Self::new(Scope::Issues, github::Permission::Write) + } + + /// The scope this requirement is against. + #[must_use] + pub fn scope(&self) -> Scope { + self.scope + } + + /// The minimum level required for [`Permission::scope`]. + #[must_use] + pub fn level(&self) -> github::Permission { + self.level + } +} + +impl fmt::Display for Permission { + /// Renders as `scope:level`, e.g. `contents:rw` — matching + /// [`github::ScopeSet::render_for_consent`]'s per-entry format, so this + /// reads identically wherever it shows up in an error or a span. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.scope, self.level) + } +} + +/// Errors from [`authorize`] and the `attrs` codec helpers in this module +/// (spec R5.4, R8.1). Every variant is safe to log or surface to a user: no +/// token material, only repo names, scope names, and grant ids (which are +/// identifiers, not secrets — see [`GrantId`]'s docs). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AuthzError { + /// The session's stored `github.*` attrs failed to decode (a malformed + /// grant id, repo spec, or scope string). This should not happen for + /// attrs this module itself wrote, but a session's attrs can in + /// principle be edited out from under it, so decode failure is handled + /// rather than panicked on. + #[error("session GitHub configuration could not be read: {0}")] + InvalidAttrs(#[source] github::Error), + + /// The session has no GitHub grant bound at all — either it was never + /// configured for GitHub, or pre-priming has not run yet. + #[error( + "this session has no GitHub authentication bound; declare a repo and \ + sign in with `min github login`" + )] + NoGrantBound, + + /// `repo` is not in the session's declared repo allow-list (spec R2.1). + /// The exact wording here is depended on by tests and is the canonical + /// deny message for this case. + #[error("repo {owner}/{repo} not declared for this session")] + RepoNotDeclared { + /// The repository owner (user or org) that was requested. + owner: String, + /// The repository name that was requested. + repo: String, + }, + + /// The session's resolved scope set does not grant the required + /// permission at the required level (spec R5.4). + #[error( + "required GitHub permission `{required}` not granted for this \ + session (resolved scopes: {resolved})" + )] + MissingScope { + /// The permission that was required, rendered `scope:level`. + required: String, + /// The session's full resolved scope set, for context. Scope names + /// and levels are not secret. + resolved: String, + }, +} + +impl From for AuthzError { + fn from(err: github::Error) -> Self { + Self::InvalidAttrs(err) + } +} + +/// Decodes the GitHub-relevant `attrs` off a session record via the shared +/// [`github::attrs::GithubAttrs`] codec (spec R7.1). A session with no +/// GitHub involvement decodes to [`GithubAttrs::default`] (all fields +/// absent), which downstream [`authorize`] calls reject as +/// [`AuthzError::NoGrantBound`] rather than treating as "anything goes". +/// +/// # Errors +/// +/// [`AuthzError::InvalidAttrs`] if a present `github.*` key does not parse. +pub fn read_github_attrs(record: &Record) -> Result { + GithubAttrs::decode(&record.attrs).map_err(AuthzError::from) +} + +/// Writes the GitHub-relevant fields into a session-creation request's +/// `attrs` (spec R7.1), via the same shared codec. Any unrelated key already +/// present is left untouched (see [`GithubAttrs::encode_into`]). +pub fn write_github_attrs(config: &mut minimald_rpc::SessionConfig, attrs: &GithubAttrs) { + attrs.encode_into(&mut config.attrs); +} + +/// The single authorization choke point (spec R5.4). Every authenticated +/// GitHub operation a session performs — `min git`/`min api` facade verbs, +/// repo pre-priming, explicit push, PR creation — MUST call this before +/// touching the network or obtaining a token. Checks, in order: +/// +/// 1. **Bound.** The session has a GitHub grant bound at all +/// ([`AuthzError::NoGrantBound`] otherwise). +/// 2. **Declared.** `repo` is present in the session's declared repo +/// allow-list ([`AuthzError::RepoNotDeclared`] otherwise) — this is what +/// makes it impossible for a facade operation to reach a repository the +/// user did not consent to at launch (spec R2.1, G6). +/// 3. **Scoped.** `perm` is contained in the session's resolved scope set at +/// a sufficient level ([`AuthzError::MissingScope`] otherwise) — e.g. a +/// push needs `contents:write`, PR creation needs `pull_requests:write` +/// (spec R5.1, R5.4). +/// +/// On success, returns the session's bound [`GrantId`]; the caller then +/// resolves that id to a live token via `GithubService::grants` (this +/// function never touches a token or the network itself — it is a pure +/// decision over `record.attrs`). +/// +/// # Contract: callers must hold a live-session handle +/// +/// `record` MUST be obtained from a **live** session actor — the +/// `mngr.get_session(SessionKeyPredicate::Id(..))` → `SessionHandle` +/// pattern used by the exec-channel dispatch (see +/// `crate::exec::handle_channel_exec`) — and not a `Record` read directly +/// from the on-disk store. The on-disk record for a **destroyed** session +/// can still be fetched by id long after the sandbox — and with it, the +/// facade channel that mediated access was bound to (spec R3.5, R6.3) — is +/// gone; nothing in a bare `Record` distinguishes "session is live" from +/// "session used to exist". `authorize` therefore cannot detect a destroyed +/// session on its own: **the caller resolving a live `SessionHandle` first +/// is the mechanism that makes a destroyed session's id authorize nothing.** +/// Every RPC/facade entry point in this daemon must look the session up via +/// the session manager and pass in the record obtained from that handle +/// (e.g. `SessionHandle::record()`), never a value cached across an +/// await point or read straight from `crate::store`. +/// +/// # Errors +/// +/// See [`AuthzError`]'s variants. +pub fn authorize( + record: &Record, + repo: &RepoSpec, + perm: Permission, +) -> Result { + let attrs = read_github_attrs(record)?; + + let grant_id = attrs.grant_id.ok_or(AuthzError::NoGrantBound)?; + + let declared = attrs + .repos + .iter() + .any(|declared| declared.owner() == repo.owner() && declared.repo() == repo.repo()); + if !declared { + return Err(AuthzError::RepoNotDeclared { + owner: repo.owner().to_string(), + repo: repo.repo().to_string(), + }); + } + + let resolved = attrs.scopes.unwrap_or_default(); + let granted = resolved.permission(perm.scope()); + if granted.is_none_or(|level| level < perm.level()) { + return Err(AuthzError::MissingScope { + required: perm.to_string(), + resolved: resolved.render_for_consent(), + }); + } + + Ok(grant_id) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use github::{Permission as GhPermission, ScopeSet}; + use paths::HostAbsPath; + use sessions::{NetworkMode, SessionId, SessionPolicy, SessionStatus}; + + use super::*; + + /// A syntactically valid absolute host path for tests that need one but + /// don't exercise path behavior. + fn dummy_path() -> HostAbsPath { + HostAbsPath::try_new("/tmp/minimald-authz-test").expect("literal absolute path") + } + + /// Builds a bare `Record` with the given `attrs`, otherwise minimal. + fn record_with_attrs(attrs: BTreeMap) -> Record { + Record { + id: SessionId::nil(), + name: None, + username: None, + project_path: dummy_path(), + network: NetworkMode::default(), + policy: SessionPolicy::default(), + status: SessionStatus::default(), + attrs, + } + } + + fn repo(spec: &str) -> RepoSpec { + spec.parse().expect("valid repo spec literal in test") + } + + fn bound_record(repos: &[&str], scopes: ScopeSet) -> Record { + let mut attrs = BTreeMap::new(); + GithubAttrs { + grant_id: Some(GrantId::new("grant-1").unwrap()), + repos: repos.iter().map(|r| repo(r)).collect(), + scopes: Some(scopes), + } + .encode_into(&mut attrs); + record_with_attrs(attrs) + } + + #[test] + fn happy_path_returns_grant_id() { + let record = bound_record(&["octocat/hello"], ScopeSet::defaults()); + let grant = authorize(&record, &repo("octocat/hello"), Permission::push()) + .expect("declared repo + sufficient scope must authorize"); + assert_eq!(grant.as_str(), "grant-1"); + } + + #[test] + fn happy_path_with_multiple_declared_repos() { + let record = bound_record(&["octocat/hello", "my-org/api"], ScopeSet::defaults()); + assert!(authorize(&record, &repo("octocat/hello"), Permission::create_pr()).is_ok()); + assert!(authorize(&record, &repo("my-org/api"), Permission::create_pr()).is_ok()); + } + + #[test] + fn unbound_session_is_denied() { + // No grant_id at all: an empty-attrs session (never touched GitHub). + let record = record_with_attrs(BTreeMap::new()); + let err = authorize(&record, &repo("octocat/hello"), Permission::push()) + .expect_err("no grant bound must deny"); + assert!(matches!(err, AuthzError::NoGrantBound)); + } + + #[test] + fn unbound_session_is_denied_even_with_declared_repos_and_scopes() { + // Repos/scopes present but no grant_id: still must deny, since there is + // no token to authorize against. + let mut attrs = BTreeMap::new(); + GithubAttrs { + grant_id: None, + repos: vec![repo("octocat/hello")], + scopes: Some(ScopeSet::defaults()), + } + .encode_into(&mut attrs); + let record = record_with_attrs(attrs); + + let err = authorize(&record, &repo("octocat/hello"), Permission::push()) + .expect_err("missing grant_id must deny regardless of repos/scopes"); + assert!(matches!(err, AuthzError::NoGrantBound)); + } + + #[test] + fn undeclared_repo_is_denied() { + let record = bound_record(&["octocat/hello"], ScopeSet::defaults()); + let err = authorize(&record, &repo("other-owner/other-repo"), Permission::push()) + .expect_err("repo not in the allow-list must deny"); + match err { + AuthzError::RepoNotDeclared { owner, repo } => { + assert_eq!(owner, "other-owner"); + assert_eq!(repo, "other-repo"); + } + other => panic!("expected RepoNotDeclared, got {other:?}"), + } + } + + #[test] + fn declared_repo_branch_is_irrelevant_to_the_allow_list_match() { + // The declared repo carries a branch/base; authorize matches on + // owner/repo only, since the branch is a prime-time detail, not part + // of the identity of "which repo may this session touch". + let record = bound_record(&["octocat/hello@feat/x:main"], ScopeSet::defaults()); + assert!(authorize(&record, &repo("octocat/hello"), Permission::push()).is_ok()); + } + + #[test] + fn missing_scope_is_denied_per_permission_push() { + // Scopes present, but contents is read-only: a push needs write. + let scopes = ScopeSet::empty().with(Scope::Contents, GhPermission::Read); + let record = bound_record(&["octocat/hello"], scopes); + let err = authorize(&record, &repo("octocat/hello"), Permission::push()) + .expect_err("read-only contents must deny a push"); + match err { + AuthzError::MissingScope { required, resolved } => { + assert_eq!(required, "contents:rw"); + assert_eq!(resolved, "contents:read"); + } + other => panic!("expected MissingScope, got {other:?}"), + } + } + + #[test] + fn missing_scope_is_denied_per_permission_create_pr() { + // pull_requests scope entirely absent from the resolved set. + let scopes = ScopeSet::empty().with(Scope::Contents, GhPermission::Write); + let record = bound_record(&["octocat/hello"], scopes); + let err = authorize(&record, &repo("octocat/hello"), Permission::create_pr()) + .expect_err("absent pull_requests scope must deny PR creation"); + assert!(matches!(err, AuthzError::MissingScope { .. })); + } + + #[test] + fn no_resolved_scopes_denies_everything() { + // Grant bound, repo declared, but scopes were never resolved: fail + // closed rather than treat "no scopes recorded" as "anything goes". + let mut attrs = BTreeMap::new(); + GithubAttrs { + grant_id: Some(GrantId::new("grant-1").unwrap()), + repos: vec![repo("octocat/hello")], + scopes: None, + } + .encode_into(&mut attrs); + let record = record_with_attrs(attrs); + + let err = authorize(&record, &repo("octocat/hello"), Permission::read_contents()) + .expect_err("no resolved scopes must deny even a read"); + assert!(matches!(err, AuthzError::MissingScope { .. })); + } + + #[test] + fn write_permission_satisfies_a_read_requirement() { + // GitHub's write implies read; a resolved `contents:write` must + // satisfy a `contents:read` requirement (e.g. a clone/fetch). + let scopes = ScopeSet::empty().with(Scope::Contents, GhPermission::Write); + let record = bound_record(&["octocat/hello"], scopes); + assert!(authorize(&record, &repo("octocat/hello"), Permission::read_contents()).is_ok()); + } + + #[test] + fn malformed_attrs_are_rejected_not_panicked_on() { + let mut attrs = BTreeMap::new(); + attrs.insert( + github::attrs::ATTR_REPOS.to_string(), + "not a valid repo spec".to_string(), + ); + let record = record_with_attrs(attrs); + let err = authorize(&record, &repo("octocat/hello"), Permission::push()) + .expect_err("malformed attrs must be a decode error, not a panic"); + assert!(matches!(err, AuthzError::InvalidAttrs(_))); + } + + #[test] + fn error_messages_are_actionable_and_secret_free() { + let messages = [ + AuthzError::NoGrantBound.to_string(), + AuthzError::RepoNotDeclared { + owner: "owner".to_string(), + repo: "x".to_string(), + } + .to_string(), + AuthzError::MissingScope { + required: "contents:rw".to_string(), + resolved: "contents:read".to_string(), + } + .to_string(), + ]; + assert_eq!(messages[1], "repo owner/x not declared for this session"); + for message in messages { + assert!(!message.is_empty()); + // No token material — and nothing that could plausibly be one — + // ever appears in a deny message (spec R6.2, R8.1). + for needle in ["ghu_", "gho_", "ghp_", "access_token", "refresh_token"] { + assert!( + !message.contains(needle), + "message must not contain {needle:?}: {message}" + ); + } + } + } + + #[test] + fn read_and_write_github_attrs_round_trip_through_session_config() { + let original = GithubAttrs { + grant_id: Some(GrantId::new("grant-42").unwrap()), + repos: vec![repo("octocat/hello")], + scopes: Some(ScopeSet::defaults()), + }; + + let mut config = minimald_rpc::SessionConfig { + name: None, + project_path: dummy_path(), + network: NetworkMode::default(), + policy: SessionPolicy::default(), + attrs: BTreeMap::new(), + }; + write_github_attrs(&mut config, &original); + + let record = record_with_attrs(config.attrs); + let decoded = read_github_attrs(&record).expect("round-tripped attrs must decode"); + assert_eq!(decoded, original); + } +} diff --git a/crates/minimald/src/github/facade.rs b/crates/minimald/src/github/facade.rs new file mode 100644 index 000000000..9995b3139 --- /dev/null +++ b/crates/minimald/src/github/facade.rs @@ -0,0 +1,2091 @@ +//! The in-sandbox mediated-access facade (spec R3): the `git%` verb behind +//! `min git`, dispatched from the session channel in `crate::env`. +//! +//! The sandbox has no GitHub credential (spec G5/R6.1); instead the in-sandbox +//! `min` helper forwards `min git ` as a `git%` request line over +//! the per-session UDS, and this module performs the real, authenticated git +//! operation on the daemon side, streaming scrubbed output back as `msg:` +//! lines. Access is bound to the sandbox lifetime by construction: the +//! dispatch lives on the channel actor that `crate::env::Env` aborts when the +//! sandbox is torn down, and every request re-reads the session record from the +//! live store (a destroyed session's record is gone), so nothing authorizes +//! after destroy (spec R3.5/R6.3). +//! +//! # The argv gate is a security boundary +//! +//! [`parse_git_argv`] is a **fail-closed allowlist**, not a git argv parser: +//! only the exact shapes below are accepted, and everything else — every +//! option, every extra argument, every unknown subcommand — is rejected. +//! The sandbox must never be able to steer a token-bearing `git` process: +//! `-c`/`--config-env` would let it inject arbitrary config (credential +//! helpers, `core.sshCommand`), `--upload-pack`/`--receive-pack` name a +//! program for git to execute, `--exec-path` redirects git's own helper +//! binaries, and `-C`/`--git-dir`/`--work-tree` or a path argument would move +//! the operation out of the session's declared workspace. None of those can +//! reach the daemon-side `git` because no token starting with `-` (other than +//! the literal `remote -v` form) and no free-form path is accepted at all. +//! Widening this grammar is a security change and needs review plus negative +//! tests. +//! +//! Accepted shapes (`[owner/repo]` selects among the session's declared +//! repos and is required only when more than one is declared): +//! +//! ```text +//! push [owner/repo] # push the current branch to the declared remote +//! pull [owner/repo] # fast-forward the current branch from the remote +//! fetch [owner/repo] # update remote-tracking refs from the remote +//! status [owner/repo] # current branch + ahead/behind of upstream +//! remote [-v] [owner/repo] # show the declared origin URL +//! clone owner/repo # clone a declared repo into the workspace +//! ``` +//! +//! # Authorization and token flow +//! +//! Every request resolves the **live** session record and passes +//! [`super::authz::authorize`] for the concrete permission the operation +//! needs (`contents:write` for push, `contents:read` otherwise) before any +//! token is fetched or any process runs. The token comes from the daemon's +//! single [`GrantManager`](github::GrantManager) and is handed straight to +//! `github::gitops`, which injects it env-only and scrubs it from all output; +//! it never appears in this module's errors, spans, or `msg:` lines. +//! +//! # The isolation invariant (the security core) +//! +//! **No privileged (daemon-run) `git` process may read sandbox-writable git +//! config.** The session workspace is sandbox-visible: in-sandbox code can +//! rewrite a primed repo's `.git/config` at will, and `git` executes a large, +//! open-ended set of config directives *as the process that reads them* — +//! `core.fsmonitor`, `core.sshCommand`, `core.pager`, `filter..process`, +//! `diff.external`, hooks, and `[include]`/`[includeIf]` (which splice in +//! arbitrary attacker-authored files, resolved only at git runtime). If the +//! daemon ran `git` in the worktree — or made a daemon git open +//! `upload-pack`/`receive-pack` *against* the worktree — any one of those would +//! run attacker code **as the daemon**, which then reads the token from the +//! mirror. This is the residual the earlier "token-free local leg" design +//! still had: even without a token in the working tree, a daemon +//! `git fetch ` spawns `upload-pack` there and executes its +//! config. A `.git/config` text scanner cannot close it (git honours includes +//! at file precedence, and a check-then-use scan races the sandbox rewriting +//! the file). +//! +//! So the daemon runs `git` in exactly one place: the daemon-private, bare, +//! daemon-authored **mirror** ([`mirror_root`] → +//! `/.gh-mirror/__.git`), a sibling of the +//! sandbox-mounted workspace that is mounted nowhere into the sandbox, with +//! `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, its +//! `remote.origin.url` set to the canonical URL [`remote_url`] derives +//! daemon-side (never the string `"origin"`, never anything the sandbox +//! authored), and the token injected env-only by [`github::gitops`]. Object and +//! ref movement between the worktree and the mirror never exposes worktree +//! config to a daemon git: +//! +//! * **Objects are inert.** Git objects are content-addressed blobs; reading +//! them executes nothing. Worktree → mirror transfer uses +//! `objects/info/alternates` ([`link_worktree_objects`]): the mirror is given +//! read-only object access to `/.git/objects`. No `git upload-pack` +//! ever runs in the worktree (that would read its config), and an alternate +//! is an object *source* only — git never reads the alternate parent's config. +//! * **Refs are read and written as plain files.** The daemon parses the +//! worktree's `HEAD`/`refs/*`/`packed-refs` itself ([`read_head_branch`], +//! [`read_ref_oid`]) and writes tracking / branch tips back with +//! [`write_loose_ref`] — never `git -C …`. Every OID is validated +//! as a hex sha ([`valid_oid`]) and every branch name is +//! [`check_safe_branch`]-validated before it is used as a path component or a +//! git argument, so a planted `refs/heads/x` holding `--evil` cannot become +//! option-injection. +//! * **Mirror → worktree** object transfer builds a self-contained pack *in the +//! mirror* ([`import_objects_into_worktree`], daemon-authored config) and +//! drops the resulting `.pack`/`.idx` into the worktree object store as plain +//! files. +//! +//! Per operation: +//! +//! * **Push:** read the worktree branch tip (plain file) → give the mirror +//! object access to the worktree (alternate) and write the tip as a mirror +//! ref (plain file) → [`github::gitops`] pushes mirror → canonical (token, +//! mirror config only). The worktree's `origin`/`pushurl`/`insteadOf`/ +//! `include`/hooks are never read. +//! * **Fetch:** [`github::gitops`] fetches canonical → mirror (token, mirror +//! config) → the objects are packed *in the mirror* and copied into the +//! worktree, and each `refs/remotes/origin/*` is written as a plain file. +//! * **Pull:** fetch as above, verify a fast-forward *in the mirror* +//! (`merge-base --is-ancestor`, the worktree tip resolved via the alternate), +//! then advance the checked-out branch and its tracking ref as plain files. +//! The daemon never runs a checkout in the sandbox-writable tree, so the +//! working-tree files re-materialize on the sandbox's own next checkout. +//! * **Status:** branch from `HEAD`; ahead/behind from a mirror-side +//! `rev-list` over the alternate — again no git in the worktree. +//! * **Clone:** the worktree does not exist yet, so the whole clone is built in +//! a daemon-private temp under the mirror root (credentialed fetch → mirror, +//! worktree assembled from the mirror over local legs) and renamed into the +//! workspace only once complete — nothing token-bearing or config-reading +//! ever runs in the sandbox-reachable destination, and a pre-existing +//! destination is refused outright rather than adopted. +//! +//! Net invariant: **no `git` process the daemon runs ever reads — or resolves a +//! remote name from — a directory the sandbox can write.** The canonical URL is +//! always the daemon's own derivation; a rewritten worktree +//! `origin`/`pushurl`/`insteadOf`/`include`, and any `core.fsmonitor`/ +//! `core.sshCommand`/`filter.*.process`/hook planted in the worktree config, is +//! simply never consulted by a daemon git, so it can neither redirect the token +//! leg nor execute code as the daemon. +//! +//! The mirror-location half of the invariant — `/.gh-mirror` +//! is writable by the daemon only — is owed by the launcher: the activation +//! wiring that mounts the workspace into the sandbox MUST NOT bind the +//! workspace's host *parent* (the session state dir) into the sandbox +//! namespace. That wiring has not landed yet ([`SessionGithub::new`] is not +//! reached from the production launcher); re-verify this property when it +//! does. +//! +//! Every span here is `github.facade` with fields limited to `repo` and +//! `grant_id` (see the conventions in `super::state`). + +use std::fs; +use std::io::Write; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use github::attrs::GithubAttrs; +use github::gitops::Repo; +use github::{GrantId, RepoSpec, SecretString}; +use paths::DaemonAbsPath; +use tracing::Instrument; +use url::Url; + +use super::authz::{self, AuthzError, Permission}; +use super::state::{DaemonGrantManager, GithubService}; +use crate::store::SessionRecordHandle; + +/// The exact deny message for a session that has no GitHub authentication +/// wired at all (no facade plumbed, or no grant bound). Shared with the +/// dispatch arm in `crate::env` so both paths answer identically. +pub(crate) const NO_GITHUB_AUTH: &str = "this session has no GitHub authentication"; + +/// One line summarizing the accepted grammar, appended to every allowlist +/// rejection so the error is actionable without widening what it accepts. +const SUPPORTED: &str = "supported: `min git push|pull|fetch|status [owner/repo]`, \ + `min git remote -v [owner/repo]`, `min git clone owner/repo`"; + +/// Name of the daemon-only directory (a sibling of the sandbox-mounted +/// workspace) that holds the clean per-repo mirrors. Hidden and prefixed so it +/// cannot collide with a repo directory named after a declared repo. +const MIRROR_DIR: &str = ".gh-mirror"; + +/// Errors surfaced to the in-sandbox client as a single `error:` line (or +/// `msg:` lines plus a terminator when multi-line). Every variant is +/// actionable and secret-free: repo names, scope names and grant ids only, +/// never token material (spec R8.1, R6.2). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub(crate) enum FacadeError { + /// The request failed the fail-closed argv allowlist (see the module + /// docs: this gate is a security boundary). + #[error("{reason}; {SUPPORTED}")] + NotPermitted { + /// Why the argv was rejected (names the offending token or shape). + reason: String, + }, + + /// The authorization choke point denied the operation (no grant bound, + /// repo not declared, or missing scope — spec R5.4). + #[error(transparent)] + Denied(#[from] AuthzError), + + /// The live session record could not be read. A destroyed session's + /// record is deleted, so this is also how facade access ends at destroy + /// (spec R3.5/R6.3). + #[error("session record unavailable: {source}")] + SessionUnavailable { + /// The store-read failure. + #[source] + source: std::io::Error, + }, + + /// Obtaining a live access token for the bound grant failed. + #[error(transparent)] + Auth(#[from] github::RefreshError), + + /// A GitHub-domain failure (e.g. the daemon has no GitHub App configured). + #[error(transparent)] + Github(#[from] github::Error), + + /// The daemon-side credentialed git operation failed. `gitops` errors + /// embed only token-scrubbed output. + #[error(transparent)] + Git(#[from] github::gitops::GitError), + + /// A daemon-side, token-free **local** git step (mirror setup, the local + /// ref transfer, or a worktree update) failed. These legs never hold a + /// token and never contact the network; the detail is a short, non-secret + /// summary. + #[error("local git step `{operation}` failed: {detail}")] + LocalGit { + /// The logical local step that failed. + operation: String, + /// A short, non-secret description of the failure. + detail: String, + }, + + /// The selected repo has no primed working tree in this session's + /// workspace. + #[error( + "repo {owner}/{name} is not primed in this session's workspace; \ + run `min git clone {owner}/{name}` first" + )] + NotPrimed { + /// The repository owner. + owner: String, + /// The repository name. + name: String, + }, + + /// The session declares several repos, so the operation must name one. + #[error( + "multiple repos are declared for this session; name one explicitly, \ + e.g. `min git {subcommand} owner/repo`" + )] + AmbiguousRepo { + /// The subcommand the user ran, echoed into the suggested fix. + subcommand: String, + }, + + /// A grant is bound but the session declares no repos to operate on. + #[error("no repos are declared for this session")] + NoDeclaredRepos, + + /// A branch name is unsafe to use as a git argument or a ref-file path + /// component: it begins with `-` (git would parse it as an option), or + /// contains a `..`/empty path component or a git-special character (a + /// planted `HEAD`/ref could try to escape the refs tree). Covers the + /// worktree's current branch on push/pull/status and the declared/derived + /// branch on clone. Refuse rather than run any git or touch any path on it + /// (the branch name is not secret). + #[error("branch `{branch}` has an unsafe name; rename it")] + UnsafeBranchName { + /// The offending branch name. + branch: String, + }, + + /// A `pull` targeted a branch that has no counterpart on the remote (there + /// is nothing to fast-forward onto). + #[error("branch `{branch}` does not exist on the remote")] + NoRemoteBranch { + /// The branch that was not found on the remote. + branch: String, + }, + + /// A `pull` would not be a fast-forward: the checked-out branch has + /// diverged from the remote. The facade never merges or rebases in the + /// sandbox-writable tree, so it refuses (mirrors `git pull --ff-only`). + #[error("`{branch}` has diverged from the remote; `min git pull` is fast-forward only")] + NotFastForward { + /// The branch that could not be fast-forwarded. + branch: String, + }, + + /// The daemon could not derive a mirror location outside the + /// sandbox-visible workspace (the workspace has no parent directory). Fail + /// closed rather than risk running the token leg near sandbox-writable + /// config. + #[error("could not derive a daemon-private mirror location for the session workspace")] + NoMirrorRoot, + + /// Streaming plumbing failed (connection clone / blocking-task join). + #[error("internal error while streaming git output: {source}")] + Stream { + /// The underlying failure. + #[source] + source: std::io::Error, + }, +} + +/// A request that passed the [`parse_git_argv`] allowlist. `repo` is the +/// optional `owner/repo` selector naming which declared repo to operate on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GitVerbCmd { + /// `push [owner/repo]` — push the current branch to the declared remote. + Push { + /// Which declared repo to push, when more than one is declared. + repo: Option, + }, + /// `pull [owner/repo]` — fast-forward the current branch. + Pull { + /// Which declared repo to pull. + repo: Option, + }, + /// `fetch [owner/repo]` — update remote-tracking refs from the remote. + Fetch { + /// Which declared repo to fetch. + repo: Option, + }, + /// `status [owner/repo]` — current branch and ahead/behind of upstream. + Status { + /// Which declared repo to inspect. + repo: Option, + }, + /// `remote [-v] [owner/repo]` — show the declared origin URL. + RemoteShow { + /// Which declared repo to show. + repo: Option, + }, + /// `clone owner/repo` — clone a declared repo into the workspace. + Clone { + /// The declared repo to clone (mandatory). + repo: RepoSpec, + }, +} + +impl GitVerbCmd { + /// The repo selector, if the request named one. + fn selector(&self) -> Option<&RepoSpec> { + match self { + Self::Push { repo } + | Self::Pull { repo } + | Self::Fetch { repo } + | Self::Status { repo } + | Self::RemoteShow { repo } => repo.as_ref(), + Self::Clone { repo } => Some(repo), + } + } + + /// The subcommand keyword, for error messages. + fn subcommand(&self) -> &'static str { + match self { + Self::Push { .. } => "push", + Self::Pull { .. } => "pull", + Self::Fetch { .. } => "fetch", + Self::Status { .. } => "status", + Self::RemoteShow { .. } => "remote", + Self::Clone { .. } => "clone", + } + } + + /// The permission this operation must be authorized for (spec R5.4): + /// `contents:write` to push, `contents:read` for everything else. + fn permission(&self) -> Permission { + match self { + Self::Push { .. } => Permission::push(), + _ => Permission::read_contents(), + } + } + + /// Whether the operation contacts the remote and therefore needs a live + /// access token. `status` and `remote` are local-only. + fn needs_token(&self) -> bool { + matches!( + self, + Self::Push { .. } | Self::Pull { .. } | Self::Fetch { .. } | Self::Clone { .. } + ) + } +} + +/// Builds a [`FacadeError::NotPermitted`] with the given reason. +fn not_permitted(reason: impl Into) -> FacadeError { + FacadeError::NotPermitted { + reason: reason.into(), + } +} + +/// Parses one `owner/repo` selector token. Branch selectors and anything +/// option- or path-shaped are rejected: the selector's only job is to pick +/// one of the session's declared repos; branches come from the declaration. +fn parse_selector(token: &str) -> Result { + if token.starts_with('-') { + return Err(not_permitted(format!( + "git option `{token}` is not permitted through `min git`" + ))); + } + if token.contains('@') || token.contains(':') { + return Err(not_permitted(format!( + "`{token}`: branch selectors are not accepted here; the branch \ + comes from the session's repo declaration" + ))); + } + token + .parse::() + .map_err(|e| not_permitted(format!("`{token}` is not an `owner/repo` name ({e})"))) +} + +/// Parses the optional trailing `[owner/repo]` selector: nothing, or exactly +/// one selector token. Options and extra arguments are rejected. +fn optional_selector(rest: &[&str]) -> Result, FacadeError> { + match rest { + [] => Ok(None), + [one] => Ok(Some(parse_selector(one)?)), + [first, ..] if first.starts_with('-') => Err(not_permitted(format!( + "git option `{first}` is not permitted through `min git`" + ))), + _ => Err(not_permitted("too many arguments")), + } +} + +/// The fail-closed argv allowlist (see the module docs — this is a security +/// boundary, not a convenience parser). Only the exact accepted shapes parse; +/// every option (`-c`, `-C`, `--upload-pack`, `--exec-path`, `--git-dir`, +/// `--work-tree`, `--config-env`, `--`, …), every path argument, and every +/// unknown subcommand is rejected with an actionable error. +pub(crate) fn parse_git_argv(argv: &str) -> Result { + let tokens: Vec<&str> = argv.split_whitespace().collect(); + let Some((&sub, rest)) = tokens.split_first() else { + return Err(not_permitted("missing git subcommand")); + }; + if sub.starts_with('-') { + // Blocks every pre-subcommand option: `-c`, `-C`, `--exec-path`, + // `--git-dir`, `--work-tree`, `--config-env`, `--namespace`, …. + return Err(not_permitted(format!( + "git option `{sub}` is not permitted through `min git`" + ))); + } + match sub { + "push" => Ok(GitVerbCmd::Push { + repo: optional_selector(rest)?, + }), + "pull" => Ok(GitVerbCmd::Pull { + repo: optional_selector(rest)?, + }), + "fetch" => Ok(GitVerbCmd::Fetch { + repo: optional_selector(rest)?, + }), + "status" => Ok(GitVerbCmd::Status { + repo: optional_selector(rest)?, + }), + "remote" => { + // `-v` is the single permitted flag anywhere in the grammar, and + // only in this position; it changes nothing (the output is + // already verbose). + let rest = match rest.split_first() { + Some((&"-v", others)) => others, + _ => rest, + }; + Ok(GitVerbCmd::RemoteShow { + repo: optional_selector(rest)?, + }) + } + "clone" => match rest { + [one] => Ok(GitVerbCmd::Clone { + repo: parse_selector(one)?, + }), + [] => Err(not_permitted( + "clone needs a declared repo, e.g. `min git clone owner/repo`", + )), + [first, ..] if first.starts_with('-') => Err(not_permitted(format!( + "git option `{first}` is not permitted through `min git`" + ))), + _ => Err(not_permitted("too many arguments")), + }, + other => Err(not_permitted(format!( + "git subcommand `{other}` is not available through `min git`" + ))), + } +} + +/// Derives the clean (credential-free) canonical remote URL for a declared +/// repo from the daemon's configured git base: `//.git`. +/// +/// This is the single source of truth for where the credentialed leg connects +/// (spec R5.4): it comes from the session's declared repo, daemon-side, and is +/// supplied explicitly to `git` — the sandbox's own `origin` is never used. +fn remote_url(git_base: &Url, repo: &RepoSpec) -> Result { + // `Url::join` resolves relative to the last `/`; guarantee the base is + // treated as a directory so a slash-less override can't eat a path + // segment. + let mut base = git_base.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + base.join(&format!("{}/{}.git", repo.owner(), repo.repo())) + .map_err(|e| { + FacadeError::Github(github::Error::InvalidConfig { + var: "MINIMALD_GITHUB_GIT_BASE_URL".to_string(), + reason: e.to_string(), + }) + }) +} + +/// Where an operation's token may come from. Production always goes through +/// the daemon's one shared [`DaemonGrantManager`]; channel-level tests use a +/// fixed token so they can exercise the full dispatch against fixture remotes +/// without a seeded grant store (the refresh machinery has its own exhaustive +/// tests in the `github` crate). +#[derive(Debug, Clone)] +enum TokenSource { + /// The shared daemon grant manager (the only production variant). + Grants(DaemonGrantManager), + /// A fixed token, compiled only into this crate's unit tests. + #[cfg(test)] + Fixed(SecretString), +} + +/// The per-session GitHub facade state carried by the session channel actor: +/// the daemon-held pieces needed to authorize and run `git%` requests. Dies +/// with the channel actor, which dies with the sandbox (spec R3.5). +#[derive(Debug, Clone)] +pub(crate) struct SessionGithub { + tokens: TokenSource, + /// Whether a GitHub App client id is configured; token-requiring + /// operations fail closed with [`github::Error::NotConfigured`] when not + /// (the mandatory pre-I/O check from `super::state`'s module docs). + configured: bool, + git_base: Url, + /// Live handle to this session's record: re-read per request so attrs + /// updates are seen and a destroyed session (deleted record) authorizes + /// nothing. + record: SessionRecordHandle, +} + +impl SessionGithub { + /// Builds the facade state for one session from the daemon's shared + /// [`GithubService`] and the session's record handle. + // Not yet reached from the production launcher: the `EnvArgs::with_github` + // wiring through `session_host` lands with the activation tasks of this + // spec's DAG. The channel-level tests in `crate::env` are the usage proof + // in the meantime (mirrors the `super::authz` precedent). + #[allow(dead_code)] + pub(crate) fn new(service: &GithubService, record: SessionRecordHandle) -> Self { + Self { + tokens: TokenSource::Grants(service.grants().clone()), + configured: service.config().is_configured(), + git_base: service.config().git_base().clone(), + record, + } + } + + /// Test constructor: a fixed token and an explicit git base, so channel + /// tests can drive the full dispatch against `file://` fixture remotes. + #[cfg(test)] + pub(crate) fn for_tests( + token: impl Into, + git_base: Url, + record: SessionRecordHandle, + ) -> Self { + Self { + tokens: TokenSource::Fixed(SecretString::new(token)), + configured: true, + git_base, + record, + } + } + + /// A clone of the live record handle, so channel tests can mutate or + /// delete the record out from under the facade. + #[cfg(test)] + pub(crate) fn record_handle_for_tests(&self) -> SessionRecordHandle { + self.record.clone() + } + + /// Handles one `git%` request line: parse → authorize → run, + /// streaming scrubbed output as `msg:` lines and terminating with an + /// `error:` line on failure. + pub(crate) async fn handle_git( + &self, + argv: &str, + working: &DaemonAbsPath, + stream: &mut UnixStream, + ) { + if let Err(err) = self.run_git(argv, working, stream).await { + write_facade_error(&err, stream); + } + } + + /// The fallible body of [`SessionGithub::handle_git`]. + async fn run_git( + &self, + argv: &str, + working: &DaemonAbsPath, + stream: &mut UnixStream, + ) -> Result<(), FacadeError> { + // 1. The allowlist gate — before anything else runs or is read. + let cmd = parse_git_argv(argv)?; + + // 2. A fresh, live read of the session record: a destroyed session's + // record is deleted, so this is where post-destroy access dies. + let record = self + .record + .record() + .await + .map_err(|source| FacadeError::SessionUnavailable { source })?; + + // 3. Resolve the target repo among the declared set and authorize the + // concrete permission through the single choke point (spec R5.4). + let attrs = authz::read_github_attrs(&record)?; + if attrs.grant_id.is_none() { + return Err(AuthzError::NoGrantBound.into()); + } + let repo = resolve_repo(&attrs, &cmd)?; + let grant_id = authz::authorize(&record, &repo, cmd.permission())?; + + let span = tracing::info_span!("github.facade", repo = %repo, grant_id = %grant_id); + async { + // 4. Only now touch a token, and only for remote-contacting ops. + let token = if cmd.needs_token() { + Some(self.token(&grant_id).await?) + } else { + None + }; + + // 5. Run the git work on the blocking pool, streaming `msg:` + // lines straight onto a clone of the connection. The actor + // awaits the join handle, so output cannot interleave with + // other requests. + let remote = remote_url(&self.git_base, &repo)?; + let out = stream + .try_clone() + .map_err(|source| FacadeError::Stream { source })?; + let working = working.clone(); + let declared_repos = attrs.repos.len(); + tokio::task::spawn_blocking(move || { + execute( + &cmd, + &repo, + &remote, + token.as_ref(), + &working, + declared_repos, + out, + ) + }) + .await + .map_err(|e| FacadeError::Stream { + source: std::io::Error::other(e), + })? + } + .instrument(span) + .await + } + + /// Obtains a live access token for the bound grant, refreshing through + /// the shared manager as needed. Fails closed with + /// [`github::Error::NotConfigured`] when the daemon has no GitHub App + /// configured (see `super::state`'s module docs). + async fn token(&self, grant_id: &GrantId) -> Result { + if !self.configured { + return Err(github::Error::NotConfigured.into()); + } + match &self.tokens { + TokenSource::Grants(grants) => Ok(grants.token_for(grant_id).await?), + #[cfg(test)] + TokenSource::Fixed(token) => Ok(token.clone()), + } + } +} + +/// Writes a facade failure to the client. Single-line errors go out as one +/// `error:` line; multi-line ones (scrubbed git stderr) go out as `msg:` +/// lines with an `error:` terminator, mirroring the channel's existing +/// multi-line error convention. +fn write_facade_error(err: &FacadeError, stream: &mut UnixStream) { + let text = err.to_string(); + if text.contains('\n') { + for line in text.lines() { + let _ = writeln!(stream, "msg:{line}"); + } + let _ = writeln!(stream, "error: git operation failed"); + } else { + let _ = writeln!(stream, "error: {text}"); + } +} + +/// Resolves which declared repo the request targets. An explicit selector +/// picks the matching declaration (falling through to the selector itself +/// when undeclared, so [`authz::authorize`] produces its canonical +/// `RepoNotDeclared` denial); with no selector the session must declare +/// exactly one repo. +fn resolve_repo(attrs: &GithubAttrs, cmd: &GitVerbCmd) -> Result { + match cmd.selector() { + Some(sel) => Ok(attrs + .repos + .iter() + .find(|declared| declared.owner() == sel.owner() && declared.repo() == sel.repo()) + .unwrap_or(sel) + .clone()), + None => match attrs.repos.as_slice() { + [] => Err(FacadeError::NoDeclaredRepos), + [one] => Ok(one.clone()), + _ => Err(FacadeError::AmbiguousRepo { + subcommand: cmd.subcommand().to_string(), + }), + }, + } +} + +/// Locates the primed working tree for `repo`: `/` +/// (multi-repo layout), or the workspace root itself when it is the single +/// declared repo (single-repo prime / adopt-local layout). The path is always +/// derived daemon-side from the validated repo name — never from sandbox +/// input — and a validated repo name is a single, non-`..` path component, so +/// it cannot escape the workspace. +fn primed_dir( + working: &DaemonAbsPath, + declared_repos: usize, + repo: &RepoSpec, +) -> Result { + let root = working.as_utf8_path().as_std_path(); + let sub = root.join(repo.repo()); + if sub.join(".git").exists() { + return Ok(sub); + } + if declared_repos == 1 && root.join(".git").exists() { + return Ok(root.to_path_buf()); + } + Err(FacadeError::NotPrimed { + owner: repo.owner().to_string(), + name: repo.repo().to_string(), + }) +} + +/// The daemon-private directory that holds this session's clean mirrors: a +/// sibling of the sandbox-mounted workspace (`/.gh-mirror`). +/// +/// In production `working` is `/tree`, so the parent is the +/// session's own daemon-side state directory — unique per session, mounted +/// nowhere into the sandbox, and removed when the session is destroyed. Fails +/// closed with [`FacadeError::NoMirrorRoot`] if the workspace somehow has no +/// parent, rather than fall back to any sandbox-reachable location. +fn mirror_root(working: &DaemonAbsPath) -> Result { + working + .as_utf8_path() + .as_std_path() + .parent() + .map(|parent| parent.join(MIRROR_DIR)) + .ok_or(FacadeError::NoMirrorRoot) +} + +/// Renders a path as `&str` for a git argument, failing closed on non-UTF-8 +/// (all daemon-side session paths are UTF-8 `DaemonAbsPath`s in practice). +fn path_arg(path: &Path) -> Result<&str, FacadeError> { + path.to_str().ok_or_else(|| FacadeError::LocalGit { + operation: "resolve path".to_string(), + detail: format!("path is not valid UTF-8: {}", path.display()), + }) +} + +/// Builds the hardened, **token-free, local-only** git command shared by +/// [`run_local_git`] and [`local_branch_exists`]: transport pinned to `file`, +/// hooks disabled, the operator's global/system config denied, and no +/// credential anywhere in its environment. +fn local_git_command(cwd: &Path, subargs: &[&str]) -> Command { + let mut command = Command::new("git"); + command + .arg("--no-optional-locks") + // Pin transport to local files only and disable hooks. `-c` config is + // propagated to any subprocess (e.g. the source repo's `upload-pack`) + // via `GIT_CONFIG_PARAMETERS`. + .args([ + "-c", + "protocol.allow=never", + "-c", + "protocol.file.allow=always", + "-c", + "core.hooksPath=/dev/null", + ]) + .args(subargs) + .current_dir(cwd) + // No token here, ever. Disable the operator's global/system config so + // an inherited credential helper or `insteadOf` cannot fire, and never + // block on a credential prompt. + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()); + command +} + +/// Runs one **token-free, local-only** git step and streams its output as +/// `msg:` lines. This is the isolation-critical counterpart to +/// `github::gitops` (which holds the token): it carries no credential and +/// cannot reach the network — transport is pinned to `file`, and the +/// operator's global/system git config is disabled — so even when it must read +/// a sandbox-writable working-tree config (the worktree-update legs) there is +/// nothing for a hostile config to exfiltrate and nowhere off-host for it to +/// redirect to. Hooks are disabled and optional locks skipped, mirroring +/// `gitops`'s non-secret hardening. +fn run_local_git( + cwd: &Path, + operation: &str, + subargs: &[&str], + out: &mut UnixStream, +) -> Result<(), FacadeError> { + let output = + local_git_command(cwd, subargs) + .output() + .map_err(|source| FacadeError::LocalGit { + operation: operation.to_string(), + detail: format!("could not run git: {source}"), + })?; + + for line in String::from_utf8_lossy(&output.stderr).lines() { + let _ = writeln!(out, "msg:{line}"); + } + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = { + let trimmed = stderr.trim(); + if trimmed.is_empty() { + match output.status.code() { + Some(code) => format!("git exited with status {code}"), + None => "git terminated by signal".to_string(), + } + } else { + // The last stderr line is the most useful and carries no token + // (these legs never hold one). + trimmed.lines().next_back().unwrap_or(trimmed).to_string() + } + }; + Err(FacadeError::LocalGit { + operation: operation.to_string(), + detail, + }) +} + +/// The maximum symbolic-ref chase depth when resolving a worktree ref by hand. +const MAX_SYMREF_DEPTH: usize = 8; + +/// Whether `s` is a git object id: 40 (SHA-1) or 64 (SHA-256) hex digits. +/// Everything read out of a sandbox-writable ref file is validated with this +/// before it is used as a git argument, so a planted `refs/heads/x` holding +/// `--upload-pack=evil` can never become option-injection on the mirror side. +fn valid_oid(s: &str) -> bool { + matches!(s.len(), 40 | 64) && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Validates a branch name that will become both a ref-file **path component** +/// and a **git argument**. A conservative subset of `git check-ref-format`: +/// non-empty, no leading `-`, no trailing `/`, no `.lock` suffix, no `..`/`//`, +/// no `.`/`..`/empty path component, and none of git's special characters or +/// control characters. Fail-closed — an unlisted shape is rejected — so a +/// hostile `HEAD`/ref cannot escape the refs tree or smuggle an option. +fn check_safe_branch(branch: &str) -> Result<(), FacadeError> { + let unsafe_name = branch.is_empty() + || branch.starts_with('-') + || branch.ends_with('/') + || branch.ends_with(".lock") + || branch.contains("..") + || branch.contains("//") + || branch.contains(|c: char| c.is_ascii_control() || " \t\\~^:?*[".contains(c)) + || branch + .split('/') + .any(|component| component.is_empty() || component == "." || component == ".."); + if unsafe_name { + return Err(FacadeError::UnsafeBranchName { + branch: branch.to_string(), + }); + } + Ok(()) +} + +/// The `.git` directory of a primed worktree. Only a real directory is +/// accepted (never a `.git` *file*: a gitfile could redirect the git dir +/// elsewhere), failing closed otherwise. +fn worktree_git_dir(work: &Path) -> Result { + let git_dir = work.join(".git"); + if git_dir.is_dir() { + Ok(git_dir) + } else { + Err(FacadeError::LocalGit { + operation: "open worktree".to_string(), + detail: format!("{} has no .git directory", work.display()), + }) + } +} + +/// A short, non-secret local-git error for a ref read/write step. +fn ref_step_error(operation: impl Into, detail: impl Into) -> FacadeError { + FacadeError::LocalGit { + operation: operation.into(), + detail: detail.into(), + } +} + +/// Validates a ref-file value as an OID, erroring with a non-secret message. +fn validated_oid(refname: &str, value: &str) -> Result { + if valid_oid(value) { + Ok(value.to_string()) + } else { + Err(ref_step_error( + format!("read ref {refname}"), + "ref does not contain a valid object id", + )) + } +} + +/// Resolves a ref's target OID by parsing the loose ref file then `packed-refs` +/// as plain files — never by running git in the (sandbox-writable) tree. Chases +/// symbolic refs up to [`MAX_SYMREF_DEPTH`]. `Ok(None)` when the ref is absent. +/// Works on any git dir, worktree or bare mirror. +fn read_ref_oid(git_dir: &Path, refname: &str) -> Result, FacadeError> { + read_ref_oid_depth(git_dir, refname, 0) +} + +fn read_ref_oid_depth( + git_dir: &Path, + refname: &str, + depth: usize, +) -> Result, FacadeError> { + if depth > MAX_SYMREF_DEPTH { + return Err(ref_step_error( + format!("read ref {refname}"), + "symbolic ref chain too deep", + )); + } + // A loose ref file takes precedence over any packed-refs entry. + let loose = git_dir.join(refname); + if let Ok(contents) = fs::read_to_string(&loose) { + let line = contents.trim(); + if let Some(target) = line.strip_prefix("ref:") { + return read_ref_oid_depth(git_dir, target.trim(), depth + 1); + } + return validated_oid(refname, line).map(Some); + } + let packed = git_dir.join("packed-refs"); + if let Ok(contents) = fs::read_to_string(&packed) { + for entry in contents.lines() { + if entry.starts_with('#') || entry.starts_with('^') { + continue; + } + if let Some((oid, name)) = entry.split_once(' ') + && name.trim() == refname + { + return validated_oid(refname, oid.trim()).map(Some); + } + } + } + Ok(None) +} + +/// Reads the branch a worktree's `HEAD` points at, as a plain file. Errors on a +/// detached HEAD (nothing to push/pull) and validates the branch name. +fn read_head_branch(git_dir: &Path) -> Result { + let head = fs::read_to_string(git_dir.join("HEAD")) + .map_err(|source| ref_step_error("read HEAD", format!("{source}")))?; + let branch = head + .trim() + .strip_prefix("ref: refs/heads/") + .ok_or_else(|| ref_step_error("read HEAD", "HEAD is detached; check out a branch first"))?; + check_safe_branch(branch)?; + Ok(branch.to_string()) +} + +/// Writes a ref as a loose plain file (creating the ref subdirectory), +/// atomically via a temp file + rename. The daemon moves a worktree ref this +/// way — never with `git -C …` — so no worktree config is read. The +/// caller has [`check_safe_branch`]-validated the branch component of +/// `refname`, so this join cannot escape the refs tree. +fn write_loose_ref(git_dir: &Path, refname: &str, oid: &str) -> Result<(), FacadeError> { + let path = git_dir.join(refname); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| { + ref_step_error(format!("write ref {refname}"), format!("{source}")) + })?; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("ref") + .to_string(); + let tmp = path.with_file_name(format!(".{file_name}.min-{}.tmp", std::process::id())); + fs::write(&tmp, format!("{oid}\n")) + .map_err(|source| ref_step_error(format!("write ref {refname}"), format!("{source}")))?; + fs::rename(&tmp, &path).map_err(|source| { + let _ = fs::remove_file(&tmp); + ref_step_error(format!("write ref {refname}"), format!("{source}")) + }) +} + +/// Grants the daemon-private mirror read-only access to the worktree's object +/// store via `objects/info/alternates`. Objects are content-addressed and +/// inert, so this exposes **no** worktree config to the mirror's git — the +/// mechanism that lets the daemon pack/read worktree commits without ever +/// running `git upload-pack` (which would read worktree config) in the tree. +fn link_worktree_objects(mirror: &Path, worktree_git_dir: &Path) -> Result<(), FacadeError> { + let objects = worktree_git_dir.join("objects"); + let objects = path_arg(&objects)?; + let info = mirror.join("objects").join("info"); + fs::create_dir_all(&info) + .map_err(|source| ref_step_error("link worktree objects", format!("{source}")))?; + fs::write(info.join("alternates"), format!("{objects}\n")) + .map_err(|source| ref_step_error("link worktree objects", format!("{source}"))) +} + +/// Runs one hardened, token-free git command in a **daemon-private** directory +/// (the mirror — never the sandbox worktree), capturing stdout. Shares +/// [`local_git_command`]'s hardening (global/system config denied, hooks off, +/// no credential prompt). `stdin` is fed when present (for `pack-objects +/// --revs`). The `detail` on failure is the last non-secret stderr line. +fn mirror_git_capture( + cwd: &Path, + operation: &str, + subargs: &[&str], + stdin: Option<&str>, +) -> Result { + let mut command = local_git_command(cwd, subargs); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + if stdin.is_some() { + command.stdin(Stdio::piped()); + } + let mut child = command + .spawn() + .map_err(|source| ref_step_error(operation, format!("could not run git: {source}")))?; + if let Some(input) = stdin + && let Some(mut sink) = child.stdin.take() + { + let _ = sink.write_all(input.as_bytes()); + } + let output = child + .wait_with_output() + .map_err(|source| ref_step_error(operation, format!("{source}")))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr + .trim() + .lines() + .next_back() + .unwrap_or("git failed") + .to_string(); + Err(ref_step_error(operation, detail)) +} + +/// The `(branch, oid)` heads of the daemon-private mirror. Read with a +/// mirror-side `for-each-ref` (daemon-authored config), never from the +/// worktree. Unsafe branch names / invalid OIDs are skipped defensively. +fn mirror_heads(mirror: &Path) -> Result, FacadeError> { + let out = mirror_git_capture( + mirror, + "list mirror heads", + &[ + "for-each-ref", + "--format=%(objectname) %(refname)", + "refs/heads", + ], + None, + )?; + Ok(out + .lines() + .filter_map(|line| line.split_once(' ')) + .filter_map(|(oid, refname)| { + let branch = refname.strip_prefix("refs/heads/")?; + (valid_oid(oid) && check_safe_branch(branch).is_ok()) + .then(|| (branch.to_string(), oid.to_string())) + }) + .collect()) +} + +/// Copies the objects reachable from `tips` out of the mirror and into the +/// worktree's object store **without running git in the worktree**. +/// `pack-objects` runs *in the mirror* (daemon-authored config) and writes a +/// self-contained pack to a daemon-private temp; the resulting `.pack`/`.idx` +/// are copied into `/.git/objects/pack` as plain, inert files. A +/// no-op when there are no valid tips. +fn import_objects_into_worktree( + mirror: &Path, + worktree_git_dir: &Path, + tips: &[String], +) -> Result<(), FacadeError> { + let stdin: String = tips + .iter() + .filter(|t| valid_oid(t)) + .map(|t| format!("{t}\n")) + .collect(); + if stdin.is_empty() { + return Ok(()); + } + let staging = mirror.join(format!(".pack-import-{}.tmp", std::process::id())); + fs::create_dir_all(&staging) + .map_err(|source| ref_step_error("import objects", format!("{source}")))?; + let result = import_objects_inner(mirror, worktree_git_dir, &staging, &stdin); + let _ = fs::remove_dir_all(&staging); + result +} + +fn import_objects_inner( + mirror: &Path, + worktree_git_dir: &Path, + staging: &Path, + stdin: &str, +) -> Result<(), FacadeError> { + let base = staging.join("pack"); + let base_arg = path_arg(&base)?; + // A full pack reachable from the tips: self-contained (no `--thin`), so the + // `.pack`/`.idx` are valid in the worktree object store on their own. + mirror_git_capture( + mirror, + "pack objects", + &["pack-objects", "--revs", "--delta-base-offset", base_arg], + Some(stdin), + )?; + let dest = worktree_git_dir.join("objects").join("pack"); + fs::create_dir_all(&dest) + .map_err(|source| ref_step_error("import objects", format!("{source}")))?; + // Copy the `.pack` before the `.idx`: a reader that sees an `.idx` treats + // the pack as usable, so the data file must already be in place. + for extension in ["pack", "idx"] { + for entry in fs::read_dir(staging) + .map_err(|source| ref_step_error("import objects", format!("{source}")))? + { + let path = entry + .map_err(|source| ref_step_error("import objects", format!("{source}")))? + .path(); + let matches = path.extension().and_then(|e| e.to_str()) == Some(extension); + if let (true, Some(name)) = (matches, path.file_name()) { + fs::copy(&path, dest.join(name)) + .map_err(|source| ref_step_error("import objects", format!("{source}")))?; + } + } + } + Ok(()) +} + +/// Whether `new` is a descendant of (or equal to) `old`, decided in the mirror +/// with `merge-base --is-ancestor` (worktree-only commits resolved via the +/// object alternate). A non-zero exit — not-an-ancestor or a bad object — is a +/// non-fast-forward. +fn is_fast_forward(mirror: &Path, old: &str, new: &str) -> Result { + if old == new { + return Ok(true); + } + let status = local_git_command(mirror, &["merge-base", "--is-ancestor", old, new]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|source| { + ref_step_error("check fast-forward", format!("could not run git: {source}")) + })?; + Ok(status.success()) +} + +/// `(ahead, behind)` of `local` relative to `upstream`, computed in the mirror +/// over the worktree object alternate (`rev-list --left-right --count +/// upstream...local` prints `\t`). +fn count_ahead_behind( + mirror: &Path, + upstream: &str, + local: &str, +) -> Result<(usize, usize), FacadeError> { + let range = format!("{upstream}...{local}"); + let out = mirror_git_capture( + mirror, + "count ahead/behind", + &["rev-list", "--left-right", "--count", range.as_str()], + None, + )?; + let mut counts = out.split_whitespace(); + let behind = counts.next().and_then(|n| n.parse().ok()); + let ahead = counts.next().and_then(|n| n.parse().ok()); + match (behind, ahead) { + (Some(behind), Some(ahead)) => Ok((ahead, behind)), + _ => Err(ref_step_error( + "count ahead/behind", + "could not parse ahead/behind counts", + )), + } +} + +/// Ahead/behind of the checked-out branch versus its `origin/` tracking +/// ref, computed in a mirror over the worktree objects (alternate). Extracted +/// from [`execute`]'s `status` arm so it has no immediately-invoked closure. +fn worktree_ahead_behind( + mirror_root: &Path, + repo: &RepoSpec, + remote: &Url, + git_dir: &Path, + upstream: &str, + local: &str, + out: &mut UnixStream, +) -> Result<(usize, usize), FacadeError> { + let mirror = ensure_mirror(mirror_root, repo, remote, out)?; + link_worktree_objects(&mirror, git_dir)?; + count_ahead_behind(&mirror, upstream, local) +} + +/// Ensures a clean, daemon-authored bare mirror exists for `repo` under +/// `root`, and returns its path. The mirror's `origin` is (re-)pointed at the +/// `canonical` URL on every call so the config the token leg later reads is +/// always the daemon's, never anything a prior sandbox action could have +/// influenced. Created bare and empty; objects arrive only via explicit, +/// daemon-issued transfers. +fn ensure_mirror( + root: &Path, + repo: &RepoSpec, + canonical: &Url, + out: &mut UnixStream, +) -> Result { + fs::create_dir_all(root).map_err(|source| FacadeError::LocalGit { + operation: "create mirror root".to_string(), + detail: format!("{source}"), + })?; + // `owner`/`repo` are validated single path components (no `/`, no `..`), + // so this name cannot escape `root`. + let dir = root.join(format!("{}__{}.git", repo.owner(), repo.repo())); + + if !dir.join("HEAD").exists() { + let dir_arg = path_arg(&dir)?; + run_local_git( + root, + "init mirror", + &["init", "--bare", "--quiet", dir_arg], + out, + )?; + } + // `config` (not `remote add`/`set-url`) is idempotent and daemon-authored: + // it sets the value whether or not it already existed, so a partially + // initialised mirror still converges to the canonical origin. + run_local_git( + &dir, + "configure mirror origin", + &["config", "remote.origin.url", canonical.as_str()], + out, + )?; + run_local_git( + &dir, + "configure mirror fetch", + &[ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/heads/*", + ], + out, + )?; + Ok(dir) +} + +/// Whether the daemon-private repo at `dir` has a local branch named `branch`. +/// A quiet, token-free, local-only probe with the same hardening as +/// [`run_local_git`]. Used only against the mirror, whose heads (after a +/// fetch) are exactly the canonical remote's — so this doubles as the +/// "does the branch exist on the remote?" check without another network op. +fn local_branch_exists(dir: &Path, branch: &str) -> Result { + // Always probed as a fully-qualified ref, so a `-`-leading branch name can + // never be parsed as an option here. + let refname = format!("refs/heads/{branch}"); + local_git_command(dir, &["rev-parse", "--verify", "--quiet", &refname]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .map_err(|source| FacadeError::LocalGit { + operation: "probe mirror branch".to_string(), + detail: format!("could not run git: {source}"), + }) +} + +/// The branch a fresh clone's worktree starts on (spec R2.2), decided +/// daemon-side against the already-fetched mirror. +#[derive(Debug)] +enum CloneTarget { + /// The branch exists on the remote (present in the mirror after the + /// fetch); check it out tracking `origin/`. + Existing { + /// The remote branch to check out. + branch: String, + }, + /// The branch is absent on the remote; create it locally from `base`, + /// with no upstream — it is never pushed implicitly (spec R2.5). + CreateFromBase { + /// The new local branch. + branch: String, + /// The remote branch it starts from. + base: String, + }, +} + +impl CloneTarget { + /// The branch the finished worktree ends up on. + fn branch(&self) -> &str { + match self { + Self::Existing { branch } | Self::CreateFromBase { branch, .. } => branch, + } + } +} + +/// Resolves the [`CloneTarget`] for a declared repo: its declared branch if it +/// exists on the remote; otherwise created from the declared base (or the +/// remote's default branch); with no declared branch, the remote's default. +/// +/// `mirror_tree` must be the **mirror**, already fetched: branch existence is +/// a local probe against the mirror's heads, and the one network op — the +/// default-branch `ls-remote` — runs in the mirror too, reading only its +/// daemon-authored config (never the workspace's). +fn clone_target( + mirror_tree: &Repo, + repo: &RepoSpec, + token: Option<&SecretString>, +) -> Result { + let mirror = mirror_tree.work_dir(); + let target = match repo.branch() { + Some(branch) if local_branch_exists(mirror, branch)? => CloneTarget::Existing { + branch: branch.to_string(), + }, + Some(branch) => { + let base = match repo.base() { + Some(base) => base.to_string(), + None => mirror_tree.default_branch(token)?, + }; + if !local_branch_exists(mirror, &base)? { + return Err(github::gitops::GitError::BaseBranchNotFound { branch: base }.into()); + } + CloneTarget::CreateFromBase { + branch: branch.to_string(), + base, + } + } + None => CloneTarget::Existing { + branch: mirror_tree.default_branch(token)?, + }, + }; + // The branch name becomes a bare `git checkout` argument on the local leg; + // a leading dash would parse as an option (argv injection). The declared + // spec's ref validation is strict but does not exclude a leading `-`. + if target.branch().starts_with('-') { + return Err(FacadeError::UnsafeBranchName { + branch: target.branch().to_string(), + }); + } + Ok(target) +} + +/// A unique temp directory under the daemon-private mirror root for staging a +/// clone's worktree — same filesystem as the workspace in production (both +/// live under the session dir), so the finalizing rename is atomic. +fn unique_clone_temp(root: &Path, repo: &RepoSpec) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + root.join(format!( + ".clone-{}__{}-{}-{nanos}.tmp", + repo.owner(), + repo.repo(), + std::process::id() + )) +} + +/// Builds the sandbox-facing worktree for a fresh clone entirely inside a temp +/// directory under the daemon-private mirror `root`, then renames it to `dest` +/// in one step. Every git process here is local-only and token-free; the tree +/// the sandbox eventually sees is complete — `origin` at the canonical URL, +/// `target` checked out — *before* it becomes sandbox-reachable, so no later +/// git step (least of all a token-bearing one) ever runs inside it. Failure at +/// any step removes the temp and leaves the workspace untouched (spec R2.6). +fn materialize_worktree( + root: &Path, + mirror: &Path, + canonical: &Url, + target: &CloneTarget, + dest: &Path, + repo: &RepoSpec, + out: &mut UnixStream, +) -> Result<(), FacadeError> { + let temp = unique_clone_temp(root, repo); + if let Err(err) = build_worktree(&temp, mirror, canonical, target, out) { + let _ = fs::remove_dir_all(&temp); + return Err(err); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|source| { + let _ = fs::remove_dir_all(&temp); + FacadeError::LocalGit { + operation: "prepare workspace".to_string(), + detail: format!("could not create the workspace directory: {source}"), + } + })?; + } + fs::rename(&temp, dest).map_err(|source| { + let _ = fs::remove_dir_all(&temp); + FacadeError::LocalGit { + operation: "finalize clone".to_string(), + detail: format!("could not move the clone into the workspace: {source}"), + } + }) +} + +/// The fallible steps of [`materialize_worktree`], separated so a failure at +/// any point cleans up the temp directory exactly once. +fn build_worktree( + temp: &Path, + mirror: &Path, + canonical: &Url, + target: &CloneTarget, + out: &mut UnixStream, +) -> Result<(), FacadeError> { + let mirror_arg = path_arg(mirror)?; + let temp_arg = path_arg(temp)?; + // `--no-checkout`: the branch decision is already made in `target`. + // `--no-hardlinks`: never share object inodes between the daemon-private + // mirror and the soon-to-be-sandbox-writable tree. + run_local_git( + mirror, + "clone from mirror", + &[ + "clone", + "--quiet", + "--no-checkout", + "--no-hardlinks", + "--origin", + "origin", + mirror_arg, + temp_arg, + ], + out, + )?; + // The finished tree presents the clean canonical URL as `origin` (R6.1: + // no credential material), exactly as a direct clone would — but no + // token-bearing git will ever read it back (see the module docs). + run_local_git( + temp, + "set clone origin", + &["remote", "set-url", "origin", canonical.as_str()], + out, + )?; + match target { + CloneTarget::Existing { branch } => { + let origin_ref = format!("origin/{branch}"); + run_local_git( + temp, + "checkout", + &["checkout", "--quiet", "-B", branch, "--track", &origin_ref], + out, + ) + } + CloneTarget::CreateFromBase { branch, base } => { + let origin_base = format!("origin/{base}"); + // `--no-track`: a created branch has no upstream until explicitly + // pushed (spec R2.5) — the PR-able signal depends on it. + run_local_git( + temp, + "checkout", + &[ + "checkout", + "--quiet", + "-b", + branch, + "--no-track", + &origin_base, + ], + out, + ) + } + } +} + +/// Runs the authorized operation (on the blocking pool), writing scrubbed +/// output as `msg:` lines directly onto the connection clone. The credentialed +/// legs go through `github::gitops` against the daemon-owned clean mirror; the +/// token never touches the sandbox working tree's config (see the module docs). +fn execute( + cmd: &GitVerbCmd, + repo: &RepoSpec, + remote: &Url, + token: Option<&SecretString>, + working: &DaemonAbsPath, + declared_repos: usize, + mut out: UnixStream, +) -> Result<(), FacadeError> { + let owner_repo = format!("{}/{}", repo.owner(), repo.repo()); + match cmd { + GitVerbCmd::Push { .. } => { + let work = primed_dir(working, declared_repos, repo)?; + let git_dir = worktree_git_dir(&work)?; + // Branch + tip read as plain files: no git runs in the worktree, so + // no worktree config is executed. `read_head_branch` validates the + // name (rejects a leading-dash / escaping branch) and rejects a + // detached HEAD. + let branch = read_head_branch(&git_dir)?; + let branch_ref = format!("refs/heads/{branch}"); + let tip = read_ref_oid(&git_dir, &branch_ref)?.ok_or_else(|| { + ref_step_error( + "read branch tip", + format!("branch `{branch}` has no commits"), + ) + })?; + + let mirror = ensure_mirror(&mirror_root(working)?, repo, remote, &mut out)?; + // The only bridge from the sandbox tree to the credentialed leg: + // read-only object access (inert) + the tip written as a mirror ref + // (plain file). No `git upload-pack` ever runs in the worktree. + link_worktree_objects(&mirror, &git_dir)?; + write_loose_ref(&mirror, &branch_ref, &tip)?; + + // Credentialed leg: push mirror → canonical. `gitops` runs in the + // mirror and reads only the daemon-authored mirror config; the tip's + // objects are reachable via the alternate. + Repo::open(mirror.clone(), remote.as_str()).push(token, &branch, |_, line| { + let _ = writeln!(out, "msg:{line}"); + })?; + + // Reflect the push in the worktree's remote-tracking ref (plain + // file; the pushed objects already live in the worktree). Purely + // local; failure here does not undo the successful push. + let _ = write_loose_ref(&git_dir, &format!("refs/remotes/origin/{branch}"), &tip); + let _ = writeln!(out, "msg:pushed `{branch}` to origin ({owner_repo})"); + } + GitVerbCmd::Fetch { .. } => { + let work = primed_dir(working, declared_repos, repo)?; + let git_dir = worktree_git_dir(&work)?; + let mirror = ensure_mirror(&mirror_root(working)?, repo, remote, &mut out)?; + // Credentialed leg: canonical → mirror (mirror config only). Objects + // accumulate in the mirror across fetches, so this stays incremental + // without ever touching the worktree. + Repo::open(mirror.clone(), remote.as_str()).fetch(token, |_, line| { + let _ = writeln!(out, "msg:{line}"); + })?; + // Reflect every canonical head into the worktree with plain-file + // ops: import the objects (packed in the mirror) then write + // `refs/remotes/origin/*`. No git runs in the worktree. + let heads = mirror_heads(&mirror)?; + let tips: Vec = heads.iter().map(|(_, oid)| oid.clone()).collect(); + import_objects_into_worktree(&mirror, &git_dir, &tips)?; + for (branch, oid) in &heads { + write_loose_ref(&git_dir, &format!("refs/remotes/origin/{branch}"), oid)?; + } + let _ = writeln!(out, "msg:fetched origin ({owner_repo})"); + } + GitVerbCmd::Pull { .. } => { + let work = primed_dir(working, declared_repos, repo)?; + let git_dir = worktree_git_dir(&work)?; + let branch = read_head_branch(&git_dir)?; + let branch_ref = format!("refs/heads/{branch}"); + let local_tip = read_ref_oid(&git_dir, &branch_ref)?.ok_or_else(|| { + ref_step_error( + "read branch tip", + format!("branch `{branch}` has no commits"), + ) + })?; + + let mirror = ensure_mirror(&mirror_root(working)?, repo, remote, &mut out)?; + // Credentialed leg: canonical → mirror (mirror config only). + Repo::open(mirror.clone(), remote.as_str()).fetch(token, |_, line| { + let _ = writeln!(out, "msg:{line}"); + })?; + let new_tip = + read_ref_oid(&mirror, &branch_ref)?.ok_or_else(|| FacadeError::NoRemoteBranch { + branch: branch.clone(), + })?; + + if new_tip == local_tip { + let _ = writeln!(out, "msg:already up to date ({owner_repo})"); + } else { + // Fast-forward only, decided in the mirror over the worktree + // objects (alternate) — the facade never merges/rebases in the + // sandbox-writable tree. + link_worktree_objects(&mirror, &git_dir)?; + if !is_fast_forward(&mirror, &local_tip, &new_tip)? { + return Err(FacadeError::NotFastForward { branch }); + } + import_objects_into_worktree(&mirror, &git_dir, std::slice::from_ref(&new_tip))?; + // Advance the checked-out branch and its tracking ref as plain + // files. The working-tree files re-materialize on the sandbox's + // own next checkout; no daemon git touches the tree. + write_loose_ref(&git_dir, &branch_ref, &new_tip)?; + write_loose_ref(&git_dir, &format!("refs/remotes/origin/{branch}"), &new_tip)?; + let _ = writeln!( + out, + "msg:fast-forwarded `{branch}` to origin ({owner_repo})" + ); + } + } + GitVerbCmd::Status { .. } => { + // Local-only, no token, no remote contact — and no git in the + // worktree: branch from `HEAD`, tips from the ref files. + let dir = primed_dir(working, declared_repos, repo)?; + let git_dir = worktree_git_dir(&dir)?; + let Ok(branch) = read_head_branch(&git_dir) else { + let _ = writeln!(out, "msg:{owner_repo}: HEAD is detached"); + return Ok(()); + }; + let _ = writeln!(out, "msg:{owner_repo}: on branch `{branch}`"); + let local_tip = read_ref_oid(&git_dir, &format!("refs/heads/{branch}"))?; + let upstream = read_ref_oid(&git_dir, &format!("refs/remotes/origin/{branch}"))?; + match (local_tip, upstream) { + (Some(local), Some(up)) => { + // Ahead/behind computed in the mirror over the worktree + // objects (alternate). Degrade gracefully rather than fail + // the whole status if the comparison cannot be made. + let counts = worktree_ahead_behind( + &mirror_root(working)?, + repo, + remote, + &git_dir, + &up, + &local, + &mut out, + ); + match counts { + Ok((ahead, behind)) => { + let _ = writeln!( + out, + "msg:ahead of origin/{branch} by {ahead} commit(s), behind by {behind}" + ); + } + Err(_) => { + let _ = writeln!(out, "msg:upstream relationship not recognized"); + } + } + } + _ => { + let _ = writeln!( + out, + "msg:no upstream: the branch has never been pushed \ + (`min git push` publishes it)" + ); + } + } + } + GitVerbCmd::RemoteShow { .. } => { + // Reported from the daemon's own derivation — the session's one + // legitimate origin — rather than by running git in a + // sandbox-writable tree. + let _ = writeln!(out, "msg:origin\t{remote} (fetch)"); + let _ = writeln!(out, "msg:origin\t{remote} (push)"); + } + GitVerbCmd::Clone { .. } => { + // Always into `/`; the root-prime layout is + // the activation flow's business, not the facade's. The clone runs + // the full mirror discipline: the moment a clone lands in the + // workspace its `.git/config` is sandbox-writable, so every + // token-bearing step (fetch, default-branch probe) happens in the + // daemon-private mirror *first*, and the worktree is materialized + // from the mirror over token-free local legs, appearing in the + // workspace fully formed (see the module docs). + let dest = working.as_utf8_path().as_std_path().join(repo.repo()); + if dest.exists() { + // Fail closed: whatever is here, the sandbox may have planted + // it (hostile config included). Never adopt or touch it. + return Err(github::gitops::GitError::DestinationExists { + path: dest.display().to_string(), + } + .into()); + } + let root = mirror_root(working)?; + let mirror = ensure_mirror(&root, repo, remote, &mut out)?; + let mirror_tree = Repo::open(mirror.clone(), remote.as_str()); + // Credentialed leg: every canonical head → mirror. Runs in the + // mirror; reads only its daemon-authored config. + mirror_tree.fetch(token, |_, line| { + let _ = writeln!(out, "msg:{line}"); + })?; + let target = clone_target(&mirror_tree, repo, token)?; + materialize_worktree(&root, &mirror, remote, &target, &dest, repo, &mut out)?; + let _ = writeln!(out, "msg:cloned {owner_repo} into `{}`", repo.repo()); + if repo.branch().is_some() { + let what = match &target { + CloneTarget::Existing { .. } => "checked out", + CloneTarget::CreateFromBase { .. } => "created", + }; + let _ = writeln!(out, "msg:{what} branch `{}`", target.branch()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Asserts that `argv` is rejected by the allowlist and that the denial + /// mentions `expect_in_reason`. + fn assert_rejected(argv: &str, expect_in_reason: &str) { + let err = parse_git_argv(argv) + .expect_err(&format!("argv must be rejected fail-closed: {argv:?}")); + assert!( + matches!(err, FacadeError::NotPermitted { .. }), + "expected NotPermitted for {argv:?}, got {err:?}" + ); + let msg = err.to_string(); + assert!( + msg.contains(expect_in_reason), + "denial for {argv:?} should mention {expect_in_reason:?}: {msg}" + ); + } + + // ---- the security-boundary negatives (see the module docs) ---- + + #[test] + fn rejects_config_injection_options() { + assert_rejected("-c core.hooksPath=/tmp/x push", "-c"); + assert_rejected("--config-env=core.askpass=X push", "--config-env"); + assert_rejected("push --config-env=core.askpass=X", "--config-env"); + } + + #[test] + fn rejects_program_steering_options() { + assert_rejected("--upload-pack=/tmp/evil fetch", "--upload-pack"); + assert_rejected("fetch --upload-pack=/tmp/evil", "--upload-pack"); + assert_rejected("push --receive-pack=/tmp/evil", "--receive-pack"); + assert_rejected("--exec-path=/tmp/evil push", "--exec-path"); + } + + #[test] + fn rejects_repository_relocation_options() { + assert_rejected("-C /tmp push", "-C"); + assert_rejected("--git-dir=/tmp/other/.git push", "--git-dir"); + assert_rejected("--work-tree=/ push", "--work-tree"); + } + + #[test] + fn rejects_double_dash_and_everything_after_it() { + assert_rejected("push -- main", "--"); + assert_rejected("-- push", "--"); + } + + #[test] + fn rejects_path_escapes_as_clone_targets() { + assert_rejected("clone ../outside", "owner/repo"); + assert_rejected("clone /etc/passwd", "owner/repo"); + assert_rejected("clone ../../root/x", "owner/repo"); + assert_rejected("clone owner/repo/extra", "owner/repo"); + } + + #[test] + fn rejects_urls_as_clone_targets() { + assert_rejected("clone https://github.com/a/b.git", "branch selectors"); + assert_rejected("clone ssh://git@host/a/b", "branch selectors"); + assert_rejected("clone git@github.com:a/b.git", "branch selectors"); + } + + #[test] + fn rejects_branch_selectors_on_selectors() { + assert_rejected("push octo/hello@feat/x", "branch selectors"); + assert_rejected("clone octo/hello@feat/x", "branch selectors"); + } + + #[test] + fn rejects_unknown_subcommands() { + for argv in [ + "rev-parse HEAD", + "config user.name evil", + "checkout main", + "daemon", + "gc", + "submodule update --init", + "PUSH", // case-sensitive: only the exact keyword passes + ] { + assert_rejected(argv, "not available"); + } + } + + #[test] + fn rejects_remote_mutations() { + assert_rejected("remote add evil https://evil.example/x.git", "too many"); + assert_rejected( + "remote set-url origin https://evil.example/x.git", + "too many", + ); + assert_rejected("remote -v -v", "-v"); + } + + #[test] + fn rejects_extra_arguments() { + assert_rejected("push origin main", "too many"); + assert_rejected("clone octo/hello extra", "too many"); + assert_rejected("clone", "clone needs a declared repo"); + assert_rejected("", "missing git subcommand"); + } + + #[test] + fn rejects_flags_in_selector_position() { + assert_rejected("push --force", "--force"); + assert_rejected("push -f", "-f"); + assert_rejected("fetch --all", "--all"); + assert_rejected("pull --rebase", "--rebase"); + } + + // ---- the accepted grammar ---- + + #[test] + fn accepts_the_allowlisted_shapes() { + let repo: RepoSpec = "octo/hello".parse().unwrap(); + assert_eq!( + parse_git_argv("push").unwrap(), + GitVerbCmd::Push { repo: None } + ); + assert_eq!( + parse_git_argv("push octo/hello").unwrap(), + GitVerbCmd::Push { + repo: Some(repo.clone()) + } + ); + assert_eq!( + parse_git_argv("pull").unwrap(), + GitVerbCmd::Pull { repo: None } + ); + assert_eq!( + parse_git_argv("fetch").unwrap(), + GitVerbCmd::Fetch { repo: None } + ); + assert_eq!( + parse_git_argv("status").unwrap(), + GitVerbCmd::Status { repo: None } + ); + assert_eq!( + parse_git_argv("remote").unwrap(), + GitVerbCmd::RemoteShow { repo: None } + ); + assert_eq!( + parse_git_argv("remote -v").unwrap(), + GitVerbCmd::RemoteShow { repo: None } + ); + assert_eq!( + parse_git_argv("remote -v octo/hello").unwrap(), + GitVerbCmd::RemoteShow { + repo: Some(repo.clone()) + } + ); + assert_eq!( + parse_git_argv("clone octo/hello").unwrap(), + GitVerbCmd::Clone { repo } + ); + // Surrounding whitespace is insignificant. + assert_eq!( + parse_git_argv(" push ").unwrap(), + GitVerbCmd::Push { repo: None } + ); + } + + #[test] + fn permissions_map_write_for_push_read_otherwise() { + assert_eq!( + parse_git_argv("push").unwrap().permission(), + Permission::push() + ); + for argv in ["pull", "fetch", "status", "remote -v", "clone o/r"] { + assert_eq!( + parse_git_argv(argv).unwrap().permission(), + Permission::read_contents(), + "{argv} must require only contents:read" + ); + } + } + + #[test] + fn local_only_ops_need_no_token() { + assert!(!parse_git_argv("status").unwrap().needs_token()); + assert!(!parse_git_argv("remote -v").unwrap().needs_token()); + for argv in ["push", "pull", "fetch", "clone o/r"] { + assert!(parse_git_argv(argv).unwrap().needs_token(), "{argv}"); + } + } + + // ---- URL derivation & mirror-root isolation ---- + + #[test] + fn remote_url_joins_base_owner_repo() { + let repo: RepoSpec = "octo/hello".parse().unwrap(); + let base = Url::parse("https://github.com/").unwrap(); + assert_eq!( + remote_url(&base, &repo).unwrap().as_str(), + "https://github.com/octo/hello.git" + ); + // A base missing its trailing slash still resolves as a directory. + let base = Url::parse("http://127.0.0.1:9999/git").unwrap(); + assert_eq!( + remote_url(&base, &repo).unwrap().as_str(), + "http://127.0.0.1:9999/git/octo/hello.git" + ); + } + + #[test] + fn mirror_root_is_a_sibling_of_the_workspace() { + let working = + DaemonAbsPath::try_new("/var/lib/minimald/sessions/abcd/tree").expect("abs path"); + let root = mirror_root(&working).expect("workspace has a parent"); + assert_eq!( + root, + std::path::Path::new("/var/lib/minimald/sessions/abcd").join(MIRROR_DIR), + "the mirror must live outside the sandbox-mounted workspace tree" + ); + } + + #[test] + fn facade_error_messages_are_actionable_and_secret_free() { + let messages = [ + not_permitted("git option `--upload-pack=/x` is not permitted through `min git`") + .to_string(), + FacadeError::NotPrimed { + owner: "octo".into(), + name: "hello".into(), + } + .to_string(), + FacadeError::LocalGit { + operation: "stage push".into(), + detail: "fatal: could not read from remote repository".into(), + } + .to_string(), + FacadeError::AmbiguousRepo { + subcommand: "push".into(), + } + .to_string(), + FacadeError::NoDeclaredRepos.to_string(), + FacadeError::NoMirrorRoot.to_string(), + FacadeError::UnsafeBranchName { + branch: "-oProxyCommand=evil".into(), + } + .to_string(), + FacadeError::NoRemoteBranch { + branch: "feat/x".into(), + } + .to_string(), + FacadeError::NotFastForward { + branch: "feat/x".into(), + } + .to_string(), + ]; + for message in messages { + assert!(!message.is_empty()); + for needle in ["ghu_", "gho_", "ghp_", "access_token", "refresh_token"] { + assert!( + !message.contains(needle), + "message must not contain {needle:?}: {message}" + ); + } + } + } + + // ---- plain-file ref reading/writing & OID/branch validation ---- + + #[test] + fn valid_oid_accepts_shas_and_rejects_everything_else() { + assert!(valid_oid(&"a".repeat(40))); + assert!(valid_oid(&"0".repeat(64))); + assert!(valid_oid("9dbd6e01f4657f834203ed1b4da152d704ddeaec")); + assert!(!valid_oid(&"a".repeat(39))); + assert!(!valid_oid(&"a".repeat(41))); + // An option-shaped or path-shaped ref-file value is never an OID, so it + // can never reach a git argument via `read_ref_oid`. + assert!(!valid_oid("--upload-pack=/tmp/evil")); + assert!(!valid_oid("ref: refs/heads/main")); + assert!(!valid_oid(&format!("{}z", "a".repeat(39)))); + } + + #[test] + fn check_safe_branch_accepts_normal_and_rejects_dangerous() { + for ok in ["main", "feat/x", "release/1.2.x", "user.name/topic"] { + check_safe_branch(ok).unwrap_or_else(|_| panic!("{ok} must be accepted")); + } + for bad in [ + "", + "-oProxyCommand=evil", + "..", + "a/../b", + "a//b", + "feat/", + "has space", + "ctrl\tchar", + "weird~ref", + "x.lock", + "back\\slash", + ] { + assert!( + matches!( + check_safe_branch(bad), + Err(FacadeError::UnsafeBranchName { .. }) + ), + "{bad:?} must be rejected" + ); + } + } + + #[test] + fn reads_head_and_refs_as_plain_files() { + let dir = tempfile::tempdir().expect("tmp"); + let git_dir = dir.path(); + let oid = "9dbd6e01f4657f834203ed1b4da152d704ddeaec"; + + fs::write(git_dir.join("HEAD"), "ref: refs/heads/feat/x\n").unwrap(); + fs::create_dir_all(git_dir.join("refs").join("heads").join("feat")).unwrap(); + fs::write(git_dir.join("refs/heads/feat/x"), format!("{oid}\n")).unwrap(); + + assert_eq!(read_head_branch(git_dir).unwrap(), "feat/x"); + assert_eq!( + read_ref_oid(git_dir, "refs/heads/feat/x") + .unwrap() + .as_deref(), + Some(oid) + ); + // A missing ref is `None`, not an error. + assert!( + read_ref_oid(git_dir, "refs/remotes/origin/feat/x") + .unwrap() + .is_none() + ); + + // packed-refs fallback for a ref with no loose file. + let packed_oid = "0000000000000000000000000000000000000abc"; + fs::write( + git_dir.join("packed-refs"), + format!("# pack-refs with: peeled\n{packed_oid} refs/heads/main\n"), + ) + .unwrap(); + assert_eq!( + read_ref_oid(git_dir, "refs/heads/main").unwrap().as_deref(), + Some(packed_oid) + ); + } + + #[test] + fn detached_and_unsafe_head_are_refused() { + let dir = tempfile::tempdir().expect("tmp"); + let git_dir = dir.path(); + + fs::write( + git_dir.join("HEAD"), + "9dbd6e01f4657f834203ed1b4da152d704ddeaec\n", + ) + .unwrap(); + assert!( + read_head_branch(git_dir).is_err(), + "detached HEAD must fail" + ); + + fs::write(git_dir.join("HEAD"), "ref: refs/heads/-oEvil\n").unwrap(); + assert!( + matches!( + read_head_branch(git_dir), + Err(FacadeError::UnsafeBranchName { .. }) + ), + "a dash-leading HEAD branch must be refused" + ); + } + + #[test] + fn write_loose_ref_roundtrips_and_creates_subdirs() { + let dir = tempfile::tempdir().expect("tmp"); + let git_dir = dir.path(); + let oid = "9dbd6e01f4657f834203ed1b4da152d704ddeaec"; + + write_loose_ref(git_dir, "refs/remotes/origin/feat/x", oid).unwrap(); + assert_eq!( + read_ref_oid(git_dir, "refs/remotes/origin/feat/x") + .unwrap() + .as_deref(), + Some(oid) + ); + // No stray temp file is left behind next to the ref. + let leftovers: Vec<_> = fs::read_dir(git_dir.join("refs/remotes/origin/feat")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp ref file not cleaned up"); + } + + #[test] + fn read_ref_oid_rejects_a_planted_option_shaped_value() { + let dir = tempfile::tempdir().expect("tmp"); + let git_dir = dir.path(); + fs::create_dir_all(git_dir.join("refs").join("heads")).unwrap(); + // A hostile worktree plants an option string where an OID belongs; the + // reader refuses it rather than let it flow into a git argument. + fs::write(git_dir.join("refs/heads/main"), "--upload-pack=/tmp/evil\n").unwrap(); + assert!( + read_ref_oid(git_dir, "refs/heads/main").is_err(), + "a non-OID ref value must be rejected" + ); + } +} diff --git a/crates/minimald/src/github/mod.rs b/crates/minimald/src/github/mod.rs new file mode 100644 index 000000000..6bc982830 --- /dev/null +++ b/crates/minimald/src/github/mod.rs @@ -0,0 +1,34 @@ +//! GitHub integration for `minimald` sessions (spec 10). +//! +//! This module tree is the daemon-side half of spec 10: `minimald` is the +//! GitHub App's OAuth client, holds the resulting tokens, and mediates every +//! GitHub-touching operation a session performs so that a token never enters +//! the sandbox (see the PRD's "Security model" section). +//! +//! [`state::GithubService`] is the composition root — one shared instance, +//! constructed at daemon startup and hung off `ServerStateHandle` (see +//! `crate::server`) — that every other submodule here is built to consume. +//! The submodules are pre-registered as an explicit fan-out target: each +//! owns one disjoint file, so the daemon tasks that implement them +//! (`daemon-auth-rpcs`, `daemon-authz`, `daemon-prime-repos`, +//! `daemon-push-pr`, `daemon-facade-git`) can land in parallel without +//! touching each other's files. +//! +//! - [`rpcs`] — the `GithubBeginLogin`/`GithubPollLogin`/`GithubStatus`/ +//! `GithubListAuths`/`GithubLogout` oneshot RPC handlers (spec R1.1–R1.4). +//! - [`authz`] — the single `authorize(&Record, repo, permission)` choke +//! point every authenticated op must pass through (spec R5.4). +//! - [`prime`] — the `GithubPrimeRepos` activation handler: clone/adopt + +//! checkout-or-create (spec R2). +//! - [`push_pr`] — `GithubPush`/`GithubCreatePr`/`GetSessionGitState` (spec +//! R3.4, R4). +//! - [`facade`] — the in-sandbox `min git`/`min api` verb dispatch (spec R3). + +pub mod authz; +pub mod facade; +pub mod prime; +pub mod push_pr; +pub mod rpcs; +pub mod state; + +pub use state::GithubService; diff --git a/crates/minimald/src/github/prime.rs b/crates/minimald/src/github/prime.rs new file mode 100644 index 000000000..8d5e82e43 --- /dev/null +++ b/crates/minimald/src/github/prime.rs @@ -0,0 +1,10 @@ +//! Repo pre-priming (spec R2): the `GithubPrimeRepos` activation handler. +//! +//! Owned by the `daemon-prime-repos` task. This module will drive, per +//! declared repo, a `github::gitops` clone (temp + rename) or adopt-local +//! wiring, followed by checkout-or-create of the requested branch, using a +//! token obtained through `super::state::GithubService::grants` and +//! authorized via `super::authz`. Every span opened here is `github.prime` +//! (see the span-name conventions documented in `super::state`). A per-repo +//! failure must roll back only that repo's directory and never push +//! implicitly (spec R2.5/R2.6). diff --git a/crates/minimald/src/github/push_pr.rs b/crates/minimald/src/github/push_pr.rs new file mode 100644 index 000000000..ec052f7c4 --- /dev/null +++ b/crates/minimald/src/github/push_pr.rs @@ -0,0 +1,11 @@ +//! Explicit push and pull-request handling (spec R3.4, R4): `GithubPush`, +//! `GithubCreatePr`, `GetSessionGitState`. +//! +//! Owned by the `daemon-push-pr` task. This module will implement the +//! explicit (never automatic) push RPC, existing-PR detection before +//! creating a new one, PR-template body pickup, and the per-repo git-state +//! query the client-driven PR-on-exit prompt reads. Pushes and PR creates run +//! through `super::authz::authorize` and a token from +//! `super::state::GithubService::grants`; spans opened here are +//! `github.push`/`github.pr` (see the span-name conventions documented in +//! `super::state`). diff --git a/crates/minimald/src/github/rpcs.rs b/crates/minimald/src/github/rpcs.rs new file mode 100644 index 000000000..bc9ebc704 --- /dev/null +++ b/crates/minimald/src/github/rpcs.rs @@ -0,0 +1,1057 @@ +//! GitHub auth RPC handlers (spec R1.1–R1.4): `GithubBeginLogin`, +//! `GithubPollLogin`, `GithubStatus`, `GithubListAuths`, `GithubLogout`. +//! +//! Owned by the `daemon-auth-rpcs` task. Handlers here delegate to +//! `super::state::GithubService`'s device-flow client and `GrantManager` +//! (device-flow login/poll, status, logout), plus refresh status reporting; +//! every span opened here is `github.auth` or `github.refresh` (see the +//! span-name conventions documented in `super::state`). No handler may +//! return a response type carrying token material — every RPC response type +//! is defined in `minimald-rpc` as plain, token-free data. +//! +//! # Channel framing lives in `rpc.rs` +//! +//! These functions are the *decision logic* for each RPC: they take an +//! already-deserialized request (plus whatever daemon state they need) and +//! return the `Errorable` (or, for [`status`], the +//! `Result, ConnectionError>` a manager lookup can also +//! fail with). The SSH-channel read/write plumbing +//! (`ServeOneshot::handle_channel`) is a `rpc.rs`-private extension trait, so +//! the thin `serve_github_*` wrappers that call into this module live there, +//! matching every other RPC in this daemon. +//! +//! # Device-flow polling: parked, not blocked (R1.1) +//! +//! [`begin_login`] performs exactly one HTTP call itself (`start_device_flow`, +//! fast) so the client gets the verification URL/code immediately, then +//! *parks* the rest of the flow: it spawns a background task that owns the +//! one [`github::DeviceAuthorization`] this login minted and drives +//! `DeviceFlowClient::poll` (which blocks internally, per RFC 8628, until the +//! user approves, the code expires, or GitHub reports a terminal error) plus +//! `fetch_user` and persisting the resulting grant. [`poll_login`] never +//! talks to GitHub itself — it only reads the outcome slot the background +//! task writes to exactly once, keyed by the `login_id` [`begin_login`] +//! returned. This is what lets `GithubPollLogin` answer `Pending` promptly on +//! every call instead of blocking an RPC channel for the lifetime of a login. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex as StdMutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use chrono::{DateTime, Utc}; +use github::{ + DeviceAuthorization, Error as GithubError, GrantId, GrantState, RepoSpec, RestClient, ScopeSet, + TokenProvider, +}; +use minimald_rpc::{ + Errorable, GithubBeginLoginRequest, GithubBeginLoginResponse, GithubIdentity, + GithubListAuthsRequest, GithubListAuthsResponse, GithubLogoutRequest, GithubLogoutResponse, + GithubPollLoginRequest, GithubPollLoginResponse, GithubStatusRequest, GithubStatusResponse, + GrantMetadata, RepoInstallationStatus, SessionRepoStatus, +}; + +use super::GithubService; +use crate::connection::ConnectionError; +use crate::server::ServerStateHandle; +use crate::sessions::SessionKeyPredicate; + +/// GitHub's typical device-code TTL (RFC 8628's own worked example, and what +/// GitHub's device-flow docs use), for [`GithubBeginLoginResponse::expires_in_secs`]. +/// +/// [`github::DeviceAuthorization`] deliberately keeps its real expiry private +/// (only [`github::DeviceFlowClient::poll`] needs it, to stop polling once +/// the code is truly dead — see that module's docs), so there is no accessor +/// this handler can read the *actual* server-advised value from. Reporting +/// this well-known default costs only display-countdown accuracy: the +/// enforcement of the real expiry happens inside `poll` regardless of what is +/// reported here, and shows up to the client as [`GithubPollLoginResponse::Expired`]. +const DEVICE_CODE_TYPICAL_EXPIRY_SECS: u64 = 900; + +/// The observed outcome of one in-flight device-flow login, as tracked by +/// [`PENDING_LOGINS`]. Distinct from the wire-facing +/// [`GithubPollLoginResponse`] so this module's internal bookkeeping doesn't +/// have to carry `minimald_rpc`'s `#[non_exhaustive]`/`#[serde(default)]` +/// shape. +#[derive(Debug, Clone)] +enum LoginOutcome { + Pending, + Complete { login: String, grant_id: String }, + Failed { message: String }, + Expired, +} + +/// Process-lifetime registry of in-flight/completed device-flow logins, +/// keyed by the opaque `login_id` [`begin_login`] mints. Not persisted: a +/// daemon restart mid-login simply means the user re-runs `min github +/// login`. Exactly one background task (spawned by `begin_login`) writes +/// each entry, exactly once, on completion; every [`poll_login`] call only +/// reads. Entries are never evicted — mirrors `github::refresh`'s per-grant +/// lock map: the set of logins one daemon process drives in its lifetime is +/// small and bounded (one anonymous local user, per spec NG2), so this can't +/// grow meaningfully. +static PENDING_LOGINS: LazyLock>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +/// Monotonic counter backing [`next_login_id`]/[`mint_grant_id`]: unique +/// enough within one daemon process (all that either id needs — see their +/// docs), without pulling in a UUID dependency this crate doesn't otherwise +/// need. +static ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Mints an opaque, process-unique handle for a new in-flight login. Not a +/// secret (it names a login attempt, not a credential) — safe in logs, safe +/// to hand back to the client so it can poll. +fn next_login_id() -> String { + let n = ID_COUNTER.fetch_add(1, Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("login-{}-{n}", now.as_nanos()) +} + +/// Mints a fresh [`GrantId`] for a just-completed login (spec R6.4 "mint"). +/// Every `GithubBeginLogin` mints a new grant; reusing an existing one is a +/// client-side decision made by never calling `login`/`begin_login` again +/// and instead referencing an id from [`GithubListAuths`](minimald_rpc::GithubListAuths) +/// at session-activation time — this handler doesn't implement reuse itself. +/// +/// Includes the GitHub login for a human-legible id, sanitized to the +/// filename-safe character set [`github::store`]'s [`github::GrantStore`] +/// requires (ASCII alphanumerics, `-`, `_`); GitHub logins are already in +/// that set, so this is defense in depth, not a load-bearing filter. +fn mint_grant_id(login: &str) -> GrantId { + let n = ID_COUNTER.fetch_add(1, Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let safe_login: String = login + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + GrantId::new(format!("{safe_login}-{}-{n}", now.as_nanos())) + .expect("non-empty by construction: at minimum the timestamp/counter suffix is present") +} + +/// Reads the current outcome for `login_id`, if any login with that id was +/// ever begun on this daemon process. +fn peek_login_outcome(login_id: &str) -> Option { + PENDING_LOGINS + .lock() + .expect("pending-logins lock poisoned") + .get(login_id) + .cloned() +} + +/// Records the terminal (or initial `Pending`) outcome for `login_id`. +fn set_login_outcome(login_id: &str, outcome: LoginOutcome) { + PENDING_LOGINS + .lock() + .expect("pending-logins lock poisoned") + .insert(login_id.to_string(), outcome); +} + +/// Resolves the scopes a login should request (spec R5.1/R5.2): the caller's +/// explicit `scope:permission` labels if any were given, else the +/// least-privilege defaults. +fn resolve_requested_scopes(scopes: &[String]) -> Result { + if scopes.is_empty() { + return Ok(ScopeSet::defaults()); + } + ScopeSet::from_attr_value(&scopes.join(",")) +} + +/// Renders a [`ScopeSet`] as the plain `scope:level` labels the wire types +/// use (e.g. `contents:rw`), in the set's canonical consent order. +fn render_scope_labels(scopes: &ScopeSet) -> Vec { + scopes.iter().map(|(s, p)| format!("{s}:{p}")).collect() +} + +/// `GithubBeginLogin` (spec R1.1, R1.4): starts a device-flow login and +/// parks the rest of it in the background (see the module docs). +/// +/// # Errors +/// +/// Never returns `Err` at the transport level; failures ahead of a usable +/// response (no GitHub App configured, malformed requested scopes, or the +/// device-code request itself failing) come back as `Errorable::Err` with an +/// actionable, non-secret message (spec R8.1). +#[tracing::instrument(name = "github.auth", skip_all)] +pub(crate) async fn begin_login( + service: GithubService, + req: GithubBeginLoginRequest, +) -> Errorable { + let client_id = match service.config().client_id() { + Ok(id) => id.to_string(), + Err(e) => { + return Errorable::Err { + error: e.to_string(), + }; + } + }; + let scopes = match resolve_requested_scopes(&req.scopes) { + Ok(s) => s, + Err(e) => { + return Errorable::Err { + error: e.to_string(), + }; + } + }; + + let authorization = match service.device_flow().start_device_flow(&client_id).await { + Ok(a) => a, + Err(e) => { + return Errorable::Err { + error: e.to_string(), + }; + } + }; + + let login_id = next_login_id(); + let response = GithubBeginLoginResponse::new( + authorization.verification_uri.to_string(), + authorization.user_code.clone(), + login_id.clone(), + authorization.interval.as_secs(), + DEVICE_CODE_TYPICAL_EXPIRY_SECS, + ); + + set_login_outcome(&login_id, LoginOutcome::Pending); + tokio::spawn(run_device_flow_poll( + service, + client_id, + authorization, + scopes, + login_id, + )); + + Errorable::Ok(response) +} + +/// The background half of a device-flow login (see the module docs): drives +/// `poll` to completion, fetches the identity, mints and persists a +/// [`github::Grant`], and records the terminal [`LoginOutcome`] for +/// [`poll_login`] to observe. Never panics on a GitHub-side failure — every +/// arm resolves to a recorded outcome instead. +#[tracing::instrument(name = "github.auth", skip_all, fields(grant_id = tracing::field::Empty))] +async fn run_device_flow_poll( + service: GithubService, + client_id: String, + authorization: DeviceAuthorization, + scopes: ScopeSet, + login_id: String, +) { + let outcome = poll_and_persist(&service, &client_id, &authorization, scopes).await; + tracing::info!("github device-flow login resolved"); + set_login_outcome(&login_id, outcome); +} + +/// The fallible core of [`run_device_flow_poll`], split out only so its +/// early-return arms read as a flat sequence of `match`es rather than nested +/// closures. Runs under the caller's `#[instrument]`ed span (records +/// `grant_id` on it once the login mints one). +async fn poll_and_persist( + service: &GithubService, + client_id: &str, + authorization: &DeviceAuthorization, + scopes: ScopeSet, +) -> LoginOutcome { + let tokens = match service.device_flow().poll(client_id, authorization).await { + Ok(t) => t, + Err(GithubError::NeedsReauth) => return LoginOutcome::Expired, + Err(e) => { + return LoginOutcome::Failed { + message: e.to_string(), + }; + } + }; + let user = match service.device_flow().fetch_user(&tokens.access_token).await { + Ok(u) => u, + Err(e) => { + return LoginOutcome::Failed { + message: e.to_string(), + }; + } + }; + + let grant_id = mint_grant_id(&user.login); + tracing::Span::current().record("grant_id", tracing::field::display(&grant_id)); + let login = user.login.clone(); + let grant = github::assemble_grant(grant_id.clone(), user, scopes, tokens); + + let store = service.grants().store().clone(); + match tokio::task::spawn_blocking(move || store.save(&grant)).await { + Ok(Ok(())) => LoginOutcome::Complete { + login, + grant_id: grant_id.to_string(), + }, + Ok(Err(e)) => LoginOutcome::Failed { + message: format!("failed to persist GitHub authentication: {e}"), + }, + Err(_join_err) => LoginOutcome::Failed { + message: "internal error persisting GitHub authentication".to_string(), + }, + } +} + +/// `GithubPollLogin` (spec R1.1): reads the current outcome of an in-flight +/// login without ever touching the network itself (see the module docs). +/// +/// # Errors +/// +/// Never returns `Err`: an unknown `login_id` (never begun, or from a +/// previous daemon process) is reported as +/// [`GithubPollLoginResponse::Failed`], not a transport error. +#[tracing::instrument(name = "github.auth", skip_all)] +pub(crate) async fn poll_login(req: GithubPollLoginRequest) -> Errorable { + if req.login_id.trim().is_empty() { + return Errorable::Err { + error: "missing login_id".to_string(), + }; + } + + Errorable::Ok(match peek_login_outcome(&req.login_id) { + None => GithubPollLoginResponse::Failed { + message: format!( + "no in-flight GitHub login with id `{}` (it may have completed on a since-restarted daemon)", + req.login_id + ), + }, + Some(LoginOutcome::Pending) => GithubPollLoginResponse::Pending, + Some(LoginOutcome::Complete { login, grant_id }) => { + GithubPollLoginResponse::Complete { login, grant_id } + } + Some(LoginOutcome::Failed { message }) => GithubPollLoginResponse::Failed { message }, + Some(LoginOutcome::Expired) => GithubPollLoginResponse::Expired, + }) +} + +/// `GithubStatus` (spec R1.4/R8.2): identity + token validity/expiry (a live +/// `token_for` refresh check plus a real `GET /user`, not just an +/// on-disk-expiry guess), per-repo App-installation state with guidance URLs +/// (spec R1.5), and the requested session's repo/branch/scope table decoded +/// straight from its `Record` attrs. +/// +/// # Errors +/// +/// [`ConnectionError::Internal`] only for a genuine daemon-internal failure +/// (the sessions manager itself being unreachable); an unknown or dead +/// session, a session with no GitHub involvement, or GitHub being +/// unreachable all resolve to a normal `Errorable::Ok` response with the +/// corresponding fields empty/`false`/`None` rather than an error — `min +/// github status` must always have *something* to show (R8.2). +#[tracing::instrument(name = "github.auth", skip_all, fields(grant_id = tracing::field::Empty))] +pub(crate) async fn status( + s: ServerStateHandle, + req: GithubStatusRequest, +) -> Result, ConnectionError> { + let service = s.github().await; + + let mut session_repos = Vec::new(); + let mut session_grant_id: Option = None; + let mut repo_set: Vec = Vec::new(); + + if let Some(session_id) = req.session_id { + let mngr = s.sessions_manager().await; + let handle = mngr + .get_session(SessionKeyPredicate::Id(session_id)) + .await + .map_err(|e| ConnectionError::Internal(e.to_string()))?; + // An unknown/dead session just means an empty session table below — + // `min github status` still answers with whatever else it can show. + if let Some(handle) = handle + && let Ok(record) = handle.record().await + && let Ok(attrs) = super::authz::read_github_attrs(&record) + { + session_grant_id = attrs.grant_id; + let scope_labels = attrs + .scopes + .as_ref() + .map(render_scope_labels) + .unwrap_or_default(); + for repo in &attrs.repos { + session_repos.push(SessionRepoStatus::new( + format!("{}/{}", repo.owner(), repo.repo()), + repo.branch().unwrap_or_default().to_string(), + repo.base().map(str::to_string), + scope_labels.clone(), + )); + } + repo_set = attrs.repos; + } + } + + for extra in &req.repos { + if let Ok(spec) = extra.parse::() + && !repo_set + .iter() + .any(|r| r.owner() == spec.owner() && r.repo() == spec.repo()) + { + repo_set.push(spec); + } + } + + let active_grant = match session_grant_id { + Some(id) => Some(id), + None => sole_stored_grant(&service).await, + }; + if let Some(grant_id) = &active_grant { + tracing::Span::current().record("grant_id", tracing::field::display(grant_id)); + } + + let (identity, token_valid, token_expires_at) = match &active_grant { + Some(grant_id) => check_identity(&service, grant_id).await, + None => (None, false, None), + }; + + let installations = match &active_grant { + Some(grant_id) if token_valid => { + let rest = service.rest_client(grant_id.clone()); + let mut out = Vec::with_capacity(repo_set.len()); + for repo in &repo_set { + out.push(repo_installation_status(&rest, repo.owner(), repo.repo()).await); + } + out + } + _ => repo_set + .iter() + .map(|r| { + RepoInstallationStatus::new(format!("{}/{}", r.owner(), r.repo()), false, None) + }) + .collect(), + }; + + Ok(Errorable::Ok(GithubStatusResponse::new( + identity, + token_valid, + token_expires_at, + installations, + session_repos, + ))) +} + +/// The grant to report on when a status request names no session: for the +/// single-anonymous-user daemon (spec NG2), that is the sole stored grant if +/// there is exactly one. With zero or several stored grants and no session +/// to disambiguate, there is no principled "active" one to guess, so this +/// reports `None` rather than picking arbitrarily. +async fn sole_stored_grant(service: &GithubService) -> Option { + let store = service.grants().store().clone(); + let summaries = tokio::task::spawn_blocking(move || store.list()) + .await + .ok()? + .ok()?; + match &summaries[..] { + [only] => Some(only.grant_id.clone()), + _ => None, + } +} + +/// Identity + live token validity for `grant_id` (spec R1.4/R8.2): the +/// stored login (for display even if the token turns out dead) plus a +/// refresh-aware `token_for` and an actual `GET /user`, since GitHub can +/// revoke a token out from under a locally-fresh-looking expiry. +async fn check_identity( + service: &GithubService, + grant_id: &GrantId, +) -> (Option, bool, Option>) { + let identity = load_grant(service, grant_id) + .await + .map(|g| GithubIdentity::new(g.github_login, grant_id.to_string())); + + let token_valid = match service.grants().token_for(grant_id).await { + Ok(_token) => service + .rest_client(grant_id.clone()) + .get_user() + .await + .is_ok(), + Err(_) => false, + }; + + let token_expires_at = if token_valid { + load_grant(service, grant_id) + .await + .map(|g| g.access_token_expires_at) + } else { + None + }; + + (identity, token_valid, token_expires_at) +} + +/// Reads a single stored [`github::Grant`] off the disk store, off the async +/// runtime. `None` on any failure (unknown grant, I/O error, or a panicked +/// blocking task) — every caller here treats an unreadable grant the same as +/// an absent one rather than surfacing a transport error from a status read. +async fn load_grant(service: &GithubService, grant_id: &GrantId) -> Option { + let store = service.grants().store().clone(); + let id = grant_id.clone(); + tokio::task::spawn_blocking(move || store.get(&id)) + .await + .ok()? + .ok()? +} + +/// One [`RepoInstallationStatus`] row (spec R1.5): installed, or not with the +/// guidance URL to install the App. +async fn repo_installation_status( + rest: &RestClient, + owner: &str, + repo: &str, +) -> RepoInstallationStatus { + match rest.repo_installation(owner, repo).await { + Ok(_) => RepoInstallationStatus::new(format!("{owner}/{repo}"), true, None), + Err(GithubError::AppNotInstalled { install_url }) => { + RepoInstallationStatus::new(format!("{owner}/{repo}"), false, Some(install_url)) + } + Err(_) => RepoInstallationStatus::new(format!("{owner}/{repo}"), false, None), + } +} + +/// `GithubListAuths` (spec R1.3/R6.4): every stored grant as metadata only — +/// `github::GrantStore::list` returns [`github::GrantSummary`], which is +/// structurally incapable of carrying a token (see that type's docs), so +/// there is no token to accidentally serialize here regardless. +/// +/// `token_valid` is the cheap, offline signal (persisted [`GrantState`]), +/// deliberately not a live `GET /user` per grant — listing every stored +/// grant must not fan out a GitHub call per entry just to answer "which +/// ones do I have" (unlike [`status`], which checks the one active grant +/// live). +/// +/// # Errors +/// +/// [`Errorable::Err`] if the on-disk grant store can't be read; never a +/// transport error. +#[tracing::instrument(name = "github.auth", skip_all)] +pub(crate) async fn list_auths( + service: GithubService, + _req: GithubListAuthsRequest, +) -> Errorable { + let store = service.grants().store().clone(); + let summaries = match tokio::task::spawn_blocking(move || store.list()).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + return Errorable::Err { + error: format!("reading stored GitHub auth grants failed: {e}"), + }; + } + Err(_join_err) => { + return Errorable::Err { + error: "internal error reading stored GitHub auth grants".to_string(), + }; + } + }; + + let grants = summaries + .into_iter() + .map(|g| { + GrantMetadata::new( + g.grant_id.to_string(), + g.github_login, + Some(g.created_at), + render_scope_labels(&g.scopes), + // A grant isn't itself tied to a fixed repo list on disk — + // repos are declared per-session (see `github.repos` attrs); + // a grant's *actual* GitHub-side repo access is whatever the + // App installation covers, which `min github status` reports + // per repo instead of duplicating here. + Vec::new(), + g.state == GrantState::Valid, + Some(g.access_token_expires_at), + ) + }) + .collect(); + + Errorable::Ok(GithubListAuthsResponse::new(grants)) +} + +/// `GithubLogout` (spec R6.4): deletes the stored grant, and — best-effort — +/// would revoke it against GitHub too, but `github::rest::RestClient` +/// exposes no revocation endpoint yet (only user/installation/PR +/// operations), so there is nothing to call there today. Deleting the local +/// file is the security-relevant half regardless: it is what stops +/// `minimald` itself from ever presenting this token again (spec R6.1/R6.3). +/// +/// Rejects an empty `grant_id` rather than guessing which grant to remove +/// (fail-closed). +/// +/// # Errors +/// +/// [`Errorable::Err`] for an empty `grant_id` or an on-disk failure; never a +/// transport error. +#[tracing::instrument(name = "github.auth", skip_all, fields(grant_id = %req.grant_id))] +pub(crate) async fn logout( + service: GithubService, + req: GithubLogoutRequest, +) -> Errorable { + let Ok(grant_id) = GrantId::new(req.grant_id.trim()) else { + return Errorable::Err { + error: "grant_id must not be empty".to_string(), + }; + }; + + let store = service.grants().store().clone(); + let existed = { + let id = grant_id.clone(); + match tokio::task::spawn_blocking(move || store.get(&id)).await { + Ok(Ok(g)) => g.is_some(), + Ok(Err(e)) => { + return Errorable::Err { + error: format!("reading GitHub auth grant `{grant_id}` failed: {e}"), + }; + } + Err(_join_err) => { + return Errorable::Err { + error: "internal error reading GitHub auth grant".to_string(), + }; + } + } + }; + + let store = service.grants().store().clone(); + let id = grant_id.clone(); + match tokio::task::spawn_blocking(move || store.delete(&id)).await { + Ok(Ok(())) => Errorable::Ok(GithubLogoutResponse::new(existed)), + Ok(Err(e)) => Errorable::Err { + error: format!("deleting GitHub auth grant `{grant_id}` failed: {e}"), + }, + Err(_join_err) => Errorable::Err { + error: "internal error deleting GitHub auth grant".to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashSet as StdHashSet; + use std::net::SocketAddr; + use std::sync::Arc; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + use minimald_rpc::{ + GithubBeginLogin, GithubListAuths, GithubLogout, GithubPollLogin, GithubStatus, + }; + + use crate::github::state::APP_SLUG; + use crate::test_harness::TestServer; + + // -- a tiny hand-rolled mock GitHub ------------------------------------- + // + // `github::testing::MockGithub` lives behind the `github` crate's + // `test-support` feature, which `minimald`'s `Cargo.toml` does not + // currently enable (that file is out of this task's ownership) — this + // mock is self-contained here instead, on top of `tokio`'s TCP stack + // (already a `minimald` dependency via `github`'s own `client` feature), + // mirroring the one-shot HTTP responder `github::device_flow`'s own + // tests use, extended to serve the handful of requests one full + // login→poll→status round trip makes. + + /// Serves exactly the endpoints [`begin_login`]/[`run_device_flow_poll`]/ + /// [`status`] call: the OAuth device flow, `GET /user`, and `GET + /// /repos/{owner}/{repo}/installation`. Always approves the device code + /// on the first poll attempt — these tests exercise the RPC plumbing, + /// not GitHub's own pending/slow_down backoff (that is `github::device_flow`'s + /// own test suite's job). + struct MockGithub { + addr: SocketAddr, + installed: Arc>>, + } + + impl MockGithub { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let addr = listener.local_addr().expect("local addr"); + let installed: Arc>> = + Arc::new(StdMutex::new(StdHashSet::new())); + let installed_bg = Arc::clone(&installed); + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let installed = Arc::clone(&installed_bg); + tokio::spawn(async move { + let _ = Self::handle_one(stream, installed).await; + }); + } + }); + Self { addr, installed } + } + + fn base_url(&self) -> String { + format!("http://{}/", self.addr) + } + + async fn handle_one( + mut stream: TcpStream, + installed: Arc>>, + ) -> std::io::Result<()> { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let (header_len, want_body) = loop { + let n = stream.read(&mut chunk).await?; + if n == 0 { + return Ok(()); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(end) = find_header_end(&buf) { + let headers = String::from_utf8_lossy(&buf[..end]).into_owned(); + break (end, content_length(&headers)); + } + }; + while buf.len() < header_len + 4 + want_body { + let n = stream.read(&mut chunk).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + let headers_text = String::from_utf8_lossy(&buf[..header_len]).into_owned(); + let request_line = headers_text.lines().next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("").to_string(); + + let (status, body) = Self::route(&method, &path, &installed); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await?; + Ok(()) + } + + fn route( + method: &str, + path: &str, + installed: &StdMutex>, + ) -> (&'static str, String) { + match (method, path) { + ("POST", "/login/device/code") => ( + "200 OK", + serde_json::json!({ + "device_code": "mock-device-code", + "user_code": "MOCK-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 1, + }) + .to_string(), + ), + ("POST", "/login/oauth/access_token") => ( + "200 OK", + serde_json::json!({ + "access_token": "ghu_mock_access_token", + "token_type": "bearer", + "expires_in": 28800, + "refresh_token": "ghr_mock_refresh_token", + "refresh_token_expires_in": 15_897_600, + }) + .to_string(), + ), + ("GET", "/user") => ( + "200 OK", + serde_json::json!({"login": "octocat", "id": 583_231}).to_string(), + ), + (method, path) + if method == "GET" + && path.starts_with("/repos/") + && path.ends_with("/installation") => + { + let repo = path + .trim_start_matches("/repos/") + .trim_end_matches("/installation") + .trim_end_matches('/'); + if installed.lock().expect("mock lock poisoned").contains(repo) { + let owner = repo.split('/').next().unwrap_or(""); + ( + "200 OK", + serde_json::json!({ + "id": 1, + "app_slug": "minimal", + "target_type": "User", + "account": {"login": owner}, + }) + .to_string(), + ) + } else { + ( + "404 Not Found", + serde_json::json!({"message": "Not Found"}).to_string(), + ) + } + } + _ => ( + "404 Not Found", + serde_json::json!({"message": "Not Found"}).to_string(), + ), + } + } + } + + fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") + } + + fn content_length(headers: &str) -> usize { + headers + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.trim() + .eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0) + } + + /// Builds a [`TestServer`] whose `GithubService` targets `mock`, by + /// scoping the `MINIMALD_GITHUB_*` env-var overrides + /// (`github::GithubConfig::from_env`'s only input) to the narrow window + /// around `TestServer::new()`'s single read of them — the daemon + /// resolves and captures its config once at construction, so the + /// override does not need to (and, to minimize cross-test interference + /// under a non-isolated `cargo test` run, should not) outlive that call. + async fn server_against(mock: &MockGithub) -> TestServer { + let base = mock.base_url(); + // SAFETY: no other thread in this process is expected to read these + // three daemon-config env vars mid-mutation; the window is scoped to + // one synchronous-from-this-task's-perspective `TestServer::new()` + // call, mirroring the same accepted-risk pattern already used by + // e.g. `paths::tests` and `minvmd`'s image tests for env-var-driven + // construction. + unsafe { + std::env::set_var(github::config::ENV_CLIENT_ID, "test-client"); + std::env::set_var(github::config::ENV_OAUTH_BASE, &base); + std::env::set_var(github::config::ENV_API_BASE, &base); + } + let server = TestServer::new().await; + unsafe { + std::env::remove_var(github::config::ENV_CLIENT_ID); + std::env::remove_var(github::config::ENV_OAUTH_BASE); + std::env::remove_var(github::config::ENV_API_BASE); + } + server + } + + #[tokio::test] + async fn login_poll_status_round_trip_against_the_mock() { + let mock = MockGithub::start().await; + let server = server_against(&mock).await; + let mut client = server.connect().await; + + // -- login: begin -- + let begin = client + .call::(&GithubBeginLoginRequest::new(vec![])) + .await; + let begin = match begin { + Errorable::Ok(r) => r, + Errorable::Err { error } => panic!("begin_login failed: {error}"), + }; + assert_eq!(begin.verification_uri, "https://github.com/login/device"); + assert_eq!(begin.user_code, "MOCK-1234"); + assert!(!begin.login_id.is_empty()); + + // -- login: poll to completion -- + let mut grant_id = String::new(); + let mut login = String::new(); + let mut completed = false; + for _ in 0..50 { + match client + .call::(&GithubPollLoginRequest::new(begin.login_id.clone())) + .await + { + Errorable::Ok(GithubPollLoginResponse::Pending) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Errorable::Ok(GithubPollLoginResponse::Complete { + login: l, + grant_id: g, + }) => { + login = l; + grant_id = g; + completed = true; + break; + } + other => panic!("unexpected poll outcome: {other:?}"), + } + } + assert!(completed, "login did not complete in time"); + assert_eq!(login, "octocat"); + assert!(!grant_id.is_empty()); + + // -- status: identity/token valid, and a not-installed repo carries + // an install URL (spec R1.5) -- + let status_resp = client + .call::(&GithubStatusRequest::new( + None, + vec!["octocat/hello".to_string()], + )) + .await; + let status_resp = match status_resp { + Errorable::Ok(r) => r, + Errorable::Err { error } => panic!("status failed: {error}"), + }; + assert!( + status_resp.token_valid, + "freshly minted token must be valid" + ); + let identity = status_resp + .identity + .expect("an identity must be reported once a grant is stored"); + assert_eq!(identity.login, "octocat"); + assert_eq!(identity.grant_id, grant_id); + assert_eq!(status_resp.installations.len(), 1); + let install = &status_resp.installations[0]; + assert_eq!(install.repo, "octocat/hello"); + assert!(!install.installed); + // The install URL is built from the configured OAuth base (see + // `state::GithubService::install_url`), which `server_against` pointed + // at the mock — so assert against that base, not public GitHub's. + assert_eq!( + install.install_url.as_deref(), + Some(format!("{}apps/{APP_SLUG}/installations/new", mock.base_url()).as_str()) + ); + + // Now mark it installed and confirm the flip. + mock.installed + .lock() + .unwrap() + .insert("octocat/hello".to_string()); + let status_resp = client + .call::(&GithubStatusRequest::new( + None, + vec!["octocat/hello".to_string()], + )) + .await + .unwrap(); + assert!(status_resp.installations[0].installed); + assert!(status_resp.installations[0].install_url.is_none()); + + // -- serialized responses are grep-clean of the mock's token bytes -- + for secret in ["ghu_mock_access_token", "ghr_mock_refresh_token"] { + assert!(!serde_json::to_string(&begin).unwrap().contains(secret)); + assert!( + !serde_json::to_string(&status_resp) + .unwrap() + .contains(secret) + ); + } + + // -- list-auths: metadata only, still no token bytes -- + let listed = client + .call::(&GithubListAuthsRequest::new()) + .await; + let listed = match listed { + Errorable::Ok(r) => r, + Errorable::Err { error } => panic!("list_auths failed: {error}"), + }; + assert_eq!(listed.grants.len(), 1); + assert_eq!(listed.grants[0].grant_id, grant_id); + assert_eq!(listed.grants[0].login, "octocat"); + assert!(listed.grants[0].token_valid); + let listed_json = serde_json::to_string(&listed).unwrap(); + for secret in ["ghu_mock_access_token", "ghr_mock_refresh_token"] { + assert!(!listed_json.contains(secret)); + } + + // -- logout: removes the grant -- + let logout_resp = client + .call::(&GithubLogoutRequest::new(grant_id.clone())) + .await; + match logout_resp { + Errorable::Ok(r) => assert!(r.removed), + Errorable::Err { error } => panic!("logout failed: {error}"), + } + let listed_after = client + .call::(&GithubListAuthsRequest::new()) + .await + .unwrap(); + assert!(listed_after.grants.is_empty()); + } + + #[tokio::test] + async fn begin_login_without_a_configured_app_fails_closed() { + // No env overrides at all: the ambient test environment has no + // `MINIMALD_GITHUB_CLIENT_ID` set (see `super::state`'s own tests, + // which assume exactly this), so `GithubConfig::from_env` resolves + // unconfigured and `begin_login` must refuse before any network I/O. + let server = TestServer::new().await; + let mut client = server.connect().await; + + let resp = client + .call::(&GithubBeginLoginRequest::new(vec![])) + .await; + match resp { + Errorable::Err { error } => { + assert!(error.contains("MINIMALD_GITHUB_CLIENT_ID")); + } + Errorable::Ok(_) => panic!("expected NotConfigured to fail begin_login"), + } + } + + #[tokio::test] + async fn poll_login_of_unknown_id_is_a_clean_failed_not_a_transport_error() { + let server = TestServer::new().await; + let mut client = server.connect().await; + + let resp = client + .call::(&GithubPollLoginRequest::new("no-such-login".to_string())) + .await; + match resp { + Errorable::Ok(GithubPollLoginResponse::Failed { message }) => { + assert!(message.contains("no-such-login")); + } + other => panic!("expected Failed, got {other:?}"), + } + } + + #[tokio::test] + async fn logout_of_empty_grant_id_is_rejected() { + let server = TestServer::new().await; + let mut client = server.connect().await; + + let resp = client + .call::(&GithubLogoutRequest::new(String::new())) + .await; + assert!(matches!(resp, Errorable::Err { .. })); + } + + #[tokio::test] + async fn logout_of_unknown_grant_is_not_an_error_and_reports_not_removed() { + let server = TestServer::new().await; + let mut client = server.connect().await; + + let resp = client + .call::(&GithubLogoutRequest::new("never-existed".to_string())) + .await; + match resp { + Errorable::Ok(r) => assert!(!r.removed), + Errorable::Err { error } => panic!("logout of unknown grant should not error: {error}"), + } + } + + #[tokio::test] + async fn status_with_no_session_and_no_stored_grants_reports_no_identity() { + let server = TestServer::new().await; + let mut client = server.connect().await; + + let resp = client + .call::(&GithubStatusRequest::new(None, vec![])) + .await + .unwrap(); + assert!(resp.identity.is_none()); + assert!(!resp.token_valid); + assert!(resp.installations.is_empty()); + assert!(resp.session_repos.is_empty()); + } +} diff --git a/crates/minimald/src/github/state.rs b/crates/minimald/src/github/state.rs new file mode 100644 index 000000000..7ed14a32f --- /dev/null +++ b/crates/minimald/src/github/state.rs @@ -0,0 +1,256 @@ +//! The daemon's single shared [`GithubService`] instance (spec 10) — the +//! composition root the rest of `crates/minimald/src/github/` fans out from. +//! +//! [`GithubService`] wires together every GitHub-domain building block the +//! `github` crate exports behind its `client` feature: +//! +//! - the on-disk [`GrantStore`], rooted at `/github/grants` +//! (spec R1.3/R6.4); +//! - exactly **one** [`GrantManager`] over that store — single-flight token +//! refresh (spec R1.2) only holds if the whole daemon shares one manager, +//! so this is the only place a `GrantManager` is constructed; +//! - a [`DeviceFlowClient`] for the OAuth device flow (spec R1.1); +//! - a [`RestClient`] factory, built per grant via [`GithubService::rest_client`] +//! (`RestClient` is generic over its token provider, so it cannot be one +//! shared value the way the manager is); +//! - `github::gitops` stays deliberately stateless: callers obtain a token +//! via [`GithubService::grants`]`.token_for(..)` and hand it to +//! `github::gitops::Repo` themselves (spec R2.7). +//! +//! `minimald::server::ServerState` holds one [`GithubService`] for the +//! daemon's lifetime, and `ServerStateHandle::github` (see `server.rs`) hands +//! out cheap clones of it, so `rpc.rs`'s auth RPCs and `env.rs`'s session +//! facade channel always reach the same manager — a second `GithubService` +//! constructed over the same grants directory would still be *correct* +//! (the store itself is safe to open twice), but it would defeat +//! single-flight refresh (two independent `GrantManager`s, two independent +//! locks), so `ServerState` must own exactly one. +//! +//! # Unconfigured daemons (spec R1.1) +//! +//! No real GitHub App is provisioned yet, so `MINIMALD_GITHUB_CLIENT_ID` is +//! normally unset. [`GithubService::new`] always succeeds regardless — +//! daemon startup must never depend on GitHub being configured. Every +//! GitHub-touching operation this module's siblings add (`rpcs.rs`, +//! `authz.rs`, `prime.rs`, `push_pr.rs`, `facade.rs`) MUST call +//! [`GithubService::ensure_configured`] (or an equivalent `client_id()` +//! check) before doing any I/O, so an unconfigured daemon answers every +//! GitHub op with the actionable [`github::Error::NotConfigured`] instead of +//! a confusing failure further downstream. +//! +//! # Observability span conventions (spec R8.1) +//! +//! Every GitHub-touching daemon operation MUST open one of these `tracing` +//! spans, named exactly as listed (each is owned by the sibling module that +//! implements it): +//! +//! | Span | Owner | Covers | +//! |-------------------|--------------|---------------------------------------------------------------| +//! | `github.auth` | `rpcs.rs` | device-flow login/poll, status, list-auths, logout | +//! | `github.refresh` | `rpcs.rs` | token refresh (incl. the `GrantManager`'s own transparent one) | +//! | `github.prime` | `prime.rs` | repo pre-priming / branch checkout-or-create | +//! | `github.facade` | `facade.rs` | `min git` / `min api` mediated operations | +//! | `github.push` | `push_pr.rs` | explicit `min session push` | +//! | `github.pr` | `push_pr.rs` | PR create/detect, on exit or explicit request | +//! +//! Span **fields** are limited to `repo`, `branch`, and `grant_id` — never a +//! token, and never a full URL that could embed one. This mirrors the +//! crate-wide rule already enforced in `github::Error` and `SecretString`: +//! nothing token-shaped may reach a `tracing` event, a `Debug` impl, or an +//! error message (spec R6.2). + +use github::{ + DeviceFlowClient, Error, GithubConfig, GrantId, GrantManager, GrantStore, GrantTokenProvider, + HttpRefreshBackend, RestClient, +}; +use url::Url; + +/// The concrete [`GrantManager`] the daemon runs: refreshes over HTTP against +/// [`GithubConfig::oauth_base`]. Every daemon-side consumer of a refreshed +/// token goes through this type, via [`GithubService::grants`]. +pub type DaemonGrantManager = GrantManager; + +/// The registered GitHub App's slug, per the PRD's authentication model ("A +/// GitHub App... named e.g. `minimal`"). No App is provisioned yet — spec +/// R1.1's "unconfigured daemon" state is the normal one for now — so this +/// constant is only used to build the best-effort installation URL surfaced +/// by [`github::Error::AppNotInstalled`] (spec R1.5); it is not read from +/// [`GithubConfig`] because the config carries no App-identity field yet. +pub(super) const APP_SLUG: &str = "minimal"; + +/// The daemon's single composition root for everything GitHub (spec 10). +/// +/// Cheap to clone: every field is either internally `Arc`-backed +/// (`GrantManager`, the `reqwest::Client`s inside `DeviceFlowClient`) or +/// plain data, so cloning shares state rather than duplicating it. See the +/// module docs for why `ServerState` must hold exactly one lineage. +#[derive(Debug, Clone)] +pub struct GithubService { + config: GithubConfig, + grants: DaemonGrantManager, + device_flow: DeviceFlowClient, +} + +impl GithubService { + /// Builds the service from `config`, rooting the on-disk grant store at + /// `grants_dir` (the daemon passes `/github/grants`; + /// see `ServerState::new` in `server.rs`). + /// + /// Always succeeds — including when `config.client_id()` is unset — so + /// daemon startup never depends on a GitHub App being provisioned. + /// Callers MUST check [`GithubService::ensure_configured`] before + /// performing any GitHub operation. + /// + /// # Errors + /// + /// Only if `grants_dir` cannot be created or its permissions asserted + /// (spec R6.2's `0700`/`0600` file-layout guarantee) — never on account + /// of `config` being unconfigured. + pub fn new( + config: GithubConfig, + grants_dir: impl Into, + ) -> std::io::Result { + let store = GrantStore::open(grants_dir)?; + // No real client id in the common (unconfigured) case; + // `HttpRefreshBackend` still needs *some* string to send as + // `client_id` on a refresh exchange. An empty placeholder is safe: + // `token_for`/`refresh_now` are the only callers, and both operate + // only on a grant that was already minted by a real device-flow + // login — which itself could only have happened while a client id + // was actually configured. If the client id is unset when a refresh + // later fires (e.g. the daemon restarted with the env var removed), + // the exchange simply fails like any other misconfiguration rather + // than silently authenticating as the wrong app. + let client_id = config.client_id().unwrap_or_default().to_string(); + let backend = HttpRefreshBackend::new(config.oauth_base().clone(), client_id); + let device_flow = + DeviceFlowClient::new(config.oauth_base().clone(), config.api_base().clone()); + Ok(Self { + grants: GrantManager::new(store, backend), + device_flow, + config, + }) + } + + /// Fails closed with [`github::Error::NotConfigured`] when no GitHub App + /// client id is set. The mandatory first call of every GitHub-touching + /// operation this module's siblings implement (spec R1.1). + /// + /// # Errors + /// + /// [`github::Error::NotConfigured`] when unconfigured. + pub fn ensure_configured(&self) -> Result<(), Error> { + self.config.client_id().map(|_| ()) + } + + /// The resolved GitHub configuration (base URLs, and the client id when + /// set). + #[must_use] + pub fn config(&self) -> &GithubConfig { + &self.config + } + + /// The one shared token-refresh state machine (spec R1.2). Every + /// consumer of a refreshed access token — the REST client, `gitops`, the + /// facade — must go through this, directly or via + /// [`GithubService::rest_client`]. + #[must_use] + pub fn grants(&self) -> &DaemonGrantManager { + &self.grants + } + + /// The OAuth device-flow client (spec R1.1). + #[must_use] + pub fn device_flow(&self) -> &DeviceFlowClient { + &self.device_flow + } + + /// Builds a REST client whose token provider transparently refreshes + /// `grant_id` through the shared [`GithubService::grants`] manager. + #[must_use] + pub fn rest_client( + &self, + grant_id: GrantId, + ) -> RestClient> { + RestClient::new( + self.config.api_base().clone(), + self.install_url(), + self.grants.token_provider(grant_id), + ) + } + + /// Best-effort GitHub App installation URL, surfaced by + /// [`github::Error::AppNotInstalled`] (spec R1.5). Built from + /// [`APP_SLUG`] since no App is registered yet; falls back to the OAuth + /// base itself if, somehow, that join fails. + fn install_url(&self) -> Url { + self.config + .oauth_base() + .join(&format!("apps/{APP_SLUG}/installations/new")) + .unwrap_or_else(|_| self.config.oauth_base().clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a [`GithubService`] over a fresh tempdir from the real process + /// environment. This intentionally exercises [`GithubConfig::from_env`] + /// (not a hand-built config) because the property under test — "an + /// unconfigured daemon still constructs its `GithubService` and every op + /// fails closed" — is precisely the ambient state of this test + /// environment: nothing here sets `MINIMALD_GITHUB_CLIENT_ID`. + fn unconfigured_service() -> (tempfile::TempDir, GithubService) { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let config = GithubConfig::from_env().expect("ambient env has no malformed override"); + assert!( + !config.is_configured(), + "this test assumes no MINIMALD_GITHUB_CLIENT_ID is set in the test environment" + ); + let service = GithubService::new(config, tmp.path().join("github").join("grants")) + .expect("an unconfigured GithubService must still construct"); + (tmp, service) + } + + #[test] + fn unconfigured_service_constructs_and_fails_closed() { + let (_tmp, service) = unconfigured_service(); + assert!( + matches!(service.ensure_configured(), Err(Error::NotConfigured)), + "an unconfigured service must answer NotConfigured, not silently proceed" + ); + } + + /// Every composed building block must be reachable — and safe to touch — + /// even before a GitHub App is configured, since callers only learn that + /// via `ensure_configured`/`client_id`, not via construction failing. + #[test] + fn every_composed_building_block_is_reachable_unconfigured() { + let (_tmp, service) = unconfigured_service(); + + let _config = service.config(); + let _grants = service.grants(); + let _device_flow = service.device_flow(); + let _rest = service.rest_client(GrantId::new("does-not-exist").unwrap()); + } + + /// Every `ServerStateHandle::github` caller gets its own clone of the one + /// `GithubService`; cloning must be cheap and side-effect-free (no new + /// grant store opened, no panic) so handing it to every RPC/facade call + /// site is free. + #[test] + fn cloning_the_service_is_side_effect_free() { + let (_tmp, service) = unconfigured_service(); + let clone = service.clone(); + assert!(matches!( + clone.ensure_configured(), + Err(Error::NotConfigured) + )); + assert_eq!( + service.config().api_base(), + clone.config().api_base(), + "a clone must observe the same resolved configuration" + ); + } +} diff --git a/crates/minimald/src/lib.rs b/crates/minimald/src/lib.rs index 90c3bd55b..19873708c 100644 --- a/crates/minimald/src/lib.rs +++ b/crates/minimald/src/lib.rs @@ -5,6 +5,7 @@ pub mod connection; mod diag; pub mod env; mod exec; +mod github; #[cfg(target_os = "linux")] pub mod guest; #[cfg(target_os = "linux")] diff --git a/crates/minimald/src/rpc.rs b/crates/minimald/src/rpc.rs index b9d9845d3..30aea70de 100644 --- a/crates/minimald/src/rpc.rs +++ b/crates/minimald/src/rpc.rs @@ -4,7 +4,8 @@ use minimald_rpc::{ AbortSession, AbortSessionResponse, CreateSession, DestroySession, DestroySessionResponse, Errorable, FinalizeSession, FinalizeSessionResponse, GetMeshStatus, GetSessionPolicy, GetSessionPolicyRequest, GetSessionRecord, GetSessionRecordRequest, GetSessionRecordResponse, - GetVersion, GetVersionResponse, IssueClientCert, IssueClientCertRequest, ListSessions, + GetVersion, GetVersionResponse, GithubBeginLogin, GithubListAuths, GithubLogout, + GithubPollLogin, GithubStatus, IssueClientCert, IssueClientCertRequest, ListSessions, ListSessionsEntry, ListSessionsResponse, OneshotSshRpc, RPC_SUBSYSTEM_PREFIX, RenameSession, RenameSessionResponse, ResourcePool, Shutdown, ShutdownRequest, ShutdownResponse, SubmitVerdict, @@ -603,6 +604,67 @@ async fn serve_get_mesh_status( .await } +// --------------------------------------------------------------------------- +// GitHub (spec 10): a contiguous block for every `github`-prefixed RPC. +// Later tasks (prime/push/PR/facade) append sibling `serve_github_*` +// wrappers + allowlist/dispatch arms here rather than scattering them +// through the file. The decision logic for each handler lives in +// `crate::github::rpcs` (see that module's docs); these wrappers only own +// the SSH-channel framing (`ServeOneshot::handle_channel`), matching every +// other RPC in this file. +// --------------------------------------------------------------------------- + +async fn serve_github_begin_login( + s: ServerStateHandle, + c: RuChannel, +) -> Result<(), ConnectionError> { + GithubBeginLogin + .handle_channel(c, async |req| { + Ok(crate::github::rpcs::begin_login(s.github().await, req).await) + }) + .await +} + +async fn serve_github_poll_login(c: RuChannel) -> Result<(), ConnectionError> { + GithubPollLogin + .handle_channel( + c, + async |req| Ok(crate::github::rpcs::poll_login(req).await), + ) + .await +} + +async fn serve_github_status( + s: ServerStateHandle, + c: RuChannel, +) -> Result<(), ConnectionError> { + GithubStatus + .handle_channel(c, async |req| crate::github::rpcs::status(s, req).await) + .await +} + +async fn serve_github_list_auths( + s: ServerStateHandle, + c: RuChannel, +) -> Result<(), ConnectionError> { + GithubListAuths + .handle_channel(c, async |req| { + Ok(crate::github::rpcs::list_auths(s.github().await, req).await) + }) + .await +} + +async fn serve_github_logout( + s: ServerStateHandle, + c: RuChannel, +) -> Result<(), ConnectionError> { + GithubLogout + .handle_channel(c, async |req| { + Ok(crate::github::rpcs::logout(s.github().await, req).await) + }) + .await +} + pub(crate) const STREAM_WORKSPACE_FILES: &str = constcat::concat!(RPC_SUBSYSTEM_PREFIX, "WorkspaceFilesTarZst"); @@ -1186,7 +1248,12 @@ pub async fn handle_ssh_rpc( | STREAM_WORKSPACE_FILES | STREAM_WORKSPACE_PATCHES | minimald_rpc::DIAG_BUNDLE_SUBSYSTEM - | IssueClientCert::NAME => { + | IssueClientCert::NAME + | GithubBeginLogin::NAME + | GithubPollLogin::NAME + | GithubStatus::NAME + | GithubListAuths::NAME + | GithubLogout::NAME => { let mut conn_lock = c.lock().await; let c_hnd = match conn_lock.take(id) { None => { @@ -1267,6 +1334,11 @@ pub async fn handle_ssh_rpc( minimald_rpc::DIAG_BUNDLE_SUBSYSTEM => { serve!(crate::diag::serve_stream_diag_bundle(s, config, channel)) } + GithubBeginLogin::NAME => serve!(serve_github_begin_login(s, channel)), + GithubPollLogin::NAME => serve!(serve_github_poll_login(channel)), + GithubStatus::NAME => serve!(serve_github_status(s, channel)), + GithubListAuths::NAME => serve!(serve_github_list_auths(s, channel)), + GithubLogout::NAME => serve!(serve_github_logout(s, channel)), IssueClientCert::NAME => { #[cfg(feature = "networking-proxy")] serve!(serve_issue_client_cert(s, channel)); diff --git a/crates/minimald/src/server.rs b/crates/minimald/src/server.rs index 34fdd5c07..cb65525fd 100644 --- a/crates/minimald/src/server.rs +++ b/crates/minimald/src/server.rs @@ -12,6 +12,7 @@ use tokio_util::sync::CancellationToken; use tracing::Instrument as _; use crate::connection::Connection; +use crate::github::GithubService; use crate::sessions; /// The ed25519 host private key for the SSH server. @@ -132,6 +133,14 @@ pub struct ServerState { config: Config, sessions: sessions::ManagerHandle, + /// The daemon's single GitHub composition root (spec 10), built once at + /// startup from [`github::GithubConfig::from_env`] and shared by every + /// `github`-RPC handler and session facade channel — see + /// [`ServerStateHandle::github`] and the module docs on + /// `crate::github::state`. Unconfigured (no GitHub App client id) is a + /// normal, fully-functional state: it never prevents daemon startup. + github: GithubService, + /// Fired by the `Shutdown` RPC handler once the session manager has been /// torn down, telling [`Server::run`]'s accept loop to stop accepting, /// drain in-flight connections, and return so the process can exit. @@ -207,6 +216,24 @@ impl ServerState { .build() .map_err(|e| std::io::Error::other(format!("mctx config: {e}")))?; + // GitHub composition root (spec 10): resolved once here so the whole + // daemon shares exactly one `GithubService`/`GrantManager` for its + // lifetime — single-flight refresh (spec R1.2) only holds with one + // manager. Unconfigured (no `MINIMALD_GITHUB_CLIENT_ID`) is a normal, + // fully-supported state (spec R1.1) and must never fail startup; only + // a malformed override (`MINIMALD_GITHUB_*_BASE_URL` not a valid URL) + // is a genuine misconfiguration worth failing loudly on. + let github_config = github::GithubConfig::from_env() + .map_err(|e| std::io::Error::other(format!("github config: {e}")))?; + let github = GithubService::new( + github_config, + minimal_state_dir + .as_utf8_path() + .as_std_path() + .join("github") + .join("grants"), + )?; + Ok(Self { sessions: sessions::Manager::init( minimal_state_dir, @@ -215,6 +242,7 @@ impl ServerState { net_switch, ) .await?, + github, config, shutdown: CancellationToken::new(), log_release, @@ -273,6 +301,16 @@ impl ServerStateHandle { self.0.lock().await.sessions.clone() } + /// Returns the daemon's single [`GithubService`] (spec 10). Every + /// `github`-RPC handler (`rpc.rs`) and session facade channel (`env.rs`) + /// must reach GitHub only through the clone this returns, never by + /// constructing a second `GithubService` — see the module docs on + /// `crate::github::state` for why that would defeat single-flight + /// refresh. Cheap to call and to clone. + pub async fn github(&self) -> GithubService { + self.0.lock().await.github.clone() + } + /// Returns a clone of the server shutdown token. [`Server::run`]'s accept /// loop awaits [`CancellationToken::cancelled`] on it to leave the loop. pub(crate) async fn shutdown_token(&self) -> CancellationToken { diff --git a/docs/internal/release-pipeline.md b/docs/internal/release-pipeline.md index e8f363a1a..0c20c2fb6 100644 --- a/docs/internal/release-pipeline.md +++ b/docs/internal/release-pipeline.md @@ -30,6 +30,27 @@ push. success on the exact commit being released. `skip_ci_verify` is an admin override for green-but-unreported commits. +**GitHub App identity (`MINIMAL_GITHUB_CLIENT_ID`).** The workflow-level `env` +block forwards the repository variable of the same name into every build job, +where [`crates/github/build.rs`](../../crates/github/build.rs) registers it as +a rebuild trigger and `crates/github/src/config.rs` bakes it in via +`option_env!`. This is the only way a shipped `min` reaches GitHub: there is no +daemon config file, `minimald` is autospawned rather than run from a service +unit, and inside the macOS microVM its init starts with an empty environment, +so no runtime variable could reach it. + +It is a repository **variable, not a secret** — a GitHub App client id is +public, because the device flow is a public-client flow with no client secret. +Do not move it to `secrets`; masking it only makes build logs harder to read. + +An unset variable resolves to the empty string, which the crate treats as "no +App configured": those binaries fail closed with `Error::NotConfigured` and +`min github login` reports the unset variable by name. So a release cut before +the variable is set still builds and ships — GitHub support is simply inert +until the next cut. `MINIMALD_GITHUB_CLIENT_ID` still overrides the baked-in id +at runtime, for GHES, the `github-mock` server, and anyone running their own +App. + **Build jobs.** - `build-release-linux-{amd64,arm64}`: static musl builds of `mip`, `min` diff --git a/docs/specs/10-spec-github-sessions/10-spec-github-sessions.md b/docs/specs/10-spec-github-sessions/10-spec-github-sessions.md new file mode 100644 index 000000000..72e0a2e95 --- /dev/null +++ b/docs/specs/10-spec-github-sessions/10-spec-github-sessions.md @@ -0,0 +1,421 @@ +--- +id: spec-github-sessions +title: "GitHub-integrated minimald sessions — daemon-held auth, mediated repo access, PR on exit" +kind: prd +status: planned +supersedes: +--- + +# PRD — GitHub-integrated minimald sessions + +## Context + +`minimald` hosts isolated development **sessions**: a client (`min`) talks to the daemon +over SSH-on-a-Unix-socket, and each session owns a workspace directory that a user (or an +agent such as Claude) attaches into for interactive work. Today a session is seeded either +by a one-shot **tarball copy-up** of the client's directory or by a host→session +`git push min://` bridge. Neither path talks to GitHub: there is no GitHub API +client, no credential store, no notion of which GitHub user a session belongs to, and no +end-of-session action. Any GitHub work today is entirely manual — a token is injected by +hand and `git`/`gh` are run inside the sandbox. + +This makes the most common developer loop — *start work on a branch, commit, push, open a +PR* — awkward, and it forces a live credential into the sandbox. This PRD defines the +**user requirements** for first-class GitHub support so that branch-based development in a +session is as easy as it is on a laptop, **without any GitHub token ever entering the +sandbox**. + +### Security model (the load-bearing decision) + +**`minimald` is the GitHub App client.** It runs the OAuth **device flow**, holds the +resulting user token in the daemon, and is the only component that ever touches it. + +**The token never enters the sandbox.** Git operations and GitHub API calls made from +inside a session are proxied back to `minimald` over the existing trusted transport (a +`min git` facade, and the same facade for GitHub MCP). `minimald` performs the +authenticated operation on the token's behalf and returns the result. The agent gets +**scoped repo access without ever holding the credential.** + +**Access dies with the sandbox.** Because access is mediated by a live RPC channel to +`minimald`, when the sandbox exits its ability to reach GitHub ends with it — there is no +lingering token on disk to leak or reuse. This is deliberately preferred over injecting a +short-lived token, and over a man-in-the-middle **egress proxy** that would intercept git +traffic to splice in credentials: the facade is an explicit, auditable request surface +rather than transparent interception, and it binds access to the sandbox lifetime. + +The design is grounded in what a **public GitHub App** grants (see +[Authentication model](#authentication-model)): a user-to-server token obtained through +the device flow, attributing work to the **real user**, scoped to the repositories and +permissions the task needs. + +## Goals + +- **G1 — Branch-ready on activate.** Activating a session pre-primes one or more repos and + puts each on the requested branch (checked out if it exists, else created from a base), + so the workspace is a ready git repo the moment the user attaches. +- **G2 — Push during a session.** A user or agent can explicitly push commits at any time + through the mediated facade, attributed to the real user. +- **G3 — PR on exit (opt-in).** At the end of work the user is prompted to open a pull + request, and can accept or decline. +- **G4 — Real-user attribution.** Commits and PRs are authored by the actual user and + appear in GitHub audit logs as that user. +- **G5 — No credential in the sandbox.** The token lives only in `minimald`; the sandbox + reaches GitHub exclusively through the mediated facade, and that access ends when the + sandbox exits. +- **G6 — Least privilege with consent.** Access is scoped to the declared repos and a + minimal permission set; the requested scopes are always shown to the user; the task spec + may narrow them further. +- **G7 — Grounded & incremental.** Every requirement maps to an existing extension seam + in the codebase (notably the `git push min://` bridge as prior art for the facade); + nothing depends on capabilities GitHub does not offer. + +## Non-goals + +- **NG1** — User-*selectable* fine-grained scope control at launch. We **show** the + requested scopes (the OAuth obligation), but a per-scope toggle UI is not shipping at + launch (see [Future work](#future-work)). "Show our work, don't make it user-selectable + out of the gates." +- **NG2** — Multi-*user* / multi-tenant operation inside one daemon. Local is treated as a + single **anonymous user** with prior authentications associated to it. The agreed + scaling axis is **multiple `minimald`s per host**, not multi-tenancy within one; remote + multi-user needs more thought. +- **NG3** — Bot/app-attributed automation identity. Attribution is the real user. (The + installation-token, `app[bot]` path is retained only as a possible future transport + mode.) +- **NG4** — A general merge/review workflow (approvals, required checks, auto-merge). We + open a PR; we do not manage its lifecycle. +- **NG5** — GitHub Enterprise Server. Public GitHub.com only for the MVP. +- **NG6** — `workflows` scope. GitHub Actions workflow permission is **explicitly + excluded** from the default and is not requested at launch. +- **NG7** — Replacing the existing tarball / `git push min://` seeding paths; GitHub + support is additive. + +## Personas + +- **Solo developer (primary).** Runs `min` on their own machine, works on their own or + their org's repos, wants the laptop git loop inside a session without ever handling a + token. +- **Agent-in-session (Claude).** An automated coding agent running inside a session that + is told `min git` exists and uses it to push/pull and to reach the GitHub API — with no + credential in its environment. + +## User stories + +- **US1** — *As a developer, I declare `owner/repo@feat/x` (and optionally more repos) in + my task spec; when I attach, each is cloned and on the right branch (created from `main` + if absent).* +- **US2** — *As a developer already in a local checkout, I activate from it and its + `origin` is wired so I can push/PR as me, without pasting a token.* +- **US3** — *As an agent, I run `min git push` / `min git pull`; the operation succeeds and + is attributed to the user, and I never see a token.* +- **US4** — *As a developer, when I finish I'm asked whether to open a PR from my branch + into its base; if I accept, the PR is created and authored by me.* +- **US5** — *As a security-conscious user, no GitHub credential ever lands in my sandbox, + and when the sandbox exits its GitHub access is gone.* +- **US6** — *As a security-conscious user creating a second sandbox, I'm asked whether to + reuse my existing authentication or mint a fresh, separately-scoped one.* +- **US7** — *As a developer, at launch I can see exactly which repositories and permissions + are being requested before I approve.* + +## Current state (grounding) + +| Capability | Today | Reference | +|---|---|---| +| Session activate | `min activate` = `CreateSession` → `ConfigureLoadout` [→ `SubmitVerdict`] → tarball upload; **no git/branch/repo flags** | `crates/minimal/src/lib.rs:943,239` | +| git-over-our-transport (prior art for the facade) | `git push min://` bridges git's pack protocol over the RPC/SSH transport into a session (`git-receive-pack`) | `crates/minimal/src/git_remote.rs`, `crates/minimald/src/exec.rs:746` | +| GitHub API / PR | **None** anywhere | — | +| git CLI on the daemon host | Yes (used for package sources) | `crates/checkouts/src/repo.rs` | +| Credential injection | **Deferred** — `class='Credential` file mappings are dropped; only env-var inherit works | `crates/mfile/src/package_composable.rs:26`, `crates/graph/src/env_setup.rs:132` | +| Daemon egress to github.com | Reachable (HostNet default); egress policy gating exists | `crates/sessions/src/lib.rs:20`, `crates/minimald/src/net/policy.rs` | +| Identity / user auth | **None** — `username` is an unauthenticated label over trusted local transport | `crates/minimald/src/connection.rs:271` | +| Task spec / session config | Free-form `attrs` on `SessionConfig`/`Record`; project `minimal.toml` `[session]` block | `crates/minimald-rpc/src/lib.rs:206`, `crates/mfile/src/lib.rs:374` | +| CLI extension points | `Session` subcommand group; client `config.toml` | `crates/minimal/src/lib.rs:117`, `crates/sessions/src/client/config.rs` | +| Hosted providers | Named as placeholders (`MinHosted`/`MinCloud`) | `docs/session-domain-diag.md` | + +**Implication:** greenfield, but the transport already exists. The `git push min://` bridge +proves that git's pack protocol can be tunnelled over `minimald`'s RPC channel; the `min +git` facade is the inverse direction (sandbox → daemon → GitHub) of the same idea. The MVP +adds (a) a device-flow auth client **in `minimald`** with a token store; (b) the `min git` +facade + GitHub-MCP proxy; (c) multi-repo pre-priming from the task spec; (d) scope +resolution/consent; and (e) a client-driven PR-on-exit prompt. It must **unblock the +deferred secrets path** — but only inside the daemon, never into the sandbox. + +## Authentication model + +**A GitHub App** (not an OAuth App) named e.g. "minimal", **installed** on the user's +account or org — installation is what grants private-repo access and lets access be scoped +to specific repositories. + +**`minimald` is the OAuth client.** It runs the **device flow** (the `min` CLI is only the +surface that shows the verification URL + `user_code`); the user approves in a browser; and +`minimald` receives and stores a **user access token** (~8h) plus a rotating **refresh +token** (~6mo), associated with the local anonymous user. `minimald` refreshes +transparently, so long sessions keep working. Because it is a user-to-server token, every +operation `minimald` performs with it is attributed to the **real user** (G4). + +**Mediated access, no token in the sandbox.** The sandbox never receives the token. +Instead: + +- **`min git`** — a facade available inside the session that proxies git operations + (`push`, `pull`, `fetch`, `clone`, …) back to `minimald` over the trusted transport; + `minimald` runs the real, authenticated operation against GitHub and streams the result + back. Agents are told `min git` exists and use it in place of raw `git`. +- **GitHub MCP** — GitHub API access (issues, PRs, reviews) uses the **same facade**: the + MCP calls route through `minimald`, which holds the token and enforces scope. + +This means the sandbox does **not** need direct `github.com` egress for GitHub work; only +`minimald` does. Access is bound to the live facade channel and ends when the sandbox exits +(G5). + +**Scopes (least privilege + consent).** + +| Permission | Default | Notes | +|---|---|---| +| `contents` | **read/write** | clone, fetch, push, branches, commits | +| `pull_requests` | **read/write** | open/update PRs | +| `issues` | **read/write** | standard dev work (via GitHub MCP) | +| `metadata` | read | mandatory baseline | +| `workflows` | **excluded** | not requested at launch (NG6) | + +- **Decision rule.** If the **task spec declares explicit required scopes** (optionally + per repo), use them. Otherwise fall back to the **defaults above and prompt the user at + launch** to approve. +- **Show, don't (yet) select.** The requested scopes are always **displayed** to the user + before approval (the OAuth obligation to show requested access). A per-scope toggle UI is + deferred (NG1). +- **Reuse-or-mint.** On creating a **subsequent** sandbox, prompt the user to either + **reuse** the existing authentication or **mint a fresh** token — keeping per-sandbox + scoping possible for the security-conscious. + +**Transport.** When `minimald` talks to GitHub it uses the token as the HTTP password: +`https://x-access-token:@github.com//.git`. PR creation is +`POST /repos/{owner}/{repo}/pulls`, run by `minimald` with the user token so the PR is +authored by the user. + +**Why not an egress proxy.** A MITM egress proxy could splice credentials into git traffic +transparently, but that hides access behind interception and still exposes authenticated +egress to sandbox code. The `min git` facade is an explicit, auditable request surface, +keeps the token entirely in the daemon, and ties access to the sandbox lifetime. + +## Requirements + +Requirement IDs (`Rx.y`) are stable once this spec is approved; this is a pre-approval +revision, so IDs are still being settled. + +### R1 — Authentication & identity (daemon-held) + +- **R1.1** `minimald` MUST authenticate to GitHub via the GitHub App **device flow**, and + MUST store the resulting user + refresh tokens **in the daemon** (never in a + workspace/sandbox). The `min` CLI MUST surface the verification URL and `user_code`. +- **R1.2** `minimald` MUST refresh the user access token transparently for the life of any + session using it; an expired refresh token MUST trigger re-auth rather than silent + failure. +- **R1.3** Tokens MUST be associated with the local **anonymous user** (NG2). Prior + authentications MUST be reusable across sandboxes (subject to R6.4 reuse-or-mint). +- **R1.4** A first-class command surface MUST exist to sign in and inspect status (e.g. + `min github login` / `min github status`), reporting the authenticated login, token + validity, and whether the App is installed on each target repo. +- **R1.5** If the App is **not installed** on a target repo/org, the flow MUST detect this + and guide the user to the installation URL rather than failing opaquely. + +### R2 — Repo pre-priming & branch-aware activation + +- **R2.1** The **task spec** MUST support a repo pre-priming field listing **one or more** + repositories to prepare in the session (monorepo splits or multi-repo working sets, e.g. + `min-vm-mac` + shim + `minimal`). +- **R2.2** For each repo, activation MUST accept a working branch and an optional base + branch (default = repo default branch), and MUST prepare it **checkout-or-create**: + check out `branch` if it exists on the remote, else create it from the base. When the + user attaches, each repo is already on its branch with a working `origin` (G1). +- **R2.3** **Server-side clone mode:** given `owner/repo@branch`, `minimald` clones the + repo into the workspace using the daemon-held token. +- **R2.4** **Adopt-local mode:** when activating from an existing local checkout (the + tarball path), the session MUST wire `origin` to route through the facade and reconcile + the branch (checkout-or-create, defaulting to the checkout's current branch). Existing + tarball / `git push min://` seeding MUST keep working when no GitHub target is given + (NG7). +- **R2.5** Branch creation MUST NOT push implicitly; a new branch exists only in the + workspace until an explicit push (R3). +- **R2.6** Activation MUST fail cleanly and actionably (repo inaccessible, missing scope, + base branch absent, App not installed) without leaving a half-primed workspace. +- **R2.7** Cloning MUST reuse the existing git-CLI wrapper pattern (`crates/checkouts`) and + the established activate RPC sequence. + +### R3 — Mediated repo access (`min git` + MCP) + +- **R3.1** A **`min git`** facade MUST be available inside the session that proxies git + operations (`push`, `pull`, `fetch`, `clone`, `remote`, …) to `minimald`, which performs + the authenticated operation. **No GitHub token may enter the sandbox** (G5). +- **R3.2** The session MUST advertise `min git` to agents (e.g. surfaced in the agent's + in-sandbox instructions) so Claude uses it in place of raw `git`. +- **R3.3** GitHub **MCP** access MUST use the same facade — API calls route through + `minimald`, which holds the token and enforces scope — so issues/PR/review tooling works + without a credential in the sandbox. +- **R3.4** A **first-class explicit push** action MUST exist (`min git push` and/or a + `min session push` convenience). Pushing MUST be explicit; the system MUST NOT auto-push. +- **R3.5** Mediated access MUST be **bound to the sandbox lifetime**: when the sandbox + exits, its facade channel and thus its GitHub access MUST end (G5). +- **R3.6** `minimald` (not the sandbox) MUST have egress to `github.com`. The sandbox MUST + NOT require direct `github.com` egress for facade-mediated GitHub operations. + +### R4 — Pull request on exit + +- **R4.1** At end of work, the user MUST be **prompted** whether to open a PR for a session + branch into its base; no PR is created without confirmation (G3). +- **R4.2** Because attach-shell exit is not observed by the daemon today, the prompt MUST + be **client-driven** — on `min attach` shell exit and/or an explicit teardown command + (e.g. `min session finish` / enhanced `min destroy`). +- **R4.3** On confirmation, the branch MUST be pushed (via the facade) and the PR created + by `minimald` using the daemon-held user token, so the PR is authored by the user (G4). +- **R4.4** The PR body SHOULD pre-populate from the repo's PR template if present; base + defaults to the branch's base; draft-vs-ready is an open question (OQ2). +- **R4.5** If a PR already exists for the branch, the flow MUST detect and surface/update + it rather than duplicating. +- **R4.6** Declining MUST leave the pushed branch intact and MUST NOT block teardown. In a + multi-repo session, the prompt MUST cover each repo with unpushed/PR-able work. + +### R5 — Scopes & least privilege + +- **R5.1** The default scope set MUST be `contents:rw`, `pull_requests:rw`, `issues:rw`, + `metadata:read`; `workflows` MUST be excluded (NG6). +- **R5.2** If the task spec declares explicit required scopes (optionally per repo), the + system MUST use them; otherwise it MUST apply the defaults and **prompt at launch**. +- **R5.3** The requested scopes (and repos) MUST be **displayed** to the user before + approval. A per-scope selection UI is out of scope for launch (NG1). +- **R5.4** Token scope MUST be bounded to the declared repositories (least repo) and the + resolved permission set (least privilege). + +### R6 — Token lifecycle & security + +- **R6.1** The token MUST live only in `minimald`; it MUST NOT be written into the + workspace, the sandbox environment, the session tarball, or any sandbox-visible file. +- **R6.2** Tokens MUST be short-lived and refreshable (R1.2), and MUST be **redacted** from + logs and diagnostic bundles (extend the existing redaction denylist). +- **R6.3** On sandbox exit/destroy, the facade channel MUST close so mediated access ends; + no credential material may persist in the workspace (R3.5). +- **R6.4** **Reuse-or-mint:** creating a subsequent sandbox MUST prompt to reuse the + existing authentication or mint a fresh, separately-scoped token, preserving per-sandbox + scoping. + +### R7 — Configuration, task spec & CLI surface + +- **R7.1** Repo pre-priming and optional per-repo scopes MUST be expressible in the **task + spec** (project `minimal.toml` `[session]` and/or the activation request); carried first + via `SessionConfig.attrs` and promotable to typed fields, with new persisted fields + `#[serde(default)]` for back-compat. +- **R7.2** New commands SHOULD live under the existing `Session` group (`min session + push`/`pr`) plus a small `github` group (`min github login`/`status`); `min git` is the + in-sandbox facade. + +### R8 — Observability & errors + +- **R8.1** Auth, clone/branch, facade, push, and PR steps MUST emit structured `tracing` + spans and actionable, non-secret errors (e.g. "App not installed on owner/repo", "scope + contents:write not granted", "base branch main not found"). +- **R8.2** `min github status` MUST let a user self-diagnose: identity, token validity, App + installation state, and each session repo's target/branch/scope. + +## UX flows + +**First-time auth (device flow owned by `minimald`):** + +``` +min github login +# → minimald starts the device flow; min shows: +# "Open https://github.com/login/device and enter code ABCD-1234" +# → user approves in browser; minimald stores the token (local anonymous user) +``` + +**Activate with pre-primed repos + scope consent:** + +``` +# task spec lists repos (and optionally per-repo scopes) +min activate --attach +# → if the spec declares scopes: used directly +# else: "This session will request repos: owner/api@feat/x, owner/web@feat/x +# scopes: contents:rw, pull_requests:rw, issues:rw [approve? y/N]" +# → (subsequent sandbox) "Reuse existing GitHub auth, or mint a fresh one? [reuse/mint]" +# → each repo cloned and on its branch; user attaches +``` + +**Work & push (no token in sandbox):** + +``` +# inside the session (human or agent) +git commit -am "…" +min git push # proxied to minimald; attributed to the user +``` + +**Exit → PR:** + +``` +exit +# → "Open a PR for owner/api feat/x → main? [y/N]" +# on y: facade pushes, minimald creates the PR authored by the user +``` + +## Technical grounding & integration seams + +- **Auth in `minimald`:** device-flow + token-refresh + token store in the daemon; unblock + the deferred secrets path (`crates/mfile/src/package_composable.rs:26`, + `crates/graph/src/env_setup.rs:132`) **daemon-side only**, never into the sandbox. +- **`min git` facade:** model on the existing git-over-transport bridge + (`crates/minimal/src/git_remote.rs`, `crates/minimald/src/exec.rs:746`) — the inverse + direction (sandbox → daemon → GitHub). Reuse the `crates/checkouts` git-CLI wrapper for + the daemon-side operations. +- **Pre-priming & activation:** extend `ActivateArgs` (`crates/minimal/src/lib.rs:239`) and + the `CreateSession`/`ConfigureLoadout` sequence; carry repos/scopes via + `SessionConfig.attrs` (`crates/minimald-rpc/src/lib.rs:206`). +- **Scope consent / reuse-or-mint:** client-side prompts in the activate flow + (`crates/minimal/src/lib.rs:943`), driven by resolved scopes from the task spec. +- **PR on exit:** client-driven prompt around `min attach`/`min destroy` + (`crates/minimal/src/lib.rs:1109,1322`); the API call is made by `minimald` with the + token. (A future headless path could implement the deferred `on_destroy` lifecycle-hook + executor — `crates/sessions/src/core/lifecyclehook.rs`, + `crates/minimald/src/session_host.rs:245`.) +- **Egress:** `minimald` needs `github.com` egress (`crates/minimald/src/net/policy.rs`); + the sandbox does not, for mediated operations. + +## Future work + +- **FW1** Remote / multi-user auth (NG2) — associating tokens beyond the local anonymous + user; the agreed scaling axis is **multiple `minimald`s per host**, not multi-tenancy + within one. `MinHosted`/`MinCloud` in `docs/session-domain-diag.md` are the anchor; in a + hosted model the same device grant is driven from the hosted side (`user_code` shown in + the session/web UI). +- **FW2** User-selectable fine-grained scope control at launch (NG1). +- **FW3** Dynamic per-task-launch scope requests (see OQ1 — an active POC). +- **FW4** Headless PR-on-exit via the deferred `on_destroy` lifecycle-hook executor. +- **FW5** GitHub Enterprise Server (NG5). +- **FW6** Optional bot/installation-token transport mode for automation identities (NG3). + +## Open questions + +- **OQ1** Whether scopes can be **dynamically requested per task launch** (POC in + progress), rather than fixed at first auth. +- **OQ2** How to **present the scope list back through the `min` client** — the exact + consent UX (and draft-vs-ready default for PRs). +- **OQ3** Reuse-or-mint default and granularity (per-repo vs per-session). + +## Appendix — GitHub token reference + +| Property | User-to-server (chosen) | Installation (future/alt) | +|---|---|---| +| Client / holder | **`minimald`** (device flow) | `minimald`/backend (App JWT) | +| Obtain | OAuth **device flow** | `POST /app/installations/{id}/access_tokens` | +| Lifetime | ~8h token + rotating ~6mo refresh token | ~1h, not refreshable (re-mint) | +| Attribution | **Real user** | `app[bot]` | +| Repo scoping | user access ∩ App install ∩ declared repos | `repositories`/`repository_ids` at mint | +| In sandbox? | **Never** — mediated via `min git` / MCP facade | Never | +| Default scopes | `contents:rw`, `pull_requests:rw`, `issues:rw`, `metadata:read`; **no `workflows`** | same policy | + +### Sources + +- Generating an installation access token for a GitHub App — +- Create an installation access token for an app (REST) — +- Authenticating as a GitHub App installation — +- Generating a user access token for a GitHub App — +- Authenticating on behalf of a user (device flow / attribution) — +- Device flow — +- Refreshing user access tokens — +- Choosing permissions for a GitHub App —