diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4715aee --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "sip" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ef8a50a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "sip" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bb2a50 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# sip + +`sip` is a minimal CI control-plane sketch. Git hook glue is implemented with +Plan 9 `rc`; trusted control-plane behavior is implemented in Rust. It follows +the design in [ci-system-design-notes.md](ci-system-design-notes.md): +Git hooks protect CI-owned refs, push events are durably queued, and a worker +turns accepted branch/tag pushes into small manifests under `refs/ci/*`. +The current durable record formats are defined in [docs/schemas.md](docs/schemas.md). + +## What exists + +- `hooks/pre-receive.rc` rejects unprivileged writes to `refs/ci/*` and + `refs/workflows/*`. +- `hooks/post-receive.rc` records accepted branch and tag updates in + `$GIT_DIR/sip/queue`. +- `bin/sip-worker.rc` launches the Rust worker, which consumes queued events, + peels refs to commit IDs, detects `.github/workflows`, and writes manifest + blobs to `refs/ci/runs/`, plus latest status refs under + `refs/ci/status/heads/*` or `refs/ci/status/tags/*`. +- `bin/sip-install-hooks.rc` installs the hooks into a target repository. + +## Usage + +```sh +bin/sip-install-hooks.rc /path/to/bare.git +bin/sip-worker.rc /path/to/bare.git +``` + +## Tests + +```sh +tests/integration.rc +``` + +Protected refs are rejected from the Git receive path. Server-side maintenance +for `refs/workflows/*` and `refs/ci/*` must run outside `git-receive-pack`, for +example with a local `git fetch` or `git update-ref` performed by an admin +process on the server. Do not expose protected-ref writes through client +environment variables. + +Set `SIP_ACTOR` in the trusted Git wrapper before invoking `git-receive-pack` if +you want queued events to record the authenticated actor. + +## Current boundary + +This is only the Git-event and state-recording base. It does not execute +workflows, expose secrets, run WASM, or upload artifacts. Those should be added +behind explicit host capabilities rather than as ambient shell access. + +## Implementation Direction + +Plan 9 `rc` stays limited to Git hook glue, hook installation, and thin process +launching. Trusted control-plane components should be implemented in Rust, +including the Wasmtime host, capability enforcement, scheduler/job records, +structured validation that outgrows the RC scripts, and any ref/state mutation +logic that becomes security-sensitive. Do not introduce C for trusted system +components. diff --git a/bin/sip-install-hooks.rc b/bin/sip-install-hooks.rc new file mode 100755 index 0000000..0fa60dd --- /dev/null +++ b/bin/sip-install-hooks.rc @@ -0,0 +1,28 @@ +#!/usr/bin/env rc + +fn usage { + echo 'usage: sip-install-hooks.rc REPO' >[1=2] + exit 2 +} + +if(! ~ $#* 1) + usage + +repo=$1 +if(! git -C $repo rev-parse --git-dir >/dev/null >[2=1]) + usage + +scriptdir=`{dirname $0} +root=`{cd $scriptdir^/..; pwd} +gitdir=`{git -C $repo rev-parse --git-dir} + +if(! ~ $gitdir /*) + gitdir=$repo^/$gitdir + +hookdir=$gitdir^/hooks +mkdir -p $hookdir +cp $root^/hooks/pre-receive.rc $hookdir^/pre-receive +cp $root^/hooks/post-receive.rc $hookdir^/post-receive +chmod +x $hookdir^/pre-receive $hookdir^/post-receive + +echo installed sip hooks in $hookdir diff --git a/bin/sip-worker.rc b/bin/sip-worker.rc new file mode 100755 index 0000000..2a5e72e --- /dev/null +++ b/bin/sip-worker.rc @@ -0,0 +1,21 @@ +#!/usr/bin/env rc + +fn usage { + echo 'usage: sip-worker.rc REPO' >[1=2] + exit 2 +} + +if(! ~ $#* 1) + usage + +scriptdir=`{dirname $0} +root=`{cd $scriptdir^/..; pwd} +repo=$1 + +if(test -x $root^/target/debug/sip) { + $root^/target/debug/sip worker $repo +} else if(cargo --version >/dev/null >[2=1]) { + cargo run --quiet --manifest-path $root^/Cargo.toml -- worker $repo +} else { + nix shell nixpkgs#rustc nixpkgs#cargo -c cargo run --quiet --manifest-path $root^/Cargo.toml -- worker $repo +} diff --git a/docs/schemas.md b/docs/schemas.md new file mode 100644 index 0000000..b43d585 --- /dev/null +++ b/docs/schemas.md @@ -0,0 +1,99 @@ +# Durable Schemas + +This document defines the durable records written by the RC hook and Rust worker +baseline. These formats are intentionally small and line-oriented so they can be +created and inspected with Git and command-line tooling. + +## Queue Event Version 1 + +Queue events live at: + +```text +$GIT_DIR/sip/queue/.event +``` + +Each event is exactly one tab-separated line with seven fields: + +```text +1old_oidnew_oidrefactorqueued_atkind +``` + +Fields: + +- `1`: schema version. +- `old_oid`: 40 lowercase hexadecimal object ID, or all zeroes for creates. +- `new_oid`: 40 lowercase hexadecimal object ID, or all zeroes for deletes. +- `ref`: trigger ref. Version 1 accepts only `refs/heads/*` and `refs/tags/*`. +- `actor`: sanitized actor identifier matching `[A-Za-z0-9._@+-]+`. +- `queued_at`: Unix timestamp in seconds. +- `kind`: one of `create`, `update`, or `delete`. + +Kind invariants: + +- `create`: `old_oid` is zero and `new_oid` is not zero. +- `update`: neither object ID is zero. +- `delete`: `old_oid` is not zero and `new_oid` is zero. + +Unsupported versions, unknown ref namespaces, malformed object IDs, invalid +actor values, and inconsistent kind/object combinations are moved to +`$GIT_DIR/sip/failed`. + +## Run Manifest Version 1 + +Run manifests are stored as Git blobs and referenced by: + +```text +refs/ci/runs/ +refs/ci/status/heads/ +refs/ci/status/tags/ +``` + +Each manifest is newline-delimited `key=value` text with these required keys: + +```text +version=1 +run_id= +status=queued +event_ref= +event_old= +event_new= +event_kind= +actor= +queued_at= +checkout_oid= +workflow_source= +workflow_oid= +workflow_digest= +workflow_trust= +capability_grants= +``` + +`checkout_oid` is a peeled commit ID for non-delete events. It is `none` for +delete events or for objects that cannot be resolved to a commit. Workflow +fields are `none` unless a workflow source is selected. + +Workflow source policy: + +- `refs/workflows/default` is the default protected workflow ref. Workers may + read a different protected ref when `SIP_WORKFLOW_REF` is set. +- A protected workflow ref wins over pushed worktree workflows when it exists + and contains `.github/workflows`. +- Pushed worktree workflows are selected only when no protected workflow is + available and the event commit contains `.github/workflows`. +- Protected workflows record `workflow_trust=protected`. +- Pushed worktree workflows record `workflow_trust=untrusted`. +- Version 1 records capability grants as a comma-separated audit field only; + later host capability code must enforce the grants before executing workflow + code. + +Status refs are latest pointers, not the source of truth for immutable run +history. The worker always writes immutable `refs/ci/runs/` records +for valid events. It updates `refs/ci/status/*` only when the triggering Git ref +still matches the event: + +- create/update events: the triggering ref must still resolve to `event_new`. +- delete events: the triggering ref must still be absent. + +The status update uses `git update-ref` with the status ref's previous object ID +as a compare-and-swap guard. If the status ref changed concurrently, the run +record remains but the latest status pointer is left unchanged. diff --git a/hooks/post-receive.rc b/hooks/post-receive.rc new file mode 100755 index 0000000..79391be --- /dev/null +++ b/hooks/post-receive.rc @@ -0,0 +1,61 @@ +#!/usr/bin/env rc + +gitdir=`{git rev-parse --git-dir} +queue=$gitdir^/sip/queue +if(! mkdir -p $queue) { + echo sip: could not create event queue $queue >[1=2] + exit 1 +} + +# Keep hooks low latency: record branch/tag events and let sip-worker.rc do the +# slower scheduling and refs/ci mutation work after the push has returned. +awk -v queue=$queue ' +function zero_oid(oid) { + return oid ~ "^0+$" +} + +function event_kind(old, new) { + if (zero_oid(old)) + return "create" + if (zero_oid(new)) + return "delete" + return "update" +} + +function clean_actor(actor) { + gsub(/[[:space:]]+/, "_", actor) + gsub(/[^A-Za-z0-9._@+-]/, "_", actor) + return actor +} + +function event_path(id, n, path) { + path = queue "/" id ".event" + n = 0 + while ((getline < path) >= 0) { + close(path) + n++ + path = queue "/" id "-" n ".event" + } + close(path) + return path +} + +$3 ~ "^refs/(heads|tags)/" { + if ($1 !~ "^[0-9a-f]{40}$" || $2 !~ "^[0-9a-f]{40}$") { + print "sip: malformed receive record for " $3 > "/dev/stderr" + exit 1 + } + + id = systime() "-" NR "-" substr($1, 1, 8) "-" substr($2, 1, 12) + path = event_path(id) + actor = clean_actor(ENVIRON["SIP_ACTOR"]) + if (actor == "") + actor = "unknown" + + printf "1\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, actor, systime(), event_kind($1, $2) > path + if (close(path) != 0) { + print "sip: could not write event " path > "/dev/stderr" + exit 1 + } +} +' diff --git a/hooks/pre-receive.rc b/hooks/pre-receive.rc new file mode 100755 index 0000000..be19bb6 --- /dev/null +++ b/hooks/pre-receive.rc @@ -0,0 +1,35 @@ +#!/usr/bin/env rc + +# Reject writes to CI-controlled namespaces from git-receive-pack. Server-side +# admin tools that need to maintain these refs must update them outside the +# client receive path. +awk ' +function reject(message) { + print message > "/dev/stderr" + bad = 1 +} + +{ + old = $1 + new = $2 + ref = $3 + + if (old !~ "^[0-9a-f]{40}$" || new !~ "^[0-9a-f]{40}$") { + reject("sip: malformed receive record for " ref) + next + } + + if (ref ~ "^refs/(ci|workflows)/") + reject("sip: updates to " ref " are restricted") + + if (ref ~ "^refs/(notes|replace)/") + reject("sip: updates to " ref " are not accepted") + + if (ref !~ "^refs/(heads|tags|ci|workflows|pull|merge|notes|replace)/") + reject("sip: updates to unknown ref namespace " ref " are not accepted") +} + +END { + exit bad ? 1 : 0 +} +' diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ed347f5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,525 @@ +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const ZERO_OID: &str = "0000000000000000000000000000000000000000"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventKind { + Create, + Update, + Delete, +} + +impl EventKind { + fn parse(value: &str) -> Option { + match value { + "create" => Some(Self::Create), + "update" => Some(Self::Update), + "delete" => Some(Self::Delete), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + } + } +} + +#[derive(Debug)] +struct Event { + old: String, + new: String, + reference: String, + actor: String, + queued_at: String, + kind: EventKind, +} + +#[derive(Debug)] +struct Workflow { + source: String, + oid: String, + digest: String, + trust: String, + grants: String, +} + +impl Workflow { + fn none() -> Self { + Self { + source: "none".to_string(), + oid: "none".to_string(), + digest: "none".to_string(), + trust: "none".to_string(), + grants: "none".to_string(), + } + } +} + +#[derive(Debug)] +struct StateDirs { + queue: PathBuf, + done: PathBuf, + failed: PathBuf, + tmp: PathBuf, +} + +fn main() { + if let Err(err) = run() { + eprintln!("sip: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("worker") => { + let repo = args + .next() + .ok_or_else(|| "usage: sip worker REPO".to_string())?; + if args.next().is_some() { + return Err("usage: sip worker REPO".to_string()); + } + run_worker(Path::new(&repo)) + } + _ => Err("usage: sip worker REPO".to_string()), + } +} + +fn run_worker(repo: &Path) -> Result<(), String> { + if !git_status(repo, ["rev-parse", "--git-dir"])? { + return Err("usage: sip worker REPO".to_string()); + } + + let git_dir_raw = git_output(repo, ["rev-parse", "--git-dir"])? + .ok_or_else(|| "could not resolve git dir".to_string())?; + let git_dir = if Path::new(&git_dir_raw).is_absolute() { + PathBuf::from(git_dir_raw) + } else { + repo.join(git_dir_raw) + }; + let state = git_dir.join("sip"); + let dirs = StateDirs { + queue: state.join("queue"), + done: state.join("done"), + failed: state.join("failed"), + tmp: state.join("tmp"), + }; + for dir in [&dirs.queue, &dirs.done, &dirs.failed, &dirs.tmp] { + fs::create_dir_all(dir) + .map_err(|err| format!("could not create {}: {err}", dir.display()))?; + } + + let protected_workflow_ref = + env::var("SIP_WORKFLOW_REF").unwrap_or_else(|_| "refs/workflows/default".to_string()); + let mut events = queue_events(&dirs.queue)?; + events.sort(); + + for event_path in events { + let name = event_path + .file_name() + .and_then(OsStr::to_str) + .and_then(|name| name.strip_suffix(".event")) + .ok_or_else(|| format!("invalid queue filename {}", event_path.display()))? + .to_string(); + + let event = match parse_event(&event_path) { + Ok(event) => event, + Err(_) => { + fail_event(&event_path, &dirs.failed, &name)?; + continue; + } + }; + + process_event(repo, &dirs, &protected_workflow_ref, &name, &event)?; + } + + Ok(()) +} + +fn queue_events(queue: &Path) -> Result, String> { + let mut events = Vec::new(); + for entry in fs::read_dir(queue).map_err(|err| format!("could not read queue: {err}"))? { + let entry = entry.map_err(|err| format!("could not read queue entry: {err}"))?; + let path = entry.path(); + if path.extension().and_then(OsStr::to_str) == Some("event") && path.is_file() { + events.push(path); + } + } + Ok(events) +} + +fn parse_event(path: &Path) -> Result { + let body = fs::read_to_string(path) + .map_err(|err| format!("could not read {}: {err}", path.display()))?; + let mut lines = body.lines(); + let line = lines.next().ok_or_else(|| "empty event".to_string())?; + if lines.next().is_some() || body.ends_with("\n\n") { + return Err("event must contain exactly one record".to_string()); + } + + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() != 7 { + return Err("event must contain seven fields".to_string()); + } + if fields[0] != "1" { + return Err("unsupported event version".to_string()); + } + + let old = fields[1]; + let new = fields[2]; + let reference = fields[3]; + let actor = fields[4]; + let queued_at = fields[5]; + let kind = EventKind::parse(fields[6]).ok_or_else(|| "invalid event kind".to_string())?; + + if !is_oid(old) || !is_oid(new) { + return Err("invalid object id".to_string()); + } + if !(reference.starts_with("refs/heads/") || reference.starts_with("refs/tags/")) { + return Err("invalid event ref namespace".to_string()); + } + if actor.is_empty() || !actor.bytes().all(is_actor_byte) { + return Err("invalid actor".to_string()); + } + if queued_at.is_empty() || !queued_at.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("invalid queued_at".to_string()); + } + + match kind { + EventKind::Create if old == ZERO_OID && new != ZERO_OID => {} + EventKind::Update if old != ZERO_OID && new != ZERO_OID => {} + EventKind::Delete if old != ZERO_OID && new == ZERO_OID => {} + _ => return Err("event kind does not match object ids".to_string()), + } + + Ok(Event { + old: old.to_string(), + new: new.to_string(), + reference: reference.to_string(), + actor: actor.to_string(), + queued_at: queued_at.to_string(), + kind, + }) +} + +fn process_event( + repo: &Path, + dirs: &StateDirs, + protected_workflow_ref: &str, + name: &str, + event: &Event, +) -> Result<(), String> { + let event_path = dirs.queue.join(format!("{name}.event")); + let checkout_oid = checkout_oid(repo, event)?; + let workflow = resolve_workflow(repo, protected_workflow_ref, checkout_oid.as_deref())?; + let manifest = render_manifest(name, event, checkout_oid.as_deref(), &workflow); + let manifest_path = dirs.tmp.join(format!("{name}.manifest")); + fs::write(&manifest_path, manifest) + .map_err(|err| format!("could not write {}: {err}", manifest_path.display()))?; + + let blob = match git_output(repo, ["hash-object", "-w", path_str(&manifest_path)?])? { + Some(blob) => blob, + None => { + let _ = fs::remove_file(&manifest_path); + move_event(&event_path, &dirs.failed, name)?; + return Err(format!("failed to hash manifest for {name}")); + } + }; + let _ = fs::remove_file(&manifest_path); + + let run_ref = format!("refs/ci/runs/{name}"); + if !git_status(repo, ["check-ref-format", run_ref.as_str()])? { + eprintln!("sip: invalid run ref {run_ref}"); + move_event(&event_path, &dirs.failed, name)?; + return Ok(()); + } + + if git_status(repo, ["update-ref", run_ref.as_str(), blob.as_str()])? { + update_latest_status(repo, event, &blob)?; + move_event(&event_path, &dirs.done, name)?; + println!("queued {run_ref}"); + } else { + eprintln!("sip: failed to update {run_ref}"); + move_event(&event_path, &dirs.failed, name)?; + } + + Ok(()) +} + +fn checkout_oid(repo: &Path, event: &Event) -> Result, String> { + if event.new == ZERO_OID { + return Ok(None); + } + git_output( + repo, + ["rev-parse", format!("{}^{{commit}}", event.new).as_str()], + ) +} + +fn resolve_workflow( + repo: &Path, + protected_workflow_ref: &str, + checkout_oid: Option<&str>, +) -> Result { + let mut workflow = Workflow::none(); + + if let Some(commit) = checkout_oid { + if has_workflow_tree(repo, commit)? { + workflow = Workflow { + source: "worktree:.github/workflows".to_string(), + oid: commit.to_string(), + digest: tree_oid(repo, commit)?.unwrap_or_else(|| "none".to_string()), + trust: "untrusted".to_string(), + grants: "status.set,jobs.enqueue,artifacts.put".to_string(), + }; + } + } + + if git_status( + repo, + ["show-ref", "--verify", "--quiet", protected_workflow_ref], + )? { + if let Some(commit) = git_output( + repo, + [ + "rev-parse", + format!("{protected_workflow_ref}^{{commit}}").as_str(), + ], + )? { + if has_workflow_tree(repo, &commit)? { + workflow = Workflow { + source: format!("protected:{protected_workflow_ref}"), + oid: commit.clone(), + digest: tree_oid(repo, &commit)?.unwrap_or_else(|| "none".to_string()), + trust: "protected".to_string(), + grants: "status.set,jobs.enqueue,artifacts.put,http.fetch_allowed,secrets.sign" + .to_string(), + }; + } + } + } + + Ok(workflow) +} + +fn has_workflow_tree(repo: &Path, commit: &str) -> Result { + Ok(git_output( + repo, + [ + "ls-tree", + "-r", + "--name-only", + commit, + "--", + ".github/workflows", + ], + )? + .map(|output| !output.is_empty()) + .unwrap_or(false)) +} + +fn tree_oid(repo: &Path, commit: &str) -> Result, String> { + git_output( + repo, + ["rev-parse", format!("{commit}:.github/workflows").as_str()], + ) +} + +fn render_manifest( + name: &str, + event: &Event, + checkout_oid: Option<&str>, + workflow: &Workflow, +) -> String { + format!( + concat!( + "version=1\n", + "run_id={name}\n", + "status=queued\n", + "event_ref={event_ref}\n", + "event_old={event_old}\n", + "event_new={event_new}\n", + "event_kind={event_kind}\n", + "actor={actor}\n", + "queued_at={queued_at}\n", + "checkout_oid={checkout_oid}\n", + "workflow_source={workflow_source}\n", + "workflow_oid={workflow_oid}\n", + "workflow_digest={workflow_digest}\n", + "workflow_trust={workflow_trust}\n", + "capability_grants={capability_grants}\n", + ), + name = name, + event_ref = event.reference, + event_old = event.old, + event_new = event.new, + event_kind = event.kind.as_str(), + actor = event.actor, + queued_at = event.queued_at, + checkout_oid = checkout_oid.unwrap_or("none"), + workflow_source = workflow.source, + workflow_oid = workflow.oid, + workflow_digest = workflow.digest, + workflow_trust = workflow.trust, + capability_grants = workflow.grants, + ) +} + +fn update_latest_status(repo: &Path, event: &Event, blob: &str) -> Result<(), String> { + if !status_is_current(repo, event)? { + return Ok(()); + } + + let status_ref = if let Some(branch) = event.reference.strip_prefix("refs/heads/") { + format!("refs/ci/status/heads/{branch}") + } else if let Some(tag) = event.reference.strip_prefix("refs/tags/") { + format!("refs/ci/status/tags/{tag}") + } else { + return Ok(()); + }; + + if !git_status(repo, ["check-ref-format", status_ref.as_str()])? { + return Ok(()); + } + + let expected = if git_status( + repo, + ["show-ref", "--verify", "--quiet", status_ref.as_str()], + )? { + git_output(repo, ["rev-parse", "--verify", status_ref.as_str()])? + .unwrap_or_else(|| ZERO_OID.to_string()) + } else { + ZERO_OID.to_string() + }; + + let _ = git_status( + repo, + ["update-ref", status_ref.as_str(), blob, expected.as_str()], + )?; + + Ok(()) +} + +fn status_is_current(repo: &Path, event: &Event) -> Result { + if event.kind == EventKind::Delete { + return git_status( + repo, + ["show-ref", "--verify", "--quiet", event.reference.as_str()], + ) + .map(|exists| !exists); + } + + if !git_status( + repo, + ["show-ref", "--verify", "--quiet", event.reference.as_str()], + )? { + return Ok(false); + } + + Ok( + git_output(repo, ["rev-parse", "--verify", event.reference.as_str()])? + .map(|oid| oid == event.new) + .unwrap_or(false), + ) +} + +fn fail_event(path: &Path, failed: &Path, name: &str) -> Result<(), String> { + eprintln!("sip: malformed event {}", path.display()); + move_event(path, failed, name) +} + +fn move_event(path: &Path, dir: &Path, name: &str) -> Result<(), String> { + let destination = dir.join(format!("{name}.event")); + match fs::rename(path, &destination) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + fs::remove_file(&destination).map_err(|remove_err| { + format!("could not replace {}: {remove_err}", destination.display()) + })?; + fs::rename(path, &destination).map_err(|rename_err| { + format!( + "could not move {} to {}: {rename_err}", + path.display(), + destination.display() + ) + }) + } + Err(err) => Err(format!( + "could not move {} to {}: {err}", + path.display(), + destination.display() + )), + } +} + +fn git_status(repo: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let status = git_command(repo, args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| format!("could not run git: {err}"))?; + Ok(status.success()) +} + +fn git_output(repo: &Path, args: I) -> Result, String> +where + I: IntoIterator, + S: AsRef, +{ + let output = git_command(repo, args) + .stderr(Stdio::null()) + .output() + .map_err(|err| format!("could not run git: {err}"))?; + if !output.status.success() { + return Ok(None); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|err| format!("git output was not utf-8: {err}"))?; + let value = stdout.trim_end_matches(['\r', '\n']).to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn git_command(repo: &Path, args: I) -> Command +where + I: IntoIterator, + S: AsRef, +{ + let mut command = Command::new("git"); + command.arg("-C").arg(repo).args(args); + command.env("GIT_NO_REPLACE_OBJECTS", "1"); + command +} + +fn path_str(path: &Path) -> Result<&str, String> { + path.to_str() + .ok_or_else(|| format!("path is not utf-8: {}", path.display())) +} + +fn is_oid(value: &str) -> bool { + value.len() == 40 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn is_actor_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'@' | b'+' | b'-') +} diff --git a/tests/integration.rc b/tests/integration.rc new file mode 100755 index 0000000..344828f --- /dev/null +++ b/tests/integration.rc @@ -0,0 +1,260 @@ +#!/usr/bin/env rc + +root=`{cd `{dirname $0}^/..; pwd} + +fn fail { + echo not ok - $* >[1=2] + exit 1 +} + +fn assert_eq { + got=$1 + want=$2 + msg=$3 + if(! test $got = $want) + fail $msg^': got '^$got^', want '^$want +} + +fn assert_contains { + file=$1 + needle=$2 + msg=$3 + if(! grep -F $needle $file >/dev/null >[2=1]) { + echo --- $file --- >[1=2] + sed -n '1,120p' $file >[1=2] + fail $msg^': missing '^$needle + } +} + +fn assert_ref_exists { + repo=$1 + ref=$2 + if(! git -C $repo rev-parse --verify $ref >/dev/null >[2=1]) + fail expected ref $ref to exist +} + +fn assert_event_schema { + file=$1 + awk -F ' ' ' + function zero_oid(oid) { + return oid ~ "^0+$" + } + + NR != 1 { + exit 1 + } + + { + if (NF != 7) + exit 1 + if ($1 != "1") + exit 1 + if ($2 !~ "^[0-9a-f]{40}$" || $3 !~ "^[0-9a-f]{40}$") + exit 1 + if ($4 !~ "^refs/(heads|tags)/") + exit 1 + if ($5 !~ "^[A-Za-z0-9._@+-]+$") + exit 1 + if ($6 !~ "^[0-9]+$") + exit 1 + if ($7 !~ "^(create|update|delete)$") + exit 1 + if ($7 == "create" && (!zero_oid($2) || zero_oid($3))) + exit 1 + if ($7 == "update" && (zero_oid($2) || zero_oid($3))) + exit 1 + if ($7 == "delete" && (zero_oid($2) || !zero_oid($3))) + exit 1 + } + + END { + if (NR != 1) + exit 1 + } + ' $file || fail event schema validation failed for $file +} + +fn setup_pair { + tmp=`{mktemp -d /tmp/sip-it.XXXXXX} + mkdir -p $tmp^/src + git init --bare $tmp^/remote.git >/dev/null || fail could not init bare repo + git -C $tmp^/src init >/dev/null || fail could not init source repo + git -C $tmp^/src config user.email test@example.invalid + git -C $tmp^/src config user.name 'Sip Test' + $root^/bin/sip-install-hooks.rc $tmp^/remote.git >/dev/null || fail could not install hooks + git -C $tmp^/src remote add origin $tmp^/remote.git + echo $tmp +} + +fn test_branch_tag_and_protected_refs { + tmp=`{setup_pair} + src=$tmp^/src + remote=$tmp^/remote.git + + mkdir -p $src^/.github/workflows + printf 'name: test\n' >$src^/.github/workflows/ci.yml + printf 'hello\n' >$src^/README.md + git -C $src add README.md .github/workflows/ci.yml + git -C $src commit -m initial >/dev/null || fail could not commit initial workflow repo + git -C $src branch -M main + + SIP_ACTOR='alice smith' git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push branch create + + printf 'again\n' >>$src^/README.md + git -C $src commit -am update >/dev/null || fail could not commit branch update + SIP_ACTOR=alice git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push branch update + + git -C $src tag v1 + SIP_ACTOR=alice git -C $src push origin v1 >/dev/null >[2]/dev/null || fail could not push tag create + SIP_ACTOR=alice git -C $src push origin :v1 >/dev/null >[2]/dev/null || fail could not push tag delete + + queue_count=`{find $remote^/sip/queue -type f -name '*.event' | awk 'END { print NR }'} + assert_eq $queue_count 4 'branch update and tag pushes should queue four events' + for(event in $remote^/sip/queue/*.event) + assert_event_schema $event + if(! awk -F ' ' '$5 == "alice_smith" { found = 1 } END { exit found ? 0 : 1 }' $remote^/sip/queue/*.event) + fail queue event sanitizes actor values + + $root^/bin/sip-worker.rc $remote >/dev/null || fail worker failed + + run_count=`{git -C $remote for-each-ref '--format=%(refname)' refs/ci/runs | awk 'END { print NR }'} + assert_eq $run_count 4 'worker should create one run ref per event' + assert_ref_exists $remote refs/ci/status/heads/main + assert_ref_exists $remote refs/ci/status/tags/v1 + + git -C $remote cat-file -p refs/ci/status/heads/main >$tmp^/main.manifest + assert_contains $tmp^/main.manifest version=1 'manifest records schema version' + assert_contains $tmp^/main.manifest event_ref=refs/heads/main 'manifest records branch ref' + assert_contains $tmp^/main.manifest event_kind=update 'manifest records branch update' + assert_contains $tmp^/main.manifest actor=alice 'manifest records actor' + assert_contains $tmp^/main.manifest workflow_source=worktree:.github/workflows 'manifest records workflow source' + assert_contains $tmp^/main.manifest workflow_trust=untrusted 'manifest records pushed workflow trust' + assert_contains $tmp^/main.manifest capability_grants=status.set,jobs.enqueue,artifacts.put 'manifest records pushed workflow grants' + + if(git -C $src push origin HEAD:refs/ci/manual >/dev/null >[2]/dev/null) + fail push to refs/ci/manual should be rejected + + if(SIP_PRIVILEGED_REF_WRITE=1 git -C $src push origin HEAD:refs/ci/manual-env >/dev/null >[2]/dev/null) + fail client env must not authorize refs/ci/* writes + + if(SIP_PRIVILEGED_REF_WRITE=1 git -C $src push origin HEAD:refs/workflows/client-env >/dev/null >[2]/dev/null) + fail client env must not authorize refs/workflows/* writes + + if(git -C $src push origin HEAD:refs/replace/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa >/dev/null >[2]/dev/null) + fail push to refs/replace/* should be rejected +} + +fn test_no_workflow_repository { + tmp=`{setup_pair} + src=$tmp^/src + remote=$tmp^/remote.git + + printf 'hello\n' >$src^/README.md + git -C $src add README.md + git -C $src commit -m initial >/dev/null || fail could not commit no-workflow repo + git -C $src branch -M main + SIP_ACTOR=bob git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push no-workflow branch + + $root^/bin/sip-worker.rc $remote >/dev/null || fail worker failed for no-workflow repo + assert_ref_exists $remote refs/ci/status/heads/main + + git -C $remote cat-file -p refs/ci/status/heads/main >$tmp^/main.manifest + assert_contains $tmp^/main.manifest workflow_source=none 'manifest records missing workflow source' + assert_contains $tmp^/main.manifest workflow_oid=none 'manifest records missing workflow oid' + assert_contains $tmp^/main.manifest workflow_digest=none 'manifest records missing workflow digest' + assert_contains $tmp^/main.manifest workflow_trust=none 'manifest records missing workflow trust' + assert_contains $tmp^/main.manifest capability_grants=none 'manifest records missing capability grants' +} + +fn test_protected_workflow_policy { + tmp=`{setup_pair} + src=$tmp^/src + remote=$tmp^/remote.git + + mkdir -p $src^/.github/workflows + printf 'name: protected\n' >$src^/.github/workflows/protected.yml + git -C $src add .github/workflows/protected.yml + git -C $src commit -m protected-workflow >/dev/null || fail could not commit protected workflow + protected_commit=`{git -C $src rev-parse HEAD} + protected_digest=`{git -C $src rev-parse HEAD^':.github/workflows'} + git -C $remote fetch $src HEAD:refs/workflows/default >/dev/null >[2]/dev/null || fail could not install protected workflow ref + + rm -rf $src^/.github + printf 'app\n' >$src^/README.md + git -C $src add README.md + git -C $src rm -r .github >/dev/null + git -C $src commit -m app-without-workflow >/dev/null || fail could not commit app branch + git -C $src branch -M main + SIP_ACTOR=carol git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push app branch + + $root^/bin/sip-worker.rc $remote >/dev/null || fail worker failed for protected workflow policy + assert_ref_exists $remote refs/ci/status/heads/main + + git -C $remote cat-file -p refs/ci/status/heads/main >$tmp^/main.manifest + assert_contains $tmp^/main.manifest workflow_source=protected:refs/workflows/default 'protected workflow source wins' + assert_contains $tmp^/main.manifest workflow_oid=$protected_commit 'manifest records protected workflow commit' + assert_contains $tmp^/main.manifest workflow_digest=$protected_digest 'manifest records protected workflow digest' + assert_contains $tmp^/main.manifest workflow_trust=protected 'manifest records protected workflow trust' + assert_contains $tmp^/main.manifest capability_grants=status.set,jobs.enqueue,artifacts.put,http.fetch_allowed,secrets.sign 'manifest records protected workflow grants' +} + +fn test_stale_events_do_not_overwrite_status { + tmp=`{setup_pair} + src=$tmp^/src + remote=$tmp^/remote.git + + printf 'one\n' >$src^/README.md + git -C $src add README.md + git -C $src commit -m one >/dev/null || fail could not commit first revision + git -C $src branch -M main + git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push first revision + first=`{git -C $src rev-parse HEAD} + + printf 'two\n' >>$src^/README.md + git -C $src commit -am two >/dev/null || fail could not commit second revision + git -C $src push origin main >/dev/null >[2]/dev/null || fail could not push second revision + second=`{git -C $src rev-parse HEAD} + + rm -f $remote^/sip/queue/*.event + mkdir -p $remote^/sip/queue + printf '1\t%s\t%s\trefs/heads/main\tactor\t2\tupdate\n' $first $second >$remote^/sip/queue/a-newer.event + printf '1\t0000000000000000000000000000000000000000\t%s\trefs/heads/main\tactor\t1\tcreate\n' $first >$remote^/sip/queue/z-older.event + + $root^/bin/sip-worker.rc $remote >/dev/null || fail worker failed for stale event test + + run_count=`{git -C $remote for-each-ref '--format=%(refname)' refs/ci/runs | awk 'END { print NR }'} + assert_eq $run_count 2 'worker should keep immutable runs for stale and current events' + + git -C $remote cat-file -p refs/ci/status/heads/main >$tmp^/main.manifest + assert_contains $tmp^/main.manifest event_kind=update 'latest status should remain on current update event' + assert_contains $tmp^/main.manifest event_new=$second 'stale event must not overwrite latest status' +} + +fn test_malformed_event_is_failed { + tmp=`{setup_pair} + remote=$tmp^/remote.git + + mkdir -p $remote^/sip/queue + printf '1\tbad\tbad\trefs/heads/main\tactor\t0\tcreate\n' >$remote^/sip/queue/bad.event + printf '2\t0000000000000000000000000000000000000000\t1111111111111111111111111111111111111111\trefs/heads/main\tactor\t1\tcreate\n' >$remote^/sip/queue/bad-version.event + printf '1\t0000000000000000000000000000000000000000\t1111111111111111111111111111111111111111\trefs/notes/main\tactor\t1\tcreate\n' >$remote^/sip/queue/bad-ref.event + printf '1\t0000000000000000000000000000000000000000\t1111111111111111111111111111111111111111\trefs/heads/main\tactor\t1\tupdate\n' >$remote^/sip/queue/bad-kind.event + + $root^/bin/sip-worker.rc $remote >/dev/null >[2]/dev/null + if(! test -f $remote^/sip/failed/bad.event) + fail malformed event should move to failed queue + if(! test -f $remote^/sip/failed/bad-version.event) + fail unsupported event version should move to failed queue + if(! test -f $remote^/sip/failed/bad-ref.event) + fail unknown event ref namespace should move to failed queue + if(! test -f $remote^/sip/failed/bad-kind.event) + fail inconsistent event kind should move to failed queue +} + +test_branch_tag_and_protected_refs +test_no_workflow_repository +test_protected_workflow_policy +test_stale_events_do_not_overwrite_status +test_malformed_event_is_failed + +echo ok - integration tests passed