Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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. |
Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"crates/common",
"crates/decode",
"crates/diagnostics",
"crates/github",
"crates/graph",
"crates/mctx",
"crates/mfile",
Expand Down Expand Up @@ -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"] }
Expand All @@ -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" }
Expand Down
75 changes: 75 additions & 0 deletions crates/github/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
20 changes: 20 additions & 0 deletions crates/github/build.rs
Original file line number Diff line number Diff line change
@@ -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");
}
156 changes: 156 additions & 0 deletions crates/github/src/attrs.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>` 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<GrantId>,
/// The repositories to pre-prime.
pub repos: Vec<RepoSpec>,
/// The resolved scope set, if one was recorded.
pub scopes: Option<ScopeSet>,
}

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<String, String>) {
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::<Vec<_>>()
.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<String, String>) -> Result<Self, Error> {
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::<Result<Vec<_>, _>>()?,
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());
}
}
Loading