1use anyhow::{bail, Context, Result};
10use gunnar_core::observe::TARGET_STORE;
11use gunnar_core::{Fingerprint, Grant, NewRepo, Repo, RepoRef, Scope, Visibility};
12
13use crate::storage::MaintenanceReport;
14use crate::stores::ServerStores;
15use gunnar_common::cli::{
16 GcArgs, GrantArgs, KeyCommand, NamespaceCommand, PrincipalCommand, ReflogArgs, RepoCommand,
17 RevokeArgs,
18};
19
20fn repo_ref(path: &str) -> Result<RepoRef> {
25 gunnar_core::resolve_repo_path(path)
26 .with_context(|| format!("`{path}` is not a valid <namespace>/<name>"))
27}
28
29fn read_certificate(path: &std::path::Path) -> Result<Vec<u8>> {
34 let raw = std::fs::read(path)
35 .with_context(|| format!("reading the certificate at {}", path.display()))?;
36 Ok(gunnar_core::auth::certificate_der(&raw)?)
37}
38
39fn key_argument(spec: &str) -> Result<String> {
41 match spec.strip_prefix('@') {
42 Some(path) => std::fs::read_to_string(path)
43 .with_context(|| format!("reading the public key from {path}"))
44 .map(|text| text.trim().to_owned()),
45 None => Ok(spec.trim().to_owned()),
46 }
47}
48
49pub fn issuer_document(pki: &gunnar_core::Pki) -> Result<gunnar_proto::IssuerInfoResponse> {
65 let trusted = pki
66 .trusted_cas()
67 .context("reading the trusted-CA list")?
68 .into_iter()
69 .map(|ca| gunnar_proto::TrustedCaInfo {
70 label: ca.label,
71 fingerprint: ca.fingerprint.to_string(),
72 cert_pem: ca.cert_pem,
73 added_unix_ms: ca.added,
74 is_issuer: ca.is_issuer,
75 })
76 .collect();
77 Ok(match pki.issuer().context("reading the issuer")? {
78 Some(live) => gunnar_proto::IssuerInfoResponse {
79 has_issuer: true,
80 subject: live.subject,
81 fingerprint: live.fingerprint.to_string(),
82 cert_pem: live.cert_pem,
83 created_unix_ms: live.created,
84 origin: live.origin.to_string(),
85 trusted,
86 },
87 None => gunnar_proto::IssuerInfoResponse {
92 has_issuer: false,
93 trusted,
94 ..Default::default()
95 },
96 })
97}
98
99pub fn issuer(stores: &ServerStores, command: gunnar_common::cli::IssuerCommand) -> Result<()> {
101 use gunnar_common::cli::IssuerCommand;
102
103 match command {
104 IssuerCommand::Create { subject, days } => {
105 let created = stores.pki()?.create(&subject, days)?;
109 println!(
110 "issuer created: {} ({})",
111 created.subject, created.fingerprint
112 );
113 eprintln!(
114 "its private key is sealed in the secret store and is not in the control \
115 database. There is no way to print it, and that is deliberate: a key that can \
116 mint any user is the one whose loss means an attacker issues themselves an \
117 administrator"
118 );
119 }
120 IssuerCommand::Import { cert, key } => {
121 let cert_pem = std::fs::read_to_string(&cert)
122 .with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
123 let key_pem = std::fs::read_to_string(&key)
124 .with_context(|| format!("reading the CA private key at {}", key.display()))?;
125 let imported = stores.pki()?.import(&cert_pem, &key_pem)?;
126 println!(
127 "issuer imported: {} ({})",
128 imported.subject, imported.fingerprint
129 );
130 }
131 IssuerCommand::Show { pem, at, identity: _ } => {
132 if let Some(at) = at {
139 bail!(
140 "`--at {at}` asks a running server, and this binary carries no control client (built without the `control` feature). Without it, `gunnar issuer show` can only read the data root it is standing in"
141 );
142 }
143 print!("{}", issuer_document(&stores.pki_readonly()?)?.render(pem));
151 }
152 IssuerCommand::Trust { label, cert } => {
153 let cert_pem = std::fs::read_to_string(&cert)
154 .with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
155 let label = if label.is_empty() {
156 cert.file_name()
157 .map(|name| name.to_string_lossy().into_owned())
158 .unwrap_or_else(|| "ca".to_owned())
159 } else {
160 label
161 };
162 let added = stores.pki()?.trust_ca(&label, &cert_pem)?;
163 println!("trusting {} ({})", added.label, added.fingerprint);
164 }
165 IssuerCommand::Readiness => {
169 print!("{}", crate::tls_preflight::preflight(stores)?);
170 println!();
171 }
172 IssuerCommand::Untrust { fingerprint } => {
173 let parsed = Fingerprint::parse(&fingerprint)?;
174 match stores.pki()?.untrust_ca(&parsed)? {
175 Some(removed) => println!("no longer trusting {} ({parsed})", removed.label),
176 None => bail!("no trusted CA has fingerprint {parsed}"),
177 }
178 }
179 }
180 Ok(())
181}
182
183fn hand_over(
195 stores: &ServerStores,
196 dir: &std::path::Path,
197 issued: &gunnar_core::Issued,
198) -> Result<()> {
199 std::fs::create_dir_all(dir)
200 .with_context(|| format!("creating the output directory {}", dir.display()))?;
201
202 let cert_path = dir.join(format!("{}_cert.pem", issued.principal_id));
203 std::fs::write(&cert_path, &issued.cert_pem)
204 .with_context(|| format!("writing {}", cert_path.display()))?;
205
206 let registered =
211 stores
212 .auth()
213 .register_certificate(&issued.principal_id, &issued.cert_der, None)?;
214
215 println!(
216 "issued {} -> {}",
217 registered.fingerprint_sha256,
218 cert_path.display()
219 );
220
221 if let Some(key_pem) = &issued.key_pem {
222 let key_path = dir.join(format!("{}_pkey.pem", issued.principal_id));
223 write_private(&key_path, key_pem)?;
224 println!("private key -> {}", key_path.display());
225 eprintln!(
226 "this key was generated HERE and is now on the wire and on this disk. \
227 `--csr` is the form where it never leaves {}'s machine",
228 issued.principal_id
229 );
230 }
231 Ok(())
232}
233
234fn write_private(path: &std::path::Path, contents: &str) -> Result<()> {
237 use std::io::Write as _;
238
239 let mut options = std::fs::OpenOptions::new();
240 options.write(true).create(true).truncate(true);
241 #[cfg(unix)]
242 {
243 use std::os::unix::fs::OpenOptionsExt as _;
244 options.mode(0o600);
245 }
246 if path.exists() {
249 std::fs::remove_file(path)
250 .with_context(|| format!("removing the previous {}", path.display()))?;
251 }
252 let mut file = options
253 .open(path)
254 .with_context(|| format!("creating {}", path.display()))?;
255 file.write_all(contents.as_bytes())
256 .with_context(|| format!("writing {}", path.display()))?;
257 Ok(())
258}
259
260pub fn namespace(stores: &ServerStores, command: NamespaceCommand) -> Result<()> {
264 match command {
265 NamespaceCommand::Create { name } => {
266 let created = stores.catalog().create_namespace(&name)?;
267 println!("created namespace {}", created.name);
268 }
269 NamespaceCommand::List => {
270 let mut names: Vec<String> = stores
271 .catalog()
272 .namespaces()?
273 .into_iter()
274 .map(|ns| ns.name)
275 .collect();
276 names.sort();
277 for name in names {
278 println!("{name}");
279 }
280 }
281 NamespaceCommand::Delete { name, delete_repos } => {
282 let gone = stores.catalog().delete_namespace(&name, delete_repos)?;
283 println!("deleted namespace {name} and {} repositories", gone.len());
284 }
285 NamespaceCommand::SetBudget {
286 name,
287 explode_budget,
288 } => {
289 let Some(spec) = explode_budget else {
290 anyhow::bail!(
291 "`namespace set-budget` needs a budget to set; today that is \
292 `--explode-budget <8G|512M|unlimited|default>`"
293 );
294 };
295 let bytes = match spec.trim().to_ascii_lowercase().as_str() {
296 "default" | "" => None,
297 #[cfg(feature = "engine-znippy")]
298 other => Some(
299 crate::storage::znippy_engine::parse_explode_budget(other)
300 .context("reading the explode budget")?,
301 ),
302 #[cfg(not(feature = "engine-znippy"))]
303 other => anyhow::bail!("an explode budget ({other}) is the znippy engine's; this server was built without it"),
304 };
305 let ns = stores
306 .catalog()
307 .set_namespace_explode_budget(&name, bytes)?;
308 for repo in stores.catalog().list(Some(&name))? {
312 if let Ok(dir) = stores.repo_dir(&repo.path()) {
313 stores.apply_explode_quota(&name, &dir);
314 }
315 }
316 match ns.explode_budget_bytes {
317 Some(0) => println!("{name}: explode budget unlimited"),
318 #[cfg(feature = "engine-znippy")]
319 Some(b) => println!(
320 "{name}: explode budget {}",
321 crate::storage::znippy_engine::human_bytes(b)
322 ),
323 #[cfg(not(feature = "engine-znippy"))]
324 Some(b) => println!("{name}: explode budget {b} bytes"),
325 None => println!("{name}: explode budget back to the server default"),
326 }
327 }
328 }
329 Ok(())
330}
331
332pub fn repo(stores: &ServerStores, command: RepoCommand) -> Result<()> {
336 match command {
337 RepoCommand::Create {
338 path,
339 hash,
340 default_branch,
341 public,
342 description,
343 adopt,
344 } => {
345 let path = repo_ref(&path)?;
346 let mut spec = NewRepo::new(path.clone())
347 .hash_kind(hash.into())
348 .visibility(if public {
349 Visibility::Public
350 } else {
351 Visibility::Private
352 });
353 spec.description = description;
354 if let Some(branch) = default_branch {
355 spec = spec.default_branch(full_branch(&branch));
356 }
357 let created = stores.create_repo(spec, adopt)?;
363 let store = stores.repo_store(&created)?;
364 println!(
365 "created {} ({}, default branch {}) at {}",
366 created.key(),
367 created.hash_kind,
368 created.default_branch,
369 store.git_dir().display()
370 );
371 }
372 RepoCommand::List { namespace } => {
373 let mut repos = stores.catalog().list(namespace.as_deref())?;
374 repos.sort_by_key(|repo| repo.key());
375 for repo in repos {
376 println!(
377 "{}\t{}\t{}\t{:?}",
378 repo.key(),
379 repo.hash_kind,
380 repo.default_branch,
381 repo.visibility
382 );
383 }
384 }
385 RepoCommand::Delete {
386 path,
387 purge,
388 retention_days,
389 } => {
390 let path = repo_ref(&path)?;
391 let orphan = stores.is_orphan(&path)?;
398 anyhow::ensure!(
399 purge || !orphan,
400 "{}",
401 crate::stores::ServerStores::orphan_soft_delete_refusal(&path)
402 );
403 let removed = match stores.catalog().delete(&path) {
407 Ok(repo) => Some(repo),
408 Err(gunnar_core::Error::NoSuchRepo(_)) if orphan => None,
409 Err(err) => return Err(err.into()),
410 };
411 stores.close_repo(&path);
414 if purge {
415 let dir = stores.repo_dir(&path)?;
416 let (before, after) = stores.purge_repo(&path)?;
417 println!(
418 "deleted {}{} and purged {} ({} bytes reclaimed)",
419 path,
420 if removed.is_none() { " (an ORPHAN: it had no catalog row)" } else { "" },
421 dir.display(),
422 before.saturating_sub(after)
423 );
424 } else {
425 let removed = removed.expect("a soft delete of an orphan is refused above");
426 let days = retention_days.unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS);
427 let record = stores.soft_delete(
428 &path,
429 Some(&removed),
430 "",
431 std::time::Duration::from_secs(u64::from(days) * 86_400),
432 )?;
433 let bytes = crate::stores::dir_bytes(&stores.repo_dir(&path)?);
434 println!(
435 "deleted {} — its {bytes} bytes are KEPT for {days} day(s)",
436 removed.key()
437 );
438 println!(
439 " undelete: gunnar repo undelete {} purge now: gunnar repo purge {}",
440 removed.key(),
441 removed.key()
442 );
443 if let Some(at) = record.purge_after_unix_ms {
444 println!(" the sweep may reclaim them after unix_ms {at}");
445 }
446 println!(" until then they still count against the account's storage.");
447 }
448 }
449 RepoCommand::Undelete { path } => {
450 let path = repo_ref(&path)?;
451 let spec = stores.undelete_spec(&path)?;
452 let repo = stores.create_repo(spec, true)?;
456 stores.clear_tombstone(&path)?;
457 println!(
458 "undeleted {} ({}, default branch {})",
459 repo.key(),
460 repo.hash_kind,
461 repo.default_branch
462 );
463 }
464 RepoCommand::Purge { path } => {
465 let path = repo_ref(&path)?;
466 anyhow::ensure!(
467 stores.catalog().get(&path)?.is_none(),
468 "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
469 deletes and reclaims it in one step; purge alone only ends a retention window."
470 );
471 anyhow::ensure!(
472 stores.tombstone_of(&path)?.is_some(),
473 "{path} is neither catalogued nor tombstoned; there is nothing to purge"
474 );
475 let (before, after) = stores.purge_repo(&path)?;
476 println!(
477 "purged {path} ({} bytes reclaimed); it can no longer be undeleted",
478 before.saturating_sub(after)
479 );
480 }
481 RepoCommand::ListDeleted {} => {
482 let rows = stores.tombstones()?;
483 if rows.is_empty() {
484 println!("nothing is deleted");
485 }
486 let now = gunnar_core::now_ms();
487 let mut total = 0u64;
488 for row in &rows {
489 total += row.bytes;
490 let left = match row.tombstone.seconds_left(now) {
491 Some(secs) => format!("{} day(s) left", secs / 86_400),
492 None => "no expiry".to_owned(),
493 };
494 println!("{}\t{} bytes\t{left}", row.path, row.bytes);
495 }
496 if !rows.is_empty() {
497 println!(
498 "{} deleted repository(ies) holding {total} bytes — still on the account",
499 rows.len()
500 );
501 }
502 }
503 RepoCommand::ListOrphans { namespace } => {
504 let rows = stores.orphans()?;
505 let rows: Vec<_> = rows
506 .into_iter()
507 .filter(|row| {
508 namespace
509 .as_deref()
510 .is_none_or(|want| row.path.namespace() == want)
511 })
512 .collect();
513 if rows.is_empty() {
514 println!("no orphan stores: every directory under the repository root is either \
515 catalogued (`repo list`) or soft-deleted (`repo list-deleted`)");
516 }
517 let mut total = 0u64;
518 for row in &rows {
519 total += row.bytes;
520 println!(
521 "{}\t{} bytes\t{}\tmtime {}\t{}{}",
522 row.path,
523 row.bytes,
524 row.hash_marker.as_deref().unwrap_or("-"),
525 row.modified_unix_ms,
526 row.dir.display(),
527 match &row.failed_reclaim {
528 Some(note) => format!("\tRECLAIM FAILED: {note}"),
529 None => String::new(),
530 }
531 );
532 }
533 if !rows.is_empty() {
534 println!(
535 "{} orphan store(s) holding {total} bytes — no catalog row, no tombstone",
536 rows.len()
537 );
538 println!(
539 " reclaim the bytes: gunnar repo reclaim <path> keep them: gunnar repo \
540 create <path> --adopt --hash <the HASH column>"
541 );
542 }
543 }
544 RepoCommand::Reclaim { path } => {
545 let path = repo_ref(&path)?;
546 let bytes = stores.reclaim_orphan(&path)?;
547 println!("reclaimed {path} ({bytes} bytes); the name is usable again");
548 }
549 #[cfg(not(feature = "engine-znippy"))]
550 RepoCommand::ArchiveOut { .. } | RepoCommand::ArchiveIn { .. } => {
551 anyhow::bail!("repository archives are the znippy engine's; this server was built without it")
552 }
553 #[cfg(feature = "engine-znippy")]
554 RepoCommand::ArchiveOut { .. } => {
555 unreachable!("archive-out is handled by repo_archive_out_without_stores")
559 }
560 #[cfg(feature = "engine-znippy")]
561 RepoCommand::ArchiveIn {
562 file,
563 as_path,
564 no_catalog,
565 } => {
566 let path = repo_ref(&as_path)?;
567 let dir = stores.repo_dir(&path)?;
568 let r = crate::storage::znippy_engine::archive_in(&file, &dir)?;
569 println!(
570 "restored {} bytes into {} (generation {})",
571 r.bytes_in,
572 r.repo_dir.display(),
573 r.to_generation
574 );
575 if !no_catalog {
576 let marker =
581 std::fs::read_to_string(dir.join(crate::storage::znippy_engine::OBJECT_FORMAT))
582 .unwrap_or_default();
583 let hash = match marker.trim() {
584 "sha1" => gunnar_core::HashKind::Sha1,
585 "sha256" | "" => gunnar_core::HashKind::Sha256,
586 other => bail!("the archive names an unknown object format {other:?}"),
587 };
588 let spec = NewRepo::new(path.clone()).hash_kind(hash);
589 match stores.create_repo(spec, true) {
601 Ok(created) => {
602 println!(
603 "catalogued {} ({}, default branch {})",
604 created.key(),
605 created.hash_kind,
606 created.default_branch
607 );
608 println!("{}", encrypted_flag_note(&path));
609 }
610 Err(e) if format!("{e}").contains("already exists") => {
611 match stores.catalog().get(&path)? {
612 Some(have) => println!(
613 "the catalog already has {} ({}, default branch {}) — restored \
614 from the control document, and left alone",
615 have.key(),
616 have.hash_kind,
617 have.default_branch
618 ),
619 None => println!("the catalog already has {path}"),
620 }
621 }
622 Err(e) => return Err(e),
623 }
624 }
625 }
626 }
627 Ok(())
628}
629
630#[cfg(feature = "engine-znippy")]
632fn hex_head(d: &[u8; 32]) -> String {
633 d[..8].iter().map(|b| format!("{b:02x}")).collect()
634}
635
636#[cfg(feature = "engine-znippy")]
645pub fn repo_archive_out_without_stores(
646 data_root: &std::path::Path,
647 command: RepoCommand,
648) -> Result<()> {
649 let RepoCommand::ArchiveOut {
650 path,
651 to,
652 from_generation,
653 } = command
654 else {
655 unreachable!("called with something other than archive-out")
656 };
657 let path = repo_ref(&path)?;
658 let dir = path
659 .path_under(&data_root.join("repos"))
660 .with_context(|| format!("resolving the directory for {path}"))?;
661 if !dir.is_dir() {
662 bail!("{path} has nothing on disk at {}", dir.display());
663 }
664 let r = crate::storage::znippy_engine::archive_out(&dir, &to, from_generation)?;
665 for s in &r.segments {
666 println!(
667 " {:<44} {:>12} bytes from {}",
668 s.name, s.len, s.from_offset
669 );
670 }
671 println!(
672 "archived {path} -> {} {} bytes, generation {} -> {} sha256 {}",
673 to.display(),
674 r.bytes_out,
675 r.from_generation,
676 r.to_generation,
677 hex_head(&r.file_sha256)
678 );
679 println!("next incremental: --from-generation {}", r.to_generation);
680 Ok(())
681}
682
683fn full_branch(name: &str) -> String {
685 if name.starts_with("refs/") {
686 name.to_owned()
687 } else {
688 format!("refs/heads/{name}")
689 }
690}
691
692pub fn principal(stores: &ServerStores, command: PrincipalCommand) -> Result<()> {
696 match command {
697 PrincipalCommand::Add {
698 id,
699 name,
700 kind,
701 csr,
702 server_generated_key,
703 out,
704 days,
705 } => {
706 let display = name.unwrap_or_else(|| id.clone());
707 let created = stores.auth().add_principal(gunnar_core::Principal::new(
708 &id,
709 display,
710 kind.into(),
711 )?)?;
712 println!("added principal {} ({:?})", created.id, created.kind);
713
714 match (csr, server_generated_key) {
719 (Some(path), _) => {
720 let request = std::fs::read_to_string(&path).with_context(|| {
721 format!("reading the signing request at {}", path.display())
722 })?;
723 if let Some(claimed) = gunnar_core::issuer::csr_claimed_common_name(&request)? {
724 if claimed != created.id {
725 println!(
730 "note: the request claims CN={claimed}; it is being issued as \
731 {} — a request does not choose its own name",
732 created.id
733 );
734 }
735 }
736 let issued = stores.pki()?.issue_from_csr(&created.id, &request, days)?;
737 hand_over(stores, &out, &issued)?;
738 }
739 (None, true) => {
740 let issued = stores.pki()?.issue_with_server_key(&created.id, days)?;
741 hand_over(stores, &out, &issued)?;
742 }
743 (None, false) => {
744 if stores
745 .control_store()
746 .read_doc(gunnar_core::ISSUER_REF)?
747 .is_some()
748 {
749 println!(
750 "no certificate was issued. `--csr <file>` signs a request whose \
751 key never leaves {}'s machine; `--server-generated-key` mints one \
752 here instead",
753 created.id
754 );
755 }
756 }
757 }
758 }
759 PrincipalCommand::List => {
760 let mut principals = stores.auth().principals()?;
761 principals.sort_by(|a, b| a.id.cmp(&b.id));
762 for principal in principals {
763 println!("{}\t{}\t{:?}", principal.id, principal.name, principal.kind);
764 }
765 }
766 PrincipalCommand::Remove { id } => {
767 let removed = stores.auth().remove_principal(&id)?;
768 println!("removed principal {}", removed.id);
769 }
770 }
771 Ok(())
772}
773
774pub fn key(stores: &ServerStores, command: KeyCommand) -> Result<()> {
776 match command {
777 KeyCommand::Add {
778 principal,
779 key,
780 cert,
781 } => {
782 let registered = match (key, cert) {
783 (_, Some(path)) => {
784 let der = read_certificate(&path)?;
785 stores.auth().register_certificate(&principal, &der, None)?
786 }
787 (Some(key), None) => {
788 let line = key_argument(&key)?;
789 stores.auth().register_key(&principal, &line, None)?
790 }
791 (None, None) => bail!("give a public key or --cert"),
792 };
793 println!(
794 "registered {} ({}) to {}",
795 registered.fingerprint_sha256, registered.algo, registered.principal_id
796 );
797 }
798 KeyCommand::List { principal } => {
799 let mut keys = match principal {
800 Some(id) => stores.auth().keys_of(&id)?,
801 None => stores.auth().keys()?,
802 };
803 keys.sort_by(|a, b| a.fingerprint_sha256.cmp(&b.fingerprint_sha256));
804 for key in keys {
805 println!(
806 "{}\t{}\t{}",
807 key.fingerprint_sha256, key.algo, key.principal_id
808 );
809 }
810 }
811 KeyCommand::Revoke { fingerprint } => {
812 let parsed = Fingerprint::parse(&fingerprint)?;
813 match stores.auth().revoke_key(&parsed)? {
814 Some(key) => println!("revoked {} from {}", parsed, key.principal_id),
815 None => bail!("no key with fingerprint {parsed} is registered"),
816 }
817 }
818 }
819 Ok(())
820}
821
822pub fn token(stores: &ServerStores, command: gunnar_common::cli::TokenCommand) -> Result<()> {
831 use gunnar_common::cli::TokenCommand;
832
833 match command {
834 TokenCommand::Add {
835 principal,
836 label,
837 expires_in,
838 } => {
839 let secret = gunnar_core::mint_token_secret();
840 let expires =
841 expires_in.map(|days| gunnar_core::now_ms() + (days as i64) * 24 * 60 * 60 * 1000);
842 let registered = stores
843 .auth()
844 .register_token(&principal, &secret, &label, expires)?;
845 println!("{secret}");
849 eprintln!(
850 "minted for {} (hash {}); this is the only time the token is printed",
851 registered.principal_id, registered.hash
852 );
853 }
854 TokenCommand::List { principal } => {
855 let mut tokens = stores.auth().tokens()?;
856 if let Some(id) = &principal {
857 tokens.retain(|token| &token.principal_id == id);
858 }
859 tokens.sort_by(|a, b| a.hash.cmp(&b.hash));
860 for token in tokens {
861 println!("{}\t{}\t{}", token.hash, token.principal_id, token.label);
862 }
863 }
864 TokenCommand::Revoke { hash } => match stores.auth().revoke_token(&hash)? {
865 Some(token) => println!("revoked {} from {}", token.hash, token.principal_id),
866 None => bail!("no token with hash {hash} is registered"),
867 },
868 }
869 Ok(())
870}
871
872fn scope_of(server: bool, namespace: Option<&str>, repo: Option<&str>) -> Result<Scope> {
875 match (server, namespace, repo) {
876 (true, _, _) => Ok(Scope::Server),
877 (_, Some(name), _) => Ok(Scope::Namespace(name.to_owned())),
878 (_, _, Some(path)) => Ok(Scope::Repo(repo_ref(path)?)),
879 _ => bail!("a grant needs a scope: --server, --namespace <ns>, or --repo <ns>/<name>"),
880 }
881}
882
883pub fn grant(stores: &ServerStores, args: GrantArgs) -> Result<()> {
885 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
886 let granted = stores.auth().grant(Grant {
887 principal: args.principal.clone(),
888 scope,
889 level: args.level.into(),
890 })?;
891 println!(
892 "{} now holds {} on {}",
893 granted.principal, granted.level, granted.scope
894 );
895 if let Scope::Namespace(name) = &granted.scope {
899 if name.contains('/')
900 || stores
901 .catalog()
902 .namespaces()?
903 .iter()
904 .any(|ns| ns.name.starts_with(&format!("{name}/")))
905 {
906 println!(
907 "note: namespace grants match EXACTLY; this does not reach nested namespaces \
908 under {name}/"
909 );
910 }
911 }
912 Ok(())
913}
914
915pub fn revoke(stores: &ServerStores, args: RevokeArgs) -> Result<()> {
917 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
918 if stores.auth().revoke_grant(&args.principal, &scope)? {
919 println!("revoked {}'s access to {scope}", args.principal);
920 Ok(())
921 } else {
922 bail!("{} holds no grant on {scope}", args.principal)
923 }
924}
925
926pub fn gc(stores: &ServerStores, args: GcArgs) -> Result<()> {
966 let repos = match &args.path {
967 Some(path) => {
968 let path = repo_ref(path)?;
969 vec![stores.catalog().open(&path)?]
970 }
971 None => stores.catalog().list(None)?,
972 };
973
974 let _ = &args.prune;
988 let _ = &args.prune_grace;
989 let mut done = 0usize;
990 let mut failed = 0usize;
991 let mut reclaimed: i64 = 0;
992 for repo in &repos {
993 match gc_one(stores, repo) {
994 Ok(report) => {
995 done += 1;
996 reclaimed += report.bytes_before as i64 - report.bytes_after as i64;
997 println!(
998 "{repo}\t{} -> {} bytes, {} live rows, {} pack(s) retired, {} section(s) \
999 sealed [{}]",
1000 report.bytes_before,
1001 report.bytes_after,
1002 report.rows,
1003 report.retired_packs,
1004 report.sections_sealed,
1005 report.strategy,
1006 );
1007 }
1008 Err(err) => {
1009 failed += 1;
1010 eprintln!("{repo}\tgc failed: {err:#}");
1011 }
1012 }
1013 }
1014 println!(
1015 "{done} of {} repository(ies) maintained, {reclaimed} bytes reclaimed",
1016 repos.len()
1017 );
1018 if failed > 0 {
1019 bail!("{failed} repository(ies) could not be maintained; see above");
1020 }
1021 Ok(())
1022}
1023
1024pub fn gc_one(stores: &ServerStores, repo: &Repo) -> Result<MaintenanceReport> {
1042 stores.wait_unpinned("a gc");
1045 let span = tracing::info_span!(target: TARGET_STORE, "store.maintain", repo = %repo);
1046 let _entered = span.enter();
1047 let started = std::time::Instant::now();
1048 let store = stores.repo_store(repo)?;
1049 match stores.engine().maintain(store.git_dir(), store.hash_kind()) {
1050 Ok(report) => {
1051 tracing::info!(
1052 target: TARGET_STORE,
1053 event = "store.maintained",
1054 strategy = %report.strategy,
1055 bytes_before = report.bytes_before,
1056 bytes_after = report.bytes_after,
1057 rows = report.rows,
1058 retired_packs = report.retired_packs,
1059 verified = report.verified,
1060 sections_sealed = report.sections_sealed,
1061 elapsed_ms = started.elapsed().as_millis() as u64,
1062 "the store was sealed and compacted"
1063 );
1064 Ok(report)
1065 }
1066 Err(err) => {
1067 tracing::error!(
1068 target: TARGET_STORE,
1069 event = "store.maintain_failed",
1070 %err,
1071 "maintaining the store failed"
1072 );
1073 Err(err)
1074 }
1075 }
1076}
1077
1078pub fn reclaimed_bytes(report: &MaintenanceReport) -> u64 {
1087 report.bytes_before.saturating_sub(report.bytes_after)
1088}
1089
1090pub fn reflog(data_root: &std::path::Path, args: ReflogArgs) -> Result<()> {
1177 let repos_root = data_root.join(crate::stores::REPOS_DIR);
1178 let repos: Vec<(String, std::path::PathBuf)> = match &args.path {
1179 Some(path) => {
1180 let path = repo_ref(path)?;
1181 let dir = path
1182 .path_under(&repos_root)
1183 .with_context(|| format!("resolving the directory for {path}"))?;
1184 if !dir.join("HEAD").exists() {
1185 bail!("there is no repository at {}", dir.display());
1186 }
1187 vec![(path.key(), dir)]
1188 }
1189 None => bare_repositories(&repos_root)?,
1190 };
1191 if repos.is_empty() {
1192 println!("no repositories under {}", repos_root.display());
1193 return Ok(());
1194 }
1195 let seal_is_a_directory = args.seal.is_some() && repos.len() > 1;
1198
1199 let mut read = 0usize;
1200 let mut failed = 0usize;
1201 let mut refused = 0usize;
1202 for (key, git_dir) in &repos {
1203 let store_dir = git_dir.join(LEGACY_STORE_DIR);
1204 if !legacy_reflog_present(&store_dir) {
1208 refused += 1;
1209 eprintln!(
1210 "{key}\tREFUSED: no reference history this verb can read — nothing at {}",
1211 store_dir.display()
1212 );
1213 continue;
1214 }
1215 let hash_kind = match stored_hash_kind(git_dir) {
1216 Ok(kind) => kind,
1217 Err(err) => {
1218 failed += 1;
1219 eprintln!("{key}\tFAILED: {err:#}");
1220 continue;
1221 }
1222 };
1223
1224 let history = match gunnar_core::History::read(&store_dir, hash_kind) {
1225 Ok(history) => history,
1226 Err(err) => {
1227 failed += 1;
1228 eprintln!("{key}\tFAILED: {err}");
1229 continue;
1230 }
1231 };
1232 read += 1;
1233
1234 match &args.seal {
1235 Some(target) => {
1236 let path = if seal_is_a_directory {
1237 target.join(format!("{}.arrow", key.replace('/', "_")))
1238 } else {
1239 target.clone()
1240 };
1241 let report = history.seal(&path)?;
1242 println!(
1243 "{key}\t{} pushes ({} archived, {} live)\t{} rows\t{} B\t{:.1} B/push\t{:.2}x smaller than the {} B it read\t{}",
1244 report.pushes,
1245 history.archived_pushes(),
1246 history.live_pushes(),
1247 report.rows,
1248 report.bytes,
1249 report.bytes_per_push(),
1250 report.shrink_x(),
1251 report.bytes_in,
1252 path.display(),
1253 );
1254 }
1255 None => {
1256 let transactions = history.transactions()?;
1257 println!(
1265 "{key}\t{} pushes ({} from the archive, {} live) in {} segment(s)\t{} rows\t{} B read",
1266 history.pushes(),
1267 history.archived_pushes(),
1268 history.live_pushes(),
1269 history.segments_read(),
1270 transactions.len(),
1271 history.bytes_in(),
1272 );
1273 let show = if args.all {
1274 transactions.as_slice()
1275 } else {
1276 let from = transactions.len().saturating_sub(args.limit);
1277 &transactions[from..]
1278 };
1279 if show.len() < transactions.len() {
1280 println!(
1281 " … {} older transaction(s) not shown; --all for every one",
1282 transactions.len() - show.len()
1283 );
1284 }
1285 for entry in show {
1286 println!(
1287 " {:>6} {} {} -> {}{}",
1288 entry.push_seq,
1289 entry.refname,
1290 value(entry.old_oid.as_ref(), entry.old_symref.as_deref()),
1291 value(entry.new_oid.as_ref(), entry.new_symref.as_deref()),
1292 if entry.is_snapshot() {
1293 " (compaction snapshot, not a move)"
1294 } else {
1295 ""
1296 },
1297 );
1298 }
1299 }
1300 }
1301 }
1302
1303 if refused > 0 {
1307 eprintln!("\n{}", refusal_explanation());
1308 }
1309 if failed > 0 {
1310 bail!(
1311 "{failed} of {} repositor{} could not be read; {read} were",
1312 repos.len(),
1313 if repos.len() == 1 { "y" } else { "ies" }
1314 );
1315 }
1316 if read == 0 {
1320 bail!(
1321 "no reference history was read from any of the {} repositor{} looked at",
1322 repos.len(),
1323 if repos.len() == 1 { "y" } else { "ies" }
1324 );
1325 }
1326 Ok(())
1327}
1328
1329const LEGACY_STORE_DIR: &str = "gunnar-store";
1337
1338fn legacy_reflog_present(store_dir: &std::path::Path) -> bool {
1348 store_dir.join(gunnar_core::arrow_log::REFS_LOG).is_file()
1349 || store_dir
1350 .join(gunnar_core::arrow_log::REFS_ARCHIVE_DIR)
1351 .is_dir()
1352}
1353
1354fn refusal_explanation() -> String {
1360 let engine = match crate::storage::Engine::selected() {
1361 Ok(engine) => engine.name(),
1362 Err(_) => "<unselectable>",
1366 };
1367 format!(
1368 "`reflog` reads the retired filesystem backend's Arrow reflog —\n\
1369 `<git-dir>/{LEGACY_STORE_DIR}/{log}` and `{archive}/` beside it. That backend went with\n\
1370 the packfile engine (the storage restart) and nothing writes those bytes any more, so\n\
1371 a repository created since has none and this verb has nothing to read.\n\
1372 \n\
1373 This process runs the `{engine}` engine. Where reference history is now:\n\
1374 \n\
1375 * znippy — one Arrow IPC frame per push in the store's own `refs.log`. It is real and it\n\
1376 \x20 is complete, but `GitStore::ref_log()` is `pub(crate)`, and its rows carry no actor\n\
1377 \x20 and no previous value, so this verb's output is not merely re-pointable at it.\n\
1378 * gix — none at all. `storage-git-gix` opens its ref store with `WriteReflog::Disable`,\n\
1379 \x20 deliberately: a git server has no use for a per-ref reflog file tree.\n\
1380 \n\
1381 Neither `GitOps` nor `GitServe` carries a ref-history accessor, and the `GitServe`\n\
1382 reading contract's ordering rule says one does not belong on the contract. Making this\n\
1383 verb answer again is an engine-shaped seam plus a decision about what it should\n\
1384 report — see `agentO-report.md`.",
1385 log = gunnar_core::arrow_log::REFS_LOG,
1386 archive = gunnar_core::arrow_log::REFS_ARCHIVE_DIR,
1387 )
1388}
1389
1390fn stored_hash_kind(git_dir: &std::path::Path) -> Result<gix_hash::Kind> {
1408 const OBJECT_FORMAT_KEY: &str = "objectformat";
1409
1410 let config_path = git_dir.join("config");
1411 let text = match std::fs::read_to_string(&config_path) {
1412 Ok(text) => text,
1413 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(gix_hash::Kind::Sha1),
1416 Err(err) => {
1417 return Err(anyhow::Error::from(err))
1418 .with_context(|| format!("reading {}", config_path.display()))
1419 }
1420 };
1421
1422 let mut in_extensions = false;
1423 for line in text.lines() {
1424 let line = line.trim();
1425 if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1426 in_extensions = header.trim().eq_ignore_ascii_case("extensions");
1427 continue;
1428 }
1429 if !in_extensions {
1430 continue;
1431 }
1432 let Some((key, value)) = line.split_once('=') else {
1433 continue;
1434 };
1435 if !key.trim().eq_ignore_ascii_case(OBJECT_FORMAT_KEY) {
1437 continue;
1438 }
1439 return match value.trim() {
1440 "sha256" => Ok(gix_hash::Kind::Sha256),
1441 "sha1" => Ok(gix_hash::Kind::Sha1),
1442 other => bail!(
1443 "{} declares an object format gunnar does not know: {other:?}",
1444 config_path.display()
1445 ),
1446 };
1447 }
1448 Ok(gix_hash::Kind::Sha1)
1449}
1450
1451fn bare_repositories(repos_root: &std::path::Path) -> Result<Vec<(String, std::path::PathBuf)>> {
1458 fn walk(
1459 dir: &std::path::Path,
1460 prefix: &str,
1461 out: &mut Vec<(String, std::path::PathBuf)>,
1462 ) -> Result<()> {
1463 let entries = match std::fs::read_dir(dir) {
1464 Ok(entries) => entries,
1465 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1466 Err(err) => return Err(err.into()),
1467 };
1468 for entry in entries {
1469 let entry = entry?;
1470 if !entry.file_type()?.is_dir() {
1471 continue;
1472 }
1473 let name = entry.file_name().to_string_lossy().into_owned();
1474 let path = entry.path();
1475 if let Some(stem) = name.strip_suffix(".git") {
1476 if path.join("HEAD").exists() {
1477 out.push((format!("{prefix}{stem}"), path));
1478 continue;
1479 }
1480 }
1481 walk(&path, &format!("{prefix}{name}/"), out)?;
1484 }
1485 Ok(())
1486 }
1487 let mut out = Vec::new();
1488 walk(repos_root, "", &mut out)?;
1489 out.sort();
1490 Ok(out)
1491}
1492
1493fn value(oid: Option<&gunnar_store::gix_hash::ObjectId>, symref: Option<&str>) -> String {
1498 match (oid, symref) {
1499 (Some(id), _) => id.to_string(),
1500 (None, Some(name)) => format!("ref: {name}"),
1501 (None, None) => "-".to_owned(),
1502 }
1503}
1504
1505pub fn attest(stores: &ServerStores, command: gunnar_common::cli::AttestCommand) -> Result<()> {
1511 use gunnar_common::cli::AttestCommand;
1512
1513 let log = stores.transparency();
1514 match command {
1515 AttestCommand::Head { builds } => {
1516 let log = if builds { stores.build_log() } else { log };
1517 let key = stores.transparency_key()?;
1518 let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1519 println!("size {}", signed.head.size);
1520 println!("root {}", signed.head.root);
1521 println!("signed by {}", signed.key_fingerprint);
1522 println!("appended {}", log.heads_path().display());
1523 if signed.head.size == 0 {
1524 println!();
1529 println!(
1530 "This head covers an EMPTY log: it says this server had accepted no push \
1531 when it was signed."
1532 );
1533 }
1534 Ok(())
1535 }
1536 AttestCommand::Audit { builds } => {
1537 let log = if builds { stores.build_log() } else { log };
1538 let key = stores.transparency_key()?;
1539 let verdict = log.audit(key.public_key())?;
1540 println!("{verdict}");
1541 if verdict.is_consistent() {
1542 return Ok(());
1543 }
1544 anyhow::bail!("the transparency log did not audit clean")
1548 }
1549 AttestCommand::Key => {
1550 let key = stores.transparency_key()?;
1551 println!(
1552 "{}",
1553 key.public_key()
1554 .to_openssh()
1555 .context("rendering the transparency log's public key")?
1556 );
1557 Ok(())
1558 }
1559 AttestCommand::Prove { pack_hash } => {
1560 let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1561 anyhow::bail!(
1562 "no attestation in this log names pack {pack_hash}. A proof for a record \
1563 the log does not hold is not an empty proof."
1564 )
1565 };
1566 println!("index {}", inclusion.index);
1567 println!("size {}", inclusion.size);
1568 println!("leaf {}", inclusion.leaf);
1569 println!("root {}", inclusion.root);
1570 for (step, hash) in inclusion.proof.iter().enumerate() {
1571 println!("proof[{step}] {hash}");
1572 }
1573 Ok(())
1574 }
1575 }
1576}
1577
1578pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1585 let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1586 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1587 let chain = match (&args.artifact, &args.repo) {
1588 (Some(sha256), _) => {
1589 crate::verify::verify_artifact(&stores, &plane, sha256, args.line.as_deref())?
1590 }
1591 (None, Some(repo)) => {
1592 let (namespace, name) = repo.split_once('/').with_context(|| {
1593 format!(
1594 "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1595 namespace (multi-tenancy)"
1596 )
1597 })?;
1598 crate::verify::verify(
1599 &stores,
1600 &plane,
1601 &gunnar_proto::RepoPath::new(namespace, name),
1602 &args.at,
1603 args.line.as_deref(),
1604 )?
1605 }
1606 (None, None) => anyhow::bail!("name a repository, or an --artifact to start from"),
1609 };
1610 println!("{chain}");
1611 if chain.is_unbroken() {
1612 return Ok(());
1613 }
1614 if chain.is_broken() {
1615 anyhow::bail!("the chain is broken");
1616 }
1617 anyhow::bail!("the chain is incomplete")
1618}
1619
1620pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1625 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1626 format!(
1627 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1628 namespace (multi-tenancy)",
1629 args.repo
1630 )
1631 })?;
1632 let rows = crate::blame::blame_at(
1633 data_root,
1634 &gunnar_proto::RepoPath::new(namespace, name),
1635 &args.rev,
1636 &args.path,
1637 )?;
1638 for row in &rows {
1641 let hex = row.commit.to_hex().to_string();
1642 let short = &hex[..hex.len().min(12)];
1643 println!("{short} {}\t{}", row.author, String::from_utf8_lossy(&row.line));
1644 }
1645 Ok(())
1646}
1647
1648pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1651 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1652 format!(
1653 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1654 namespace (multi-tenancy)",
1655 args.repo
1656 )
1657 })?;
1658 let limits = crate::search::Limits {
1659 max_file_bytes: args.max_file_bytes,
1660 max_matches: args.max_matches,
1661 };
1662 let hits = crate::search::search_at(
1663 data_root,
1664 &gunnar_proto::RepoPath::new(namespace, name),
1665 &args.rev,
1666 &args.pattern,
1667 limits,
1668 )?;
1669 for hit in &hits {
1670 println!(
1671 "{}:{}:{}",
1672 hit.path,
1673 hit.line_number,
1674 String::from_utf8_lossy(&hit.line)
1675 );
1676 }
1677 Ok(())
1678}
1679
1680pub fn build(stores: &ServerStores, command: gunnar_common::cli::BuildCommand) -> Result<()> {
1682 use gunnar_common::cli::BuildCommand;
1683
1684 let log = stores.build_log();
1685 match command {
1686 BuildCommand::Attest {
1687 repo,
1688 commit,
1689 recipe,
1690 toolchain,
1691 builder,
1692 network,
1693 materials,
1694 artifacts,
1695 } => {
1696 let subject = |path: &std::path::Path| -> Result<gunnar_core::Subject> {
1700 Ok(gunnar_core::Subject {
1701 name: path
1702 .file_name()
1703 .map(|n| n.to_string_lossy().into_owned())
1704 .unwrap_or_else(|| path.display().to_string()),
1705 sha256: gunnar_core::provenance::digest_file(path)
1706 .with_context(|| format!("hashing {}", path.display()))?,
1707 })
1708 };
1709 let now = gunnar_grpc::bus::now_unix_ms();
1710 let record = gunnar_core::BuildRecord {
1711 builder,
1712 source_repo: repo,
1713 source_commit: commit,
1714 recipe,
1715 toolchain,
1716 network,
1717 materials: materials
1718 .iter()
1719 .map(|p| subject(p))
1720 .collect::<Result<_>>()?,
1721 subjects: artifacts
1722 .iter()
1723 .map(|p| subject(p))
1724 .collect::<Result<_>>()?,
1725 started_at_unix_ms: now,
1726 finished_at_unix_ms: now,
1727 };
1728 let key = stores.provenance_key()?;
1729 let signed = gunnar_core::provenance::sign_build(&key, record)
1730 .context("signing the build record")?;
1731 let mut line = serde_json::to_string(&signed)?;
1732 line.push('\n');
1733 let _framed = gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))?;
1734 use std::io::Write as _;
1735 let mut file = std::fs::OpenOptions::new()
1736 .create(true)
1737 .append(true)
1738 .open(log.records_path())?;
1739 file.write_all(line.as_bytes())?;
1740
1741 println!("recorded {}", signed.record.digest());
1742 println!("builder {}", signed.record.builder);
1743 println!(
1744 "source {}@{}",
1745 signed.record.source_repo, signed.record.source_commit
1746 );
1747 println!("network {}", signed.record.network);
1748 for subject in &signed.record.subjects {
1749 println!("produced {} {}", subject.sha256, subject.name);
1750 }
1751 println!("signed by {}", signed.key_fingerprint);
1752 println!();
1753 println!(
1754 "No signed head covers it yet. `gunnar attest head --builds` is what turns this \
1755 record into evidence somebody else can check."
1756 );
1757 Ok(())
1758 }
1759 BuildCommand::Sbom { lock, artifact } => {
1760 let text = std::fs::read_to_string(&lock)
1761 .with_context(|| format!("reading {}", lock.display()))?;
1762 let packages = gunnar_core::sbom::parse_lock(&text);
1763 anyhow::ensure!(
1764 !packages.is_empty(),
1765 "{} yielded no packages. An empty SBOM is not a small one — it is a document \
1766 that says this artifact contains nothing, and that is never true.",
1767 lock.display()
1768 );
1769 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1770 .with_context(|| format!("hashing {}", artifact.display()))?;
1771 let name = artifact
1772 .file_name()
1773 .map(|n| n.to_string_lossy().into_owned())
1774 .unwrap_or_else(|| artifact.display().to_string());
1775 print!(
1779 "{}",
1780 gunnar_core::sbom::cyclonedx(&packages, &name, &sha256, &sha256)
1781 );
1782 Ok(())
1783 }
1784 BuildCommand::Admit { artifact } => {
1785 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1786 .with_context(|| format!("hashing {}", artifact.display()))?;
1787 match crate::registry::admit(stores, &sha256)? {
1788 Ok(admitted) => {
1789 println!("admitted {sha256}");
1790 println!("built by {}", admitted.record.builder);
1791 println!(
1792 "source {}@{}",
1793 admitted.record.source_repo, admitted.record.source_commit
1794 );
1795 println!("record {} of the build log", admitted.index);
1796 println!(
1797 "head size {} root {}",
1798 admitted.head.size, admitted.head.root
1799 );
1800 Ok(())
1801 }
1802 Err(refusal) => {
1803 println!("refused {sha256}");
1807 println!("{refusal}");
1808 anyhow::bail!("the registry refuses this artifact")
1809 }
1810 }
1811 }
1812 BuildCommand::Run {
1813 repo,
1814 at,
1815 workspace,
1816 builder,
1817 allow_network,
1818 } => {
1819 let (namespace, name) = repo
1820 .split_once('/')
1821 .with_context(|| format!("{repo:?} is not `<namespace>/<repo>`"))?;
1822 let repo_ref = gunnar_core::RepoRef::new(namespace, name)
1823 .context("the repository path is not a legal one")?;
1824 let commit = resolve_commit(stores, &repo_ref, &at)?;
1825 let built = crate::runner::build_commit(
1826 stores,
1827 &repo_ref,
1828 commit,
1829 &workspace,
1830 &builder,
1831 allow_network,
1832 )?;
1833 println!(
1834 "built {}@{}",
1835 built.record.source_repo, built.record.source_commit
1836 );
1837 println!("recipe {}", built.record.recipe);
1838 println!("toolchain {}", built.record.toolchain);
1839 println!("network {}", built.record.network);
1840 for subject in &built.record.subjects {
1841 println!("produced {} {}", subject.sha256, subject.name);
1842 }
1843 println!("workdir {}", built.workdir.display());
1844 println!();
1845 println!(
1846 "`gunnar attest head --builds` is what turns this record into evidence \
1847 somebody else can check."
1848 );
1849 Ok(())
1850 }
1851 BuildCommand::Watch {
1852 workspace,
1853 builder,
1854 refs,
1855 allow_network,
1856 max_builds,
1857 } => {
1858 anyhow::bail!(
1859 "`gunnar build watch` needs the event bus the serving process owns, and this \
1860 command opens the stores on its own. Start the server with `--runner \
1861 --runner-workspace {} --runner-builder {}`{}{}; the runner then shares the \
1862 bus every push publishes on.\n\n\
1863 A second process cannot subscribe to an in-process bus, and a runner that \
1864 polled the log instead would build a push some time after it landed rather \
1865 than because it landed.",
1866 workspace.display(),
1867 builder,
1868 if allow_network {
1869 " --runner-allow-network"
1870 } else {
1871 ""
1872 },
1873 match (refs.is_empty(), max_builds) {
1874 (true, None) => String::new(),
1875 _ => " (--ref/--max-builds are `watch` options; the served runner builds \
1876 every branch)".to_string(),
1877 }
1878 )
1879 }
1880 BuildCommand::Image {
1881 artifact,
1882 entrypoint,
1883 builder,
1884 } => {
1885 let bytes = std::fs::read(&artifact)
1886 .with_context(|| format!("reading {}", artifact.display()))?;
1887 let artifact_digest = gunnar_core::provenance::digest_bytes(&bytes);
1888
1889 let admitted = match crate::registry::admit(stores, &artifact_digest)? {
1893 Ok(admitted) => admitted,
1894 Err(refusal) => {
1895 anyhow::bail!("the binary this image would carry is not admitted: {refusal}")
1896 }
1897 };
1898
1899 let registry = crate::oci::Registry::under(stores.root());
1900 let image = crate::oci::build_and_store(®istry, &bytes, &entrypoint)?;
1901 registry.put_blob(&image.manifest)?;
1902
1903 let now = gunnar_grpc::bus::now_unix_ms();
1907 let record = gunnar_core::BuildRecord {
1908 builder,
1909 source_repo: admitted.record.source_repo.clone(),
1910 source_commit: admitted.record.source_commit.clone(),
1911 recipe: format!("oci image of {entrypoint}"),
1912 toolchain: "gunnar oci".to_owned(),
1913 network: false,
1917 materials: vec![gunnar_core::Subject {
1918 name: artifact
1919 .file_name()
1920 .map(|n| n.to_string_lossy().into_owned())
1921 .unwrap_or_else(|| artifact.display().to_string()),
1922 sha256: artifact_digest.clone(),
1923 }],
1924 subjects: vec![gunnar_core::Subject {
1925 name: format!("{entrypoint} (oci manifest)"),
1926 sha256: image.digest.trim_start_matches("sha256:").to_owned(),
1927 }],
1928 started_at_unix_ms: now,
1929 finished_at_unix_ms: now,
1930 };
1931 let key = stores.provenance_key()?;
1932 let signed = gunnar_core::provenance::sign_build(&key, record)
1933 .context("signing the image's build record")?;
1934 let mut line = serde_json::to_string(&signed)?;
1935 line.push('\n');
1936 let _framed = gunnar_core::txlog::sink::append_line(stores.build_log().records_path(), line.trim_end_matches('\n'))?;
1937 use std::io::Write as _;
1938 let mut file = std::fs::OpenOptions::new()
1939 .create(true)
1940 .append(true)
1941 .open(stores.build_log().records_path())?;
1942 file.write_all(line.as_bytes())?;
1943
1944 println!("image {}", image.digest);
1945 println!("config {}", image.config_digest);
1946 println!("layer {}", image.layer_digest);
1947 println!("files {}", image.files);
1948 println!("from {}", artifact_digest);
1949 println!(
1950 "source {}@{}",
1951 signed.record.source_repo, signed.record.source_commit
1952 );
1953 println!();
1954 println!(
1955 "Zero CVEs is this number: {} file(s) in the image's filesystem, on no base at \
1956 all. Next: `gunnar attest head --builds`, then `gunnar registry push`.",
1957 image.files
1958 );
1959 Ok(())
1960 }
1961 BuildCommand::List => {
1962 let key = stores.provenance_key()?;
1963 let mut any = false;
1964 for line in log.lines_of()? {
1965 let signed: gunnar_core::SignedBuild =
1966 serde_json::from_str(&line).context("decoding a build record")?;
1967 let verdict = match gunnar_core::provenance::verify_build(&signed, key.public_key())
1968 {
1969 Ok(()) => "ok ".to_owned(),
1970 Err(problem) => format!("BAD ({problem})"),
1971 };
1972 println!(
1973 "{verdict} {} {}@{} {}",
1974 signed.record.digest(),
1975 signed.record.source_repo,
1976 signed.record.source_commit,
1977 signed.record.recipe
1978 );
1979 any = true;
1980 }
1981 if !any {
1982 println!("this server has attested to no builds");
1985 }
1986 Ok(())
1987 }
1988 BuildCommand::Key => {
1989 let key = stores.provenance_key()?;
1990 println!(
1991 "{}",
1992 key.public_key()
1993 .to_openssh()
1994 .context("rendering the provenance public key")?
1995 );
1996 Ok(())
1997 }
1998 }
1999}
2000
2001pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
2003 use gunnar_common::cli::ForgeCommand;
2004
2005 let log = stores.forge_log();
2006
2007 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2010 log.lines_of()?
2011 .iter()
2012 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2013 .collect()
2014 }
2015
2016 fn append(
2017 stores: &ServerStores,
2018 identity: &std::path::Path,
2019 event: gunnar_core::ForgeEvent,
2020 ) -> Result<gunnar_core::SignedEvent> {
2021 let pem = std::fs::read_to_string(identity)
2022 .with_context(|| format!("reading {}", identity.display()))?;
2023 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2024 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2025 let fingerprint = key
2030 .public_key()
2031 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2032 .to_string();
2033 let known = stores
2034 .auth()
2035 .signing_keys()?
2036 .into_iter()
2037 .any(|row| row.fingerprint_sha256 == fingerprint);
2038 anyhow::ensure!(
2039 known,
2040 "{fingerprint} is not registered on this server, so nobody reading this event later \
2041 could check its signature. Register it with `gunnar key add` first."
2042 );
2043 let signed =
2044 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2045 let mut line = serde_json::to_string(&signed)?;
2046 line.push('\n');
2047 let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2048 use std::io::Write as _;
2049 let mut file = std::fs::OpenOptions::new()
2050 .create(true)
2051 .append(true)
2052 .open(stores.forge_log().records_path())?;
2053 file.write_all(line.as_bytes())?;
2054 Ok(signed)
2055 }
2056
2057 match command {
2058 ForgeCommand::Comment {
2059 repo,
2060 pull,
2061 body,
2062 actor,
2063 identity,
2064 } => {
2065 append(
2066 stores,
2067 &identity,
2068 gunnar_core::ForgeEvent {
2069 repo: repo.clone(),
2070 pull,
2071 actor,
2072 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2073 event: gunnar_core::Event::Commented {
2074 body,
2075 path: String::new(),
2076 line: 0,
2077 blob_oid: String::new(),
2078 },
2079 },
2080 )?;
2081 println!("commented on {repo}#{pull}");
2082 Ok(())
2083 }
2084 ForgeCommand::Approve {
2085 repo,
2086 pull,
2087 commit,
2088 actor,
2089 identity,
2090 } => {
2091 let signed = append(
2092 stores,
2093 &identity,
2094 gunnar_core::ForgeEvent {
2095 repo: repo.clone(),
2096 pull,
2097 actor: actor.clone(),
2098 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2099 event: gunnar_core::Event::Approved {
2100 commit: commit.clone(),
2101 },
2102 },
2103 )?;
2104 println!("{actor} approved {commit} on {repo}#{pull}");
2105 println!("signed by {}", signed.key_fingerprint);
2106 Ok(())
2107 }
2108 ForgeCommand::Reject {
2109 repo,
2110 pull,
2111 commit,
2112 reason,
2113 actor,
2114 identity,
2115 } => {
2116 let signed = append(
2117 stores,
2118 &identity,
2119 gunnar_core::ForgeEvent {
2120 repo: repo.clone(),
2121 pull,
2122 actor: actor.clone(),
2123 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2124 event: gunnar_core::Event::Rejected {
2125 commit: commit.clone(),
2126 reason,
2127 },
2128 },
2129 )?;
2130 println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
2131 println!("signed by {}", signed.key_fingerprint);
2132 Ok(())
2133 }
2134 ForgeCommand::Show { repo, pull } => {
2135 let all = events(&log)?;
2136 let mine: Vec<gunnar_core::ForgeEvent> = all
2137 .iter()
2138 .filter(|e| e.event.repo == repo)
2139 .map(|e| e.event.clone())
2140 .collect();
2141 let state = gunnar_core::forge::fold(pull, &mine);
2142 println!("{repo}#{pull} review");
2146 println!("comments {}", state.comments);
2147 if state.approvals.is_empty() {
2148 println!("approvals none");
2149 }
2150 let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
2155 .auth()
2156 .signing_keys()?
2157 .iter()
2158 .filter_map(|row| {
2159 gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
2160 .ok()
2161 .map(|key| (row.fingerprint_sha256.clone(), key))
2162 })
2163 .collect();
2164 for (actor, commit) in &state.approvals {
2165 let signed = all.iter().rev().find(|e| {
2166 e.event.repo == repo
2167 && e.event.pull == pull
2168 && e.event.actor == *actor
2169 && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
2170 });
2171 let verdict = match signed {
2172 None => "NO EVENT".to_owned(),
2173 Some(signed) => match keys
2174 .iter()
2175 .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
2176 {
2177 None => "UNKNOWN KEY".to_owned(),
2178 Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
2179 Ok(()) => "verified".to_owned(),
2180 Err(problem) => format!("BAD ({problem})"),
2181 },
2182 },
2183 };
2184 println!("approval {actor} → {commit} [{verdict}]");
2185 }
2186 Ok(())
2187 }
2188 ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
2189 }
2190}
2191
2192fn forge_issue(
2194 stores: &ServerStores,
2195 log: &crate::attest::TransparencyLog,
2196 command: gunnar_common::cli::IssueCommand,
2197) -> Result<()> {
2198 use gunnar_common::cli::IssueCommand;
2199
2200 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2202 log.lines_of()?
2203 .iter()
2204 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2205 .collect()
2206 }
2207
2208 fn repo_events(
2210 log: &crate::attest::TransparencyLog,
2211 repo: &str,
2212 ) -> Result<Vec<gunnar_core::ForgeEvent>> {
2213 Ok(events(log)?
2214 .into_iter()
2215 .filter(|e| e.event.repo == repo)
2216 .map(|e| e.event)
2217 .collect())
2218 }
2219
2220 fn append(
2224 stores: &ServerStores,
2225 identity: &std::path::Path,
2226 event: gunnar_core::ForgeEvent,
2227 ) -> Result<gunnar_core::SignedEvent> {
2228 let pem = std::fs::read_to_string(identity)
2229 .with_context(|| format!("reading {}", identity.display()))?;
2230 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2231 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2232 let fingerprint = key
2233 .public_key()
2234 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2235 .to_string();
2236 let known = stores
2237 .auth()
2238 .signing_keys()?
2239 .into_iter()
2240 .any(|row| row.fingerprint_sha256 == fingerprint);
2241 anyhow::ensure!(
2242 known,
2243 "{fingerprint} is not registered on this server, so nobody reading this event later \
2244 could check its signature. Register it with `gunnar key add` first."
2245 );
2246 let signed =
2247 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2248 let mut line = serde_json::to_string(&signed)?;
2249 line.push('\n');
2250 let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2251 use std::io::Write as _;
2252 let mut file = std::fs::OpenOptions::new()
2253 .create(true)
2254 .append(true)
2255 .open(stores.forge_log().records_path())?;
2256 file.write_all(line.as_bytes())?;
2257 Ok(signed)
2258 }
2259
2260 match command {
2261 IssueCommand::Open {
2262 repo,
2263 title,
2264 body,
2265 actor,
2266 identity,
2267 } => {
2268 append(
2269 stores,
2270 &identity,
2271 gunnar_core::ForgeEvent {
2272 repo: repo.clone(),
2273 pull: 0,
2275 actor: actor.clone(),
2276 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2277 event: gunnar_core::Event::IssueOpened {
2278 title: title.clone(),
2279 body,
2280 },
2281 },
2282 )?;
2283 let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
2286 .last()
2287 .map(|i| i.number)
2288 .unwrap_or(0);
2289 println!("opened {repo}#{number} {title}");
2290 println!("{actor} filed it, signed with their own key");
2291 Ok(())
2292 }
2293 IssueCommand::Comment {
2294 repo,
2295 number,
2296 body,
2297 actor,
2298 identity,
2299 } => {
2300 anyhow::ensure!(
2301 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2302 .is_some_and(|i| i.open),
2303 "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2304 evidence about nothing"
2305 );
2306 append(
2307 stores,
2308 &identity,
2309 gunnar_core::ForgeEvent {
2310 repo: repo.clone(),
2311 pull: number,
2312 actor,
2313 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2314 event: gunnar_core::Event::IssueCommented { body },
2315 },
2316 )?;
2317 println!("commented on {repo}#{number}");
2318 Ok(())
2319 }
2320 IssueCommand::Close {
2321 repo,
2322 number,
2323 reason,
2324 actor,
2325 identity,
2326 } => {
2327 anyhow::ensure!(
2328 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2329 .is_some_and(|i| i.open),
2330 "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2331 );
2332 append(
2333 stores,
2334 &identity,
2335 gunnar_core::ForgeEvent {
2336 repo: repo.clone(),
2337 pull: number,
2338 actor: actor.clone(),
2339 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2340 event: gunnar_core::Event::IssueClosed { reason },
2341 },
2342 )?;
2343 println!("{actor} closed {repo}#{number}");
2344 Ok(())
2345 }
2346 IssueCommand::List { repo } => {
2347 let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2348 if issues.is_empty() {
2349 println!("{repo} has no issues");
2350 return Ok(());
2351 }
2352 for issue in issues {
2353 let state = if issue.open { "open " } else { "closed" };
2354 println!(
2355 "{repo}#{} [{state}] {} ({} comment(s), by {})",
2356 issue.number, issue.title, issue.comments, issue.author
2357 );
2358 }
2359 Ok(())
2360 }
2361 IssueCommand::Show { repo, number } => {
2362 let all = repo_events(log, &repo)?;
2363 let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2364 anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2365 };
2366 let state = if issue.open { "open" } else { "closed" };
2367 println!("{repo}#{number} [{state}] {}", issue.title);
2368 println!("opened by {}", issue.author);
2369 if !issue.body.is_empty() {
2370 println!();
2371 println!("{}", issue.body);
2372 }
2373 println!();
2374 println!("comments {}", issue.comments);
2375 Ok(())
2376 }
2377 }
2378}
2379
2380fn resolve_commit(
2386 stores: &ServerStores,
2387 repo: &gunnar_core::RepoRef,
2388 rev: &str,
2389) -> Result<gix_hash::ObjectId> {
2390 let entry = stores
2391 .catalog()
2392 .get(repo)?
2393 .with_context(|| format!("no such repository: {repo}"))?;
2394 let store = stores.repo_store(&entry)?;
2395 let git = store.serve();
2396 if let Ok(oid) = gix_hash::ObjectId::from_hex(rev.as_bytes()) {
2397 anyhow::ensure!(
2398 oid.kind() == store.hash_kind(),
2399 "{rev} is a {} oid; this repository is {}",
2400 oid.kind(),
2401 store.hash_kind()
2402 );
2403 return Ok(oid);
2404 }
2405 let snapshot = gunnar_wire::RefSnapshot::read(git)?;
2406 let resolved = if rev == gunnar_store::HEAD {
2407 snapshot.head(git)?
2408 } else {
2409 snapshot.resolve(rev)?
2410 };
2411 resolved.with_context(|| format!("{rev} names no commit in {repo}"))
2412}
2413
2414pub fn registry(
2416 stores: &std::sync::Arc<ServerStores>,
2417 command: gunnar_common::cli::RegistryCommand,
2418) -> Result<()> {
2419 use gunnar_common::cli::RegistryCommand;
2420
2421 let registry = crate::oci::Registry::under(stores.root());
2422 match command {
2423 RegistryCommand::Push { name, tag, digest } => {
2424 let manifest = registry
2425 .get_blob(&digest)?
2426 .with_context(|| format!("{digest} is not in this registry"))?;
2427 match registry.put_manifest(stores, &name, &tag, &manifest)? {
2428 Ok(landed) => {
2429 println!(
2430 "pushed {}:{} → {}",
2431 landed.name, landed.reference, landed.digest
2432 );
2433 Ok(())
2434 }
2435 Err(refusal) => {
2436 println!("refused {digest}");
2437 println!("{refusal}");
2438 anyhow::bail!("the registry refuses this manifest")
2439 }
2440 }
2441 }
2442 RegistryCommand::Resolve { name, tag } => {
2443 let digest = registry
2444 .resolve(&name, &tag)?
2445 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2446 println!("{digest}");
2447 Ok(())
2448 }
2449 RegistryCommand::Pull { name, tag, line } => {
2450 let digest = registry
2451 .resolve(&name, &tag)?
2452 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2453 let manifest = registry
2457 .get_blob(&digest)?
2458 .with_context(|| format!("{digest} is tagged and its manifest is not stored"))?;
2459 println!("{}", String::from_utf8_lossy(&manifest));
2460 println!();
2461
2462 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(stores));
2466 let chain = crate::verify::verify_artifact(
2467 stores,
2468 &plane,
2469 digest.trim_start_matches("sha256:"),
2470 line.as_deref(),
2471 )?;
2472 println!("{chain}");
2473 if chain.is_unbroken() {
2474 return Ok(());
2475 }
2476 if chain.is_broken() {
2477 anyhow::bail!("the chain behind this image is broken");
2478 }
2479 anyhow::bail!("the chain behind this image is incomplete")
2480 }
2481 }
2482}
2483
2484#[cfg(feature = "engine-znippy")]
2489pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2490 format!(
2491 "note: a repository archive does not carry the account's settings, so {path} is NOT \
2492 encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2493 console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2494 set-encrypted {path} on where the control plane is in reach; or restore the control \
2495 document as well"
2496 )
2497}
2498
2499#[cfg(all(test, feature = "engine-znippy"))]
2500mod tests {
2501 use super::*;
2502
2503 #[test]
2504 fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2505 let path = RepoRef::new("acme", "vault").expect("a legal path");
2506 let note = encrypted_flag_note(&path);
2507 assert!(note.contains("encrypted"), "{note}");
2508 assert!(note.contains("gunnar control repo set-encrypted acme/vault on"), "the one command that sets it again: {note}");
2509 }
2510
2511 #[test]
2515 fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2516 let path = RepoRef::new("acme", "vault").expect("a legal path");
2517 let note = encrypted_flag_note(&path);
2518 assert!(note.contains("console") && note.contains("Turn encryption on"), "the console's switch: {note}");
2519 }
2520}