Skip to content
Open
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: 15 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor
| `checkpoints/<seq>/checkpoint.pb`, `refs.pb` | Folded state at `seq`: live pack set + full `RefSnapshot`. Cold start = snapshot + tail, never full replay. |
| `leases/<name>.pb` | CAS lease with TTL heartbeat: `compact`. The only cross-instance mutex. |
| `cache/api/v1/<sha1>.json` | Shared render cache of immutable web API answers. |
| `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all. |
| `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all, except for a host `[policy] baseline`, which applies to every repository as well. |
| `fsck.pb` | Last connectivity audit (`FsckReport`), written by the maintainer's `fsck` unit, consumed by `repair` (`docs/INTEGRITY.md`). |
| `events/cursor.json` | Durable acknowledged WAL sequence of the events bridge; advanced only after the webhook acknowledged (D32). |
| `lfs/objects/<aa>/<bb>/<oid>` | LFS objects (sha256-addressed, immutable). Missing ones can be read through from `upstream.lfs` and persisted (`docs/LFS.md`). |
Expand Down Expand Up @@ -292,7 +292,8 @@ Unrelated constraints remain in force. The current design target and migration g
tasks, policy, settings) so the repository prefix determines placement. The browser lane is
`/{o}/{r}/api-browser/…`; `/api/v1` is non-repository discovery, identity and owner listing. No aliases.
- **D16** Push authorization is a per-repo **rule language** at `repos/<o>/<r>/policy.json` (`docs/POLICY.md`).
Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref.
Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref
(unless the host has a baseline: *host baseline push policy*, below).
- **D17 (historical; superseded by D42)** `bundles.require` refuses only **unbounded** zero-have fetches (no `deepen*`, no `filter`): that is a
full clone and belongs to bundle-uri. Bounded zero-have fetches (CI's `--depth`/`--filter`) go to upload-pack.
git never retries a failed bundle download and then falls back to a zero-have fetch; a principal that fetched
Expand Down Expand Up @@ -487,6 +488,18 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`.
and candidate external-boundary proof remain separate obligations; local loose objects and retired
download membership cannot justify retirement. See the cost and remaining-evidence rows in the linked docs.

- **D5x — host baseline push policy (2026-09-30; number assigned at merge).** `[policy] baseline =
"<path>"` names one more document in the D16 language, read at startup with the `policy.json` parser and
validator (unreadable or invalid = no start, `walgit config check` fails) and held in memory (0 store
requests). **Both apply:** a push is judged against the baseline, then the repository's own `policy.json`,
and must pass both — the `protect` AND carried across documents, so order only picks the named rule
(`rejected by baseline rule '<name>'` first). Each document resolves `group:` against its own roster. Not
"own replaces baseline": the language has no admitting effect, so AND lets a repository add restrictions and
never lift one, and no per-repo write (nor `PUT` of an empty document) can drop the host's protection;
exceptions are the operator's. Host-only like auth: not a settings section, same file on every host that
runs receive-pack. `GET …/policy` stays the repository's document; `GET …/policy/effective` lists the layers;
dry-run judges after the baseline. The overlapping-bypass check stays per document (`docs/POLICY.md`).

## 5. Working rules

- **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Read the [design](docs/PACKFILE_URI_DESIGN.md) and
| **git** | smart HTTP v0/v2: `ls-refs` with prefixes, fetch with filter/shallow/deepen/sideband-all, receive-pack (atomic, deletes, tags, push options, report-status-v2), `<owner>/<repo>` namespaces, sha1 and sha256 repositories. Upstream `git` does upload-pack/repack; walgit does receive-pack, the WAL and the plumbing. |
| **LFS** | Batch API + basic transfer, objects in the bucket, optional read-through from an upstream LFS server for imported repositories. |
| **web UI + API** | A React UI (tree, blob, commits, diffs, the WAL's own health page) on a read-mostly JSON API under `/{owner}/{repo}/api/*`; sha-addressed answers are immutable and cached everywhere; long answers stream progress as SSE. `repos.js` is a dependency-free SDK for pages, agents and scripts. |
| **policy** | Per-repository push rules (`policy.json`): protected refs, groups, fast-forward only, bypass lists. `docs/POLICY.md`. |
| **policy** | Per-repository push rules (`policy.json`): protected refs, groups, fast-forward only, bypass lists; optionally a host baseline every repository is judged against too. `docs/POLICY.md`. |
| **settings** | Per-repository config (maintenance, compaction, upstream follow) published into the WAL with history. |
| **events** | A small bridge tails the WAL and POSTs ref events to a webhook, exactly-once per (repo, seq, ref) with a durable cursor. `docs/EVENTS.md`. |
| **maintenance** | Checkpoints, geometric compaction, connectivity audits and repairs — one loop that computes the desired state from (config, WAL) every pass and does one bounded unit of the most important missing work. Manual `compact --base` rebuilds the base on a host with sufficient disk. |
Expand Down
2 changes: 2 additions & 0 deletions crates/walgit-cli/src/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub async fn run(action: ConfigAction, cfg: &Arc<Config>) -> Result<()> {
}
let ignored = cfg.apply_env_report(vars.into_iter())?;
cfg.validate()?;
// Startup parses the baseline too; `check` must fail where startup would.
walgit_server::policy::load_baseline(&cfg)?;
for (k, why) in &ignored {
eprintln!("ignored {k}: {why}");
}
Expand Down
46 changes: 46 additions & 0 deletions crates/walgit-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub struct Config {
pub lfs: LfsConfig,
pub git: GitConfig,
pub upstream: UpstreamConfig,
pub policy: PolicyConfig,
/// Links to the systems around a repository (per repo via settings).
#[serde(default)]
pub telemetry: TelemetryConfig,
Expand Down Expand Up @@ -341,6 +342,21 @@ pub struct CacheConfig {
pub store_mount: Option<PathBuf>,
}

/// Host-side push policy (`docs/POLICY.md`). Host-only: not a settings section, so a
/// repository cannot name, replace or drop it.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct PolicyConfig {
/// A `policy.json` document every repository is evaluated against **in addition to**
/// its own `repos/<o>/<r>/policy.json` (baseline first, then the repository's; a
/// repository can add restrictions, never lift one). Read and parsed at startup by
/// the same parser and validator as `policy.json`; unreadable or invalid = the
/// process does not start. Held in memory: no store request. Every host that runs
/// receive-pack (fronts with a local fallback, the push broker) must carry the same
/// file, like `server.auth.tokens`. Unset = no baseline.
pub baseline: Option<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[expect(
Expand Down Expand Up @@ -1088,6 +1104,12 @@ impl Config {
"packfile_uri.max_uris_per_fetch must be 1..=64"
);
anyhow::ensure!(!self.store.bucket.is_empty(), "store.bucket must be set");
if let Some(p) = &self.policy.baseline {
anyhow::ensure!(
!p.as_os_str().is_empty(),
"policy.baseline is empty (omit the key for no baseline)"
);
}
let t = &self.server.tls;
match t.mode {
TlsMode::Files => anyhow::ensure!(
Expand Down Expand Up @@ -1549,6 +1571,30 @@ mod tests {
);
}

#[test]
fn policy_baseline_is_a_host_only_path() {
let c =
Config::parse("[policy]\nbaseline = \"/etc/walgit/policy-baseline.json\"\n").unwrap();
assert_eq!(
c.policy.baseline.as_deref(),
Some(std::path::Path::new("/etc/walgit/policy-baseline.json"))
);
assert!(Config::parse("").unwrap().policy.baseline.is_none());
// Parsing the document is the server's job (same parser as policy.json); the
// config only refuses what cannot name a file and typos in the section.
let e = Config::parse("[policy]\nbaseline = \"\"\n")
.unwrap_err()
.to_string();
assert!(e.contains("policy.baseline"), "{e}");
assert!(Config::parse("[policy]\ndefault = \"/x.json\"\n").is_err());
// A repository cannot set, replace or clear the host's baseline.
let e = Config::default()
.with_settings("[policy]\nbaseline = \"/tmp/allow-all.json\"\n")
.unwrap_err()
.to_string();
assert!(e.contains("[policy]"), "{e}");
}

#[test]
fn settings_merge_over_config_and_are_restricted() {
let mut base = Config::default();
Expand Down
15 changes: 15 additions & 0 deletions crates/walgit-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ pub struct AppState {
pub follow: follow::FollowStatuses,
/// In-process TLS (standalone, D39); `None` behind an edge (h2c).
pub tls: Option<Arc<tls::Tls>>,
/// `[policy] baseline`, parsed at startup: judged before every repository's own
/// `policy.json` (`docs/POLICY.md`). In memory, so it costs a push no store request.
pub policy_baseline: Option<policy::RepoPolicy>,
}

impl AppState {
Expand All @@ -114,6 +117,14 @@ impl AppState {
let bridge = bridge::Bridge::new(&cfg, registry.clone());
let metrics_handle = metrics::install()?;
let tls = tls::load(&cfg)?;
let policy_baseline = policy::load_baseline(&cfg)?;
if let Some(p) = &policy_baseline {
tracing::info!(
rules = p.rules.len(),
groups = p.groups.len(),
"push policy baseline loaded"
);
}
if let Some(t) = &tls {
tracing::info!(fingerprint = %t.fingerprint, mode = ?cfg.server.tls.mode, "TLS terminated in-process");
}
Expand All @@ -131,6 +142,7 @@ impl AppState {
bridge,
follow: follow::FollowStatuses::default(),
tls,
policy_baseline,
}))
}
}
Expand Down Expand Up @@ -399,6 +411,9 @@ pub(crate) async fn dispatch_route(
(&Method::DELETE, "") => admin::delete(st, route, &headers).await,
// Admin routes reach here only through `/{o}/{r}/api[-browser]/…` (web::v1).
(&Method::GET, "policy") => policy::http_get(st, route, &headers).await,
(&Method::GET, "policy/effective") => {
policy::http_get_effective(st, route, &headers).await
}
(&Method::PUT, "policy") => {
policy::http_put(st, route, &headers, body.take().unwrap()).await
}
Expand Down
Loading