Skip to main content

gunnar_core/
lib.rs

1//! **gunnar's repository model, identity table, ACL and path sandbox.** The
2//! layer `gunnar-ssh`, `gunnar-grpc` and `gunnar-cli` all sit on (the ACL model, multi-tenancy).
3//!
4//! # What this crate answers
5//!
6//! An SSH connection arrives with a public key and a command line. Four
7//! questions have to be answered before a single byte of git protocol is
8//! spoken, and this crate answers all four:
9//!
10//! | question | answer |
11//! |---|---|
12//! | *who is this?* | [`AuthTable::principal_by_fingerprint`], on the key's `SHA256:` fingerprint, looked up in gunnar's own table |
13//! | *what did they ask for?* | [`resolve_repo_path`]: **the sandbox**, and the security core of the server |
14//! | *does it exist?* | [`RepoCatalog::open`] |
15//! | *may they?* | [`AuthTable::may`]. `upload-pack` needs [`Level::Read`], `receive-pack` needs [`Level::Write`] |
16//!
17//! ```
18//! use gunnar_core::{resolve_repo_path, Level};
19//!
20//! // Already unquoted: git wraps this in single quotes on the wire and
21//! // `gunnar_ssh::command` takes them off. The sandbox does not know SSH
22//! // exists (the one-sandbox rule).
23//! let path = resolve_repo_path("team/sub/project.git").expect("a legal path");
24//! assert_eq!(path.namespace(), "team/sub");
25//! assert_eq!(path.name(), "project");
26//! assert_eq!(path.key(), "team/sub/project");
27//!
28//! // The escape does not resolve, and never becomes a filesystem path at all.
29//! assert!(resolve_repo_path("ns/../../etc/passwd").is_err());
30//! assert!(Level::Admin.permits(Level::Read));
31//! assert!(!Level::Read.permits(Level::Write));
32//! ```
33//!
34//! # One sandbox, one fingerprint
35//!
36//! `gunnar-ssh` was written concurrently with this crate and grew **its own**
37//! path sandbox, key fingerprint, host-key loader and auth lookup. The two
38//! sandboxes disagreed: one enforced exactly two path components, this one
39//! permits nested namespaces. Two security checks that disagree are worse than
40//! either alone. `gunnar-ssh` now depends on this crate and its copies are
41//! deleted. What survives lives here:
42//!
43//! | the one implementation | here |
44//! |---|---|
45//! | path sandbox → `(namespace, repo)` | [`sandbox::resolve_repo_path`] |
46//! | key fingerprint | [`auth::fingerprint_of_blob`] → [`Fingerprint`] |
47//! | host key | [`host_key::ensure_host_key`] |
48//! | principal + grant lookup | [`AuthTable`] |
49//!
50//! The fingerprint is defined over **bytes**, not over an `ssh-key` type,
51//! because russh pins `ssh-key =0.7.0-rc.10` and this crate links the
52//! workspace's `0.6`; see [`auth::fingerprint_of_blob`].
53//!
54//! # No OS underneath
55//!
56//! There are **no OS users**. No `/etc/passwd`, no home directories, no
57//! `~/.ssh/authorized_keys`, no `ssh-keygen` subprocess. gunnar owns its own
58//! identity table, mints its own host key in-process, and works when this
59//! binary is PID 1 in a distroless image (the no-OS server rule). The one function that
60//! touches a filesystem, [`RepoRef::path_under`], exists for the `fs` backend
61//! and is where the symlink check lives; nothing else in this crate knows what
62//! a path is.
63//!
64//! # Where the state lives
65//!
66//! In the control store ([`control`]) and, for the host key, on the
67//! [`gunnar_store`] seam — and on nothing else. The catalog and the identity
68//! table are JSON documents behind a name in a redb database, updated by a real
69//! compare-and-swap; the host key is a [`SecretStore`](gunnar_store::SecretStore)
70//! entry. Until 2026-08-09 the documents were git blobs behind git references,
71//! spelled in gunnar's own `ObjectStore`/`RefStore` traits; both are gone (the
72//! storage contract is `GitOps` now, and it takes objects as packs). See
73//! [`control`] for why they moved and [`doc`] for what the shape costs.
74//!
75//! # Verification
76//!
77//! Measure, never assert, concretely, in `tests/`:
78//!
79//! * the sandbox's escape table is asserted to go **red** against a
80//!   deliberately broken resolver, and its legitimate-path table is asserted to
81//!   still resolve, because a sandbox that rejects everything is the
82//!   identity-value trap;
83//! * fingerprints are pinned against known answers from the **real OpenSSH
84//!   binary**, not against gunnar recomputing its own arithmetic;
85//! * the ACL is asserted in both directions: a `Read` principal refused a
86//!   write, an `Admin` allowed one;
87//! * the host key is asserted byte-identical across a simulated restart, *and*
88//!   two fresh mints are asserted to differ, so the first assertion cannot pass
89//!   on a constant;
90//! * both sha1 and sha256 repositories are exercised.
91
92#![warn(missing_docs)]
93#![warn(clippy::all)]
94#![doc(
95    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
96    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
97)]
98
99/// The account admin's audit log: per account, hash-chained, a year (2026-10-05).
100pub mod account_audit;
101/// When a principal last signed in and last changed something (2026-10-21).
102pub mod activity;
103pub mod arrow_log;
104pub mod audit;
105pub mod auth;
106pub mod catalog;
107pub mod control;
108pub mod doc;
109pub mod encryption_key;
110#[cfg(feature = "metered")]
111pub mod entitlement;
112pub mod error;
113/// **How close a certificate is to bricking the box it protects.** An
114/// appliance has no shell and its only door is the mutual listener, so this is
115/// graded and REFUSED early rather than reported.
116pub mod expiry;
117/// **The flow ledger**: what MOVED through a namespace, per UTC day — bytes in
118/// and out, pushes, fetches, refusals, and when the tenant was last seen. Every
119/// other meter gunnar keeps is a STOCK meter and reads the same for a busy
120/// tenant and a silent one.
121pub mod flow;
122/// The forge's evidence spine: signed, append-only issue and review events.
123pub mod forge;
124pub mod history;
125pub mod host_key;
126pub mod issuer;
127/// Where a repository's pull-mirror registration lives (`refs/gunnar/mirrors`).
128/// Group membership as signed, append-only evidence — the signing half of
129/// punkt 9, beside [`auth`]'s live table.
130pub mod key_attestation;
131pub mod membership;
132pub mod mirror_store;
133pub mod model;
134/// The name filter: offensive, abusive and reserved names (2026-10-05).
135pub mod name_filter;
136pub mod observe;
137/// Where a repository's policy document lives (`refs/gunnar/policy`).
138pub mod policy_store;
139/// Build provenance, signed — links 5 and 6 of the chain.
140pub mod provenance;
141pub mod pulls;
142pub mod sandbox;
143/// A CycloneDX SBOM out of a locked `Cargo.lock`.
144pub mod sbom;
145pub mod seal;
146pub mod secrets;
147pub mod settings;
148/// One signer and one verifier for every signed event log (one implementation).
149pub mod sshsig;
150/// Company single sign-on: the identity providers, the directories, SCIM.
151pub mod sso;
152/// The transparency log's arithmetic: a Merkle tree and a signed tree head.
153pub mod transparency;
154pub mod txlog;
155
156/// Outbound webhook delivery signatures: the sshsig over each delivered body.
157pub mod webhook;
158
159/// **The account gate**: the row that says a user may do nothing, and the lease
160/// that keeps a dead purge from bricking an account.
161pub mod gate;
162
163pub use gate::{AccountGate, GateReason, ADMINISTRATIVE_HOLDER, PURGE_LEASE_MS};
164
165pub use activity::{PrincipalActivity, Seen as ActivitySeen, ACTIVITY_FILE};
166pub use audit::{AuditChain, AuditEntry, AuditError, AuditEvent, Recorded, AUDIT_FILE};
167
168pub use auth::operator_access::{
169    OperatorAccess, OperatorAccessState, Shield, OPERATOR_ACCESS_DEFAULT_MINUTES,
170    OPERATOR_ACCESS_MAX_MINUTES, OPERATOR_REQUEST_TTL_MS,
171};
172/// Clans, round 2 (2026-10-05): a repository offered out of a clan.
173pub use auth::ClanRepoOffer;
174pub use auth::{
175    fingerprint_of_blob, mint_token_secret, token_hash, AuthTable, Bootstrap, BootstrapRecord,
176    BulkUser, EmployeeRoom, Enrolled, Fingerprint, Readiness, RedeemedInvite, RoomAnswer, RoomAsk,
177    RoomGrant, SeatLot, SsoImportOp, SsoImportRow, SsoLogin, SsoLoginDone, SsoProvision,
178    SsoUserPatch, AUTH_REF, BOOTSTRAP_ADMIN_ID, LAST_USED_GRANULARITY, REDEEMED_INVITE_KEEP_MS,
179    SHA256_PREFIX,
180};
181/// Clans (2026-10-05): ordinary accounts grouped, each giving part of its room.
182pub use auth::{AccountRoom, Clan, ClanInvite, ClanMember, ClanRefusal, ClanRole};
183pub use catalog::{point_head_at, NewRepo, RepoCatalog, CATALOG_REF};
184pub use control::{
185    memory_control, memory_control_store, open_control, open_control_store, open_control_with_db,
186    BlobId, ControlDb, ControlStore, ControlView, DocVersion, RamCopy, RedbControlStore,
187    ResumeReport, SharedControlStore, BLOB_ID_LEN, RAM_COPY_CEILING_BYTES, TWIN_APPLIED,
188};
189#[cfg(feature = "metered")]
190pub use entitlement::{Entitlement, EntitlementState, EntitlementStore, ENTITLEMENT_REF};
191pub use error::{human_bytes, Error, Result};
192pub use flow::{
193    day_of, iso8601_utc, midnight_of, Fleet, FlowDay, FlowLedger, FlowObserver, FlowStore, Glance,
194    NamespaceFlow, Refusal, Transfer, DAY_MS, FLOW_REF, PRINCIPAL_CEILING, RETENTION_DAYS,
195};
196pub use forge::{Attested, Event, ForgeEvent, Review, SignedEvent, FORGE_NAMESPACE};
197pub use history::{read_sealed, History, RefTransaction, SealReport};
198pub use host_key::{ensure_host_key, mint_ed25519, os_rng, HostKey};
199pub use issuer::{
200    Issued, Issuer, IssuerOrigin, Pki, TrustedCa, DEFAULT_ISSUER_DAYS, DEFAULT_LEAF_DAYS,
201    ISSUER_REF,
202};
203pub use key_attestation::{
204    AttestationProblem, AttestingKey, KeyAttestation, SignedKeyAttestation,
205    KEY_ATTESTATION_NAMESPACE,
206};
207pub use membership::{
208    fold as fold_membership, Membership, MembershipAction, MembershipEvent, SignedMembershipEvent,
209    MEMBERSHIP_NAMESPACE,
210};
211pub use mirror_store::{
212    credential_secret_key, MirrorCredentialKind, MirrorRecord, MirrorStore, MAX_RESUMES, MIRROR_REF,
213};
214pub use model::{
215    now_ms, AccessToken, FirstInviteRow, Grant, HashKind, Level, Namespace, Principal,
216    PrincipalKind, PublicKey, Repo, Scope, UnixMillis, Visibility, WebCredential,
217    ANONYMOUS_PRINCIPAL_ID, DEFAULT_BRANCH,
218};
219pub use observe::{
220    InFlightGuard, MetricsSnapshot, RequestId, ServerMetrics, TARGETS, TARGET_AUDIT, TARGET_GIT,
221    TARGET_METRICS, TARGET_POLICY, TARGET_SSH, TARGET_STORE,
222};
223pub use policy_store::{PolicyStore, POLICY_REF};
224pub use provenance::{BuildRecord, SignedBuild, Subject, PROVENANCE_NAMESPACE};
225pub use pulls::{PullRecord, PullState, Pulls, Transition as PullTransition, PULLS_REF};
226pub use sandbox::{resolve_repo_path, PathReject, RepoRef};
227pub use seal::{
228    is_sealed, seal, unseal, Kek, PlaintextPolicy, SealedSecretStore, KEK_ENV, KEK_FILE, SEAL_MAGIC,
229};
230pub use secrets::{legacy_secret_log, MigratingSecretStore, RedbSecretStore, LEGACY_SECRET_DIRS};
231pub use sshsig::EventProblem;
232pub use transparency::{SignedTreeHead, TreeHead, STH_NAMESPACE};
233pub use webhook::{
234    sign_delivery, verify_delivery, DeliveryProblem, SignedDelivery, WEBHOOK_NAMESPACE,
235};
236
237// Re-exported so downstream crates can name these types without pinning the
238// exact versions gunnar resolved.
239pub use gunnar_store;
240pub use ssh_key;