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 {
132 pem,
133 at,
134 identity: _,
135 } => {
136 if let Some(at) = at {
143 bail!(
144 "`--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"
145 );
146 }
147 print!("{}", issuer_document(&stores.pki_readonly()?)?.render(pem));
155 }
156 IssuerCommand::Trust { label, cert } => {
157 let cert_pem = std::fs::read_to_string(&cert)
158 .with_context(|| format!("reading the CA certificate at {}", cert.display()))?;
159 let label = if label.is_empty() {
160 cert.file_name()
161 .map(|name| name.to_string_lossy().into_owned())
162 .unwrap_or_else(|| "ca".to_owned())
163 } else {
164 label
165 };
166 let added = stores.pki()?.trust_ca(&label, &cert_pem)?;
167 println!("trusting {} ({})", added.label, added.fingerprint);
168 }
169 IssuerCommand::Readiness => {
173 print!("{}", crate::tls_preflight::preflight(stores)?);
174 println!();
175 }
176 IssuerCommand::Untrust { fingerprint } => {
177 let parsed = Fingerprint::parse(&fingerprint)?;
178 match stores.pki()?.untrust_ca(&parsed)? {
179 Some(removed) => println!("no longer trusting {} ({parsed})", removed.label),
180 None => bail!("no trusted CA has fingerprint {parsed}"),
181 }
182 }
183 }
184 Ok(())
185}
186
187fn hand_over(
199 stores: &ServerStores,
200 dir: &std::path::Path,
201 issued: &gunnar_core::Issued,
202) -> Result<()> {
203 std::fs::create_dir_all(dir)
204 .with_context(|| format!("creating the output directory {}", dir.display()))?;
205
206 let cert_path = dir.join(format!("{}_cert.pem", issued.principal_id));
207 std::fs::write(&cert_path, &issued.cert_pem)
208 .with_context(|| format!("writing {}", cert_path.display()))?;
209
210 let registered =
215 stores
216 .auth()
217 .register_certificate(&issued.principal_id, &issued.cert_der, None)?;
218
219 println!(
220 "issued {} -> {}",
221 registered.fingerprint_sha256,
222 cert_path.display()
223 );
224
225 if let Some(key_pem) = &issued.key_pem {
226 let key_path = dir.join(format!("{}_pkey.pem", issued.principal_id));
227 write_private(&key_path, key_pem)?;
228 println!("private key -> {}", key_path.display());
229 eprintln!(
230 "this key was generated HERE and is now on the wire and on this disk. \
231 `--csr` is the form where it never leaves {}'s machine",
232 issued.principal_id
233 );
234 }
235 Ok(())
236}
237
238fn write_private(path: &std::path::Path, contents: &str) -> Result<()> {
241 use std::io::Write as _;
242
243 let mut options = std::fs::OpenOptions::new();
244 options.write(true).create(true).truncate(true);
245 #[cfg(unix)]
246 {
247 use std::os::unix::fs::OpenOptionsExt as _;
248 options.mode(0o600);
249 }
250 if path.exists() {
253 std::fs::remove_file(path)
254 .with_context(|| format!("removing the previous {}", path.display()))?;
255 }
256 let mut file = options
257 .open(path)
258 .with_context(|| format!("creating {}", path.display()))?;
259 file.write_all(contents.as_bytes())
260 .with_context(|| format!("writing {}", path.display()))?;
261 Ok(())
262}
263
264pub fn namespace(stores: &ServerStores, command: NamespaceCommand) -> Result<()> {
268 match command {
269 NamespaceCommand::Create { name } => {
270 let created = stores.catalog().create_namespace(&name)?;
271 println!("created namespace {}", created.name);
272 }
273 NamespaceCommand::List => {
274 let mut names: Vec<String> = stores
275 .catalog()
276 .namespaces()?
277 .into_iter()
278 .map(|ns| ns.name)
279 .collect();
280 names.sort();
281 for name in names {
282 println!("{name}");
283 }
284 }
285 NamespaceCommand::Delete { name, delete_repos } => {
286 let gone = stores.catalog().delete_namespace(&name, delete_repos)?;
287 println!("deleted namespace {name} and {} repositories", gone.len());
288 }
289 NamespaceCommand::SetBudget {
290 name,
291 explode_budget,
292 } => {
293 let Some(spec) = explode_budget else {
294 anyhow::bail!(
295 "`namespace set-budget` needs a budget to set; today that is \
296 `--explode-budget <8G|512M|unlimited|default>`"
297 );
298 };
299 let bytes = match spec.trim().to_ascii_lowercase().as_str() {
300 "default" | "" => None,
301 #[cfg(feature = "engine-znippy")]
302 other => Some(
303 crate::storage::znippy_engine::parse_explode_budget(other)
304 .context("reading the explode budget")?,
305 ),
306 #[cfg(not(feature = "engine-znippy"))]
307 other => anyhow::bail!("an explode budget ({other}) is the znippy engine's; this server was built without it"),
308 };
309 let ns = stores
310 .catalog()
311 .set_namespace_explode_budget(&name, bytes)?;
312 for repo in stores.catalog().list(Some(&name))? {
316 if let Ok(dir) = stores.repo_dir(&repo.path()) {
317 stores.apply_explode_quota(&name, &dir);
318 }
319 }
320 match ns.explode_budget_bytes {
321 Some(0) => println!("{name}: explode budget unlimited"),
322 #[cfg(feature = "engine-znippy")]
323 Some(b) => println!(
324 "{name}: explode budget {}",
325 crate::storage::znippy_engine::human_bytes(b)
326 ),
327 #[cfg(not(feature = "engine-znippy"))]
328 Some(b) => println!("{name}: explode budget {b} bytes"),
329 None => println!("{name}: explode budget back to the server default"),
330 }
331 }
332 }
333 Ok(())
334}
335
336pub fn repo(stores: &ServerStores, command: RepoCommand) -> Result<()> {
340 match command {
341 RepoCommand::Create {
342 path,
343 hash,
344 default_branch,
345 public,
346 description,
347 adopt,
348 } => {
349 let path = repo_ref(&path)?;
350 let mut spec = NewRepo::new(path.clone())
351 .hash_kind(hash.into())
352 .visibility(if public {
353 Visibility::Public
354 } else {
355 Visibility::Private
356 });
357 spec.description = description;
358 if let Some(branch) = default_branch {
359 spec = spec.default_branch(full_branch(&branch));
360 }
361 let created = stores.create_repo(spec, adopt)?;
367 let store = stores.repo_store(&created)?;
368 println!(
369 "created {} ({}, default branch {}) at {}",
370 created.key(),
371 created.hash_kind,
372 created.default_branch,
373 store.git_dir().display()
374 );
375 }
376 RepoCommand::List { namespace } => {
377 let mut repos = stores.catalog().list(namespace.as_deref())?;
378 repos.sort_by_key(|repo| repo.key());
379 for repo in repos {
380 println!(
381 "{}\t{}\t{}\t{:?}",
382 repo.key(),
383 repo.hash_kind,
384 repo.default_branch,
385 repo.visibility
386 );
387 }
388 }
389 RepoCommand::Delete {
390 path,
391 purge,
392 retention_days,
393 } => {
394 let path = repo_ref(&path)?;
395 let orphan = stores.is_orphan(&path)?;
402 anyhow::ensure!(
403 purge || !orphan,
404 "{}",
405 crate::stores::ServerStores::orphan_soft_delete_refusal(&path)
406 );
407 let removed = match stores.catalog().delete(&path) {
411 Ok(repo) => Some(repo),
412 Err(gunnar_core::Error::NoSuchRepo(_)) if orphan => None,
413 Err(err) => return Err(err.into()),
414 };
415 stores.close_repo(&path);
418 if purge {
419 let dir = stores.repo_dir(&path)?;
420 let (before, after) = stores.purge_repo(&path)?;
421 println!(
422 "deleted {}{} and purged {} ({} bytes reclaimed)",
423 path,
424 if removed.is_none() {
425 " (an ORPHAN: it had no catalog row)"
426 } else {
427 ""
428 },
429 dir.display(),
430 before.saturating_sub(after)
431 );
432 } else {
433 let removed = removed.expect("a soft delete of an orphan is refused above");
434 let days = retention_days.unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS);
435 let record = stores.soft_delete(
436 &path,
437 Some(&removed),
438 "",
439 std::time::Duration::from_secs(u64::from(days) * 86_400),
440 )?;
441 let bytes = crate::stores::dir_bytes(&stores.repo_dir(&path)?);
442 println!(
443 "deleted {} — its {bytes} bytes are KEPT for {days} day(s)",
444 removed.key()
445 );
446 println!(
447 " undelete: gunnar repo undelete {} purge now: gunnar repo purge {}",
448 removed.key(),
449 removed.key()
450 );
451 if let Some(at) = record.purge_after_unix_ms {
452 println!(" the sweep may reclaim them after unix_ms {at}");
453 }
454 println!(" until then they still count against the account's storage.");
455 }
456 }
457 RepoCommand::Undelete { path } => {
458 let path = repo_ref(&path)?;
459 let spec = stores.undelete_spec(&path)?;
460 let repo = stores.create_repo(spec, true)?;
464 stores.clear_tombstone(&path)?;
465 println!(
466 "undeleted {} ({}, default branch {})",
467 repo.key(),
468 repo.hash_kind,
469 repo.default_branch
470 );
471 }
472 RepoCommand::Purge { path } => {
473 let path = repo_ref(&path)?;
474 anyhow::ensure!(
475 stores.catalog().get(&path)?.is_none(),
476 "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
477 deletes and reclaims it in one step; purge alone only ends a retention window."
478 );
479 anyhow::ensure!(
480 stores.tombstone_of(&path)?.is_some(),
481 "{path} is neither catalogued nor tombstoned; there is nothing to purge"
482 );
483 let (before, after) = stores.purge_repo(&path)?;
484 println!(
485 "purged {path} ({} bytes reclaimed); it can no longer be undeleted",
486 before.saturating_sub(after)
487 );
488 }
489 RepoCommand::ListDeleted {} => {
490 let rows = stores.tombstones()?;
491 if rows.is_empty() {
492 println!("nothing is deleted");
493 }
494 let now = gunnar_core::now_ms();
495 let mut total = 0u64;
496 for row in &rows {
497 total += row.bytes;
498 let left = match row.tombstone.seconds_left(now) {
499 Some(secs) => format!("{} day(s) left", secs / 86_400),
500 None => "no expiry".to_owned(),
501 };
502 println!("{}\t{} bytes\t{left}", row.path, row.bytes);
503 }
504 if !rows.is_empty() {
505 println!(
506 "{} deleted repository(ies) holding {total} bytes — still on the account",
507 rows.len()
508 );
509 }
510 }
511 RepoCommand::ListOrphans { namespace } => {
512 let rows = stores.orphans()?;
513 let rows: Vec<_> = rows
514 .into_iter()
515 .filter(|row| {
516 namespace
517 .as_deref()
518 .is_none_or(|want| row.path.namespace() == want)
519 })
520 .collect();
521 if rows.is_empty() {
522 println!(
523 "no orphan stores: every directory under the repository root is either \
524 catalogued (`repo list`) or soft-deleted (`repo list-deleted`)"
525 );
526 }
527 let mut total = 0u64;
528 for row in &rows {
529 total += row.bytes;
530 println!(
531 "{}\t{} bytes\t{}\tmtime {}\t{}{}",
532 row.path,
533 row.bytes,
534 row.hash_marker.as_deref().unwrap_or("-"),
535 row.modified_unix_ms,
536 row.dir.display(),
537 match &row.failed_reclaim {
538 Some(note) => format!("\tRECLAIM FAILED: {note}"),
539 None => String::new(),
540 }
541 );
542 }
543 if !rows.is_empty() {
544 println!(
545 "{} orphan store(s) holding {total} bytes — no catalog row, no tombstone",
546 rows.len()
547 );
548 println!(
549 " reclaim the bytes: gunnar repo reclaim <path> keep them: gunnar repo \
550 create <path> --adopt --hash <the HASH column>"
551 );
552 }
553 }
554 RepoCommand::Reclaim { path } => {
555 let path = repo_ref(&path)?;
556 let bytes = stores.reclaim_orphan(&path)?;
557 println!("reclaimed {path} ({bytes} bytes); the name is usable again");
558 }
559 #[cfg(not(feature = "engine-znippy"))]
560 RepoCommand::ArchiveOut { .. } | RepoCommand::ArchiveIn { .. } => {
561 anyhow::bail!(
562 "repository archives are the znippy engine's; this server was built without it"
563 )
564 }
565 #[cfg(feature = "engine-znippy")]
566 RepoCommand::ArchiveOut { .. } => {
567 unreachable!("archive-out is handled by repo_archive_out_without_stores")
571 }
572 #[cfg(feature = "engine-znippy")]
573 RepoCommand::ArchiveIn {
574 file,
575 as_path,
576 no_catalog,
577 } => {
578 let path = repo_ref(&as_path)?;
579 let dir = stores.repo_dir(&path)?;
580 let r = crate::storage::znippy_engine::archive_in(&file, &dir)?;
581 println!(
582 "restored {} bytes into {} (generation {})",
583 r.bytes_in,
584 r.repo_dir.display(),
585 r.to_generation
586 );
587 if !no_catalog {
588 let marker =
593 std::fs::read_to_string(dir.join(crate::storage::znippy_engine::OBJECT_FORMAT))
594 .unwrap_or_default();
595 let hash = match marker.trim() {
596 "sha1" => gunnar_core::HashKind::Sha1,
597 "sha256" | "" => gunnar_core::HashKind::Sha256,
598 other => bail!("the archive names an unknown object format {other:?}"),
599 };
600 let spec = NewRepo::new(path.clone()).hash_kind(hash);
601 match stores.create_repo(spec, true) {
613 Ok(created) => {
614 println!(
615 "catalogued {} ({}, default branch {})",
616 created.key(),
617 created.hash_kind,
618 created.default_branch
619 );
620 println!("{}", encrypted_flag_note(&path));
621 }
622 Err(e) if format!("{e}").contains("already exists") => {
623 match stores.catalog().get(&path)? {
624 Some(have) => println!(
625 "the catalog already has {} ({}, default branch {}) — restored \
626 from the control document, and left alone",
627 have.key(),
628 have.hash_kind,
629 have.default_branch
630 ),
631 None => println!("the catalog already has {path}"),
632 }
633 }
634 Err(e) => return Err(e),
635 }
636 }
637 }
638 }
639 Ok(())
640}
641
642#[cfg(feature = "engine-znippy")]
644fn hex_head(d: &[u8; 32]) -> String {
645 d[..8].iter().map(|b| format!("{b:02x}")).collect()
646}
647
648#[cfg(feature = "engine-znippy")]
657pub fn repo_archive_out_without_stores(
658 data_root: &std::path::Path,
659 command: RepoCommand,
660) -> Result<()> {
661 let RepoCommand::ArchiveOut {
662 path,
663 to,
664 from_generation,
665 } = command
666 else {
667 unreachable!("called with something other than archive-out")
668 };
669 let path = repo_ref(&path)?;
670 let dir = path
671 .path_under(&data_root.join("repos"))
672 .with_context(|| format!("resolving the directory for {path}"))?;
673 if !dir.is_dir() {
674 bail!("{path} has nothing on disk at {}", dir.display());
675 }
676 let r = crate::storage::znippy_engine::archive_out(&dir, &to, from_generation)?;
677 for s in &r.segments {
678 println!(
679 " {:<44} {:>12} bytes from {}",
680 s.name, s.len, s.from_offset
681 );
682 }
683 println!(
684 "archived {path} -> {} {} bytes, generation {} -> {} sha256 {}",
685 to.display(),
686 r.bytes_out,
687 r.from_generation,
688 r.to_generation,
689 hex_head(&r.file_sha256)
690 );
691 println!("next incremental: --from-generation {}", r.to_generation);
692 Ok(())
693}
694
695fn full_branch(name: &str) -> String {
697 if name.starts_with("refs/") {
698 name.to_owned()
699 } else {
700 format!("refs/heads/{name}")
701 }
702}
703
704pub fn principal(stores: &ServerStores, command: PrincipalCommand) -> Result<()> {
708 match command {
709 PrincipalCommand::Add {
710 id,
711 name,
712 kind,
713 csr,
714 server_generated_key,
715 out,
716 days,
717 } => {
718 let display = name.unwrap_or_else(|| id.clone());
719 let created = stores.auth().add_principal(gunnar_core::Principal::new(
720 &id,
721 display,
722 kind.into(),
723 )?)?;
724 println!("added principal {} ({:?})", created.id, created.kind);
725
726 match (csr, server_generated_key) {
731 (Some(path), _) => {
732 let request = std::fs::read_to_string(&path).with_context(|| {
733 format!("reading the signing request at {}", path.display())
734 })?;
735 if let Some(claimed) = gunnar_core::issuer::csr_claimed_common_name(&request)? {
736 if claimed != created.id {
737 println!(
742 "note: the request claims CN={claimed}; it is being issued as \
743 {} — a request does not choose its own name",
744 created.id
745 );
746 }
747 }
748 let issued = stores.pki()?.issue_from_csr(&created.id, &request, days)?;
749 hand_over(stores, &out, &issued)?;
750 }
751 (None, true) => {
752 let issued = stores.pki()?.issue_with_server_key(&created.id, days)?;
753 hand_over(stores, &out, &issued)?;
754 }
755 (None, false) => {
756 if stores
757 .control_store()
758 .read_doc(gunnar_core::ISSUER_REF)?
759 .is_some()
760 {
761 println!(
762 "no certificate was issued. `--csr <file>` signs a request whose \
763 key never leaves {}'s machine; `--server-generated-key` mints one \
764 here instead",
765 created.id
766 );
767 }
768 }
769 }
770 }
771 PrincipalCommand::List => {
772 let mut principals = stores.auth().principals()?;
773 principals.sort_by(|a, b| a.id.cmp(&b.id));
774 for principal in principals {
775 println!("{}\t{}\t{:?}", principal.id, principal.name, principal.kind);
776 }
777 }
778 PrincipalCommand::Remove { id } => {
779 let removed = stores.auth().remove_principal(&id)?;
780 println!("removed principal {}", removed.id);
781 }
782 }
783 Ok(())
784}
785
786pub fn key(stores: &ServerStores, command: KeyCommand) -> Result<()> {
788 match command {
789 KeyCommand::Add {
790 principal,
791 key,
792 cert,
793 } => {
794 let registered = match (key, cert) {
795 (_, Some(path)) => {
796 let der = read_certificate(&path)?;
797 stores.auth().register_certificate(&principal, &der, None)?
798 }
799 (Some(key), None) => {
800 let line = key_argument(&key)?;
801 stores.auth().register_key(&principal, &line, None)?
802 }
803 (None, None) => bail!("give a public key or --cert"),
804 };
805 println!(
806 "registered {} ({}) to {}",
807 registered.fingerprint_sha256, registered.algo, registered.principal_id
808 );
809 }
810 KeyCommand::List { principal } => {
811 let mut keys = match principal {
812 Some(id) => stores.auth().keys_of(&id)?,
813 None => stores.auth().keys()?,
814 };
815 keys.sort_by(|a, b| a.fingerprint_sha256.cmp(&b.fingerprint_sha256));
816 for key in keys {
817 println!(
818 "{}\t{}\t{}",
819 key.fingerprint_sha256, key.algo, key.principal_id
820 );
821 }
822 }
823 KeyCommand::Revoke { fingerprint } => {
824 let parsed = Fingerprint::parse(&fingerprint)?;
825 match stores.auth().revoke_key(&parsed)? {
826 Some(key) => println!("revoked {} from {}", parsed, key.principal_id),
827 None => bail!("no key with fingerprint {parsed} is registered"),
828 }
829 }
830 }
831 Ok(())
832}
833
834pub fn token(stores: &ServerStores, command: gunnar_common::cli::TokenCommand) -> Result<()> {
843 use gunnar_common::cli::TokenCommand;
844
845 match command {
846 TokenCommand::Add {
847 principal,
848 label,
849 expires_in,
850 } => {
851 let secret = gunnar_core::mint_token_secret();
852 let expires =
853 expires_in.map(|days| gunnar_core::now_ms() + (days as i64) * 24 * 60 * 60 * 1000);
854 let registered = stores
855 .auth()
856 .register_token(&principal, &secret, &label, expires)?;
857 println!("{secret}");
861 eprintln!(
862 "minted for {} (hash {}); this is the only time the token is printed",
863 registered.principal_id, registered.hash
864 );
865 }
866 TokenCommand::List { principal } => {
867 let mut tokens = stores.auth().tokens()?;
868 if let Some(id) = &principal {
869 tokens.retain(|token| &token.principal_id == id);
870 }
871 tokens.sort_by(|a, b| a.hash.cmp(&b.hash));
872 for token in tokens {
873 println!("{}\t{}\t{}", token.hash, token.principal_id, token.label);
874 }
875 }
876 TokenCommand::Revoke { hash } => match stores.auth().revoke_token(&hash)? {
877 Some(token) => println!("revoked {} from {}", token.hash, token.principal_id),
878 None => bail!("no token with hash {hash} is registered"),
879 },
880 }
881 Ok(())
882}
883
884fn scope_of(server: bool, namespace: Option<&str>, repo: Option<&str>) -> Result<Scope> {
887 match (server, namespace, repo) {
888 (true, _, _) => Ok(Scope::Server),
889 (_, Some(name), _) => Ok(Scope::Namespace(name.to_owned())),
890 (_, _, Some(path)) => Ok(Scope::Repo(repo_ref(path)?)),
891 _ => bail!("a grant needs a scope: --server, --namespace <ns>, or --repo <ns>/<name>"),
892 }
893}
894
895pub fn grant(stores: &ServerStores, args: GrantArgs) -> Result<()> {
897 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
898 let granted = stores.auth().grant(Grant {
899 principal: args.principal.clone(),
900 scope,
901 level: args.level.into(),
902 })?;
903 println!(
904 "{} now holds {} on {}",
905 granted.principal, granted.level, granted.scope
906 );
907 if let Scope::Namespace(name) = &granted.scope {
911 if name.contains('/')
912 || stores
913 .catalog()
914 .namespaces()?
915 .iter()
916 .any(|ns| ns.name.starts_with(&format!("{name}/")))
917 {
918 println!(
919 "note: namespace grants match EXACTLY; this does not reach nested namespaces \
920 under {name}/"
921 );
922 }
923 }
924 Ok(())
925}
926
927pub fn revoke(stores: &ServerStores, args: RevokeArgs) -> Result<()> {
929 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
930 if stores.auth().revoke_grant(&args.principal, &scope)? {
931 println!("revoked {}'s access to {scope}", args.principal);
932 Ok(())
933 } else {
934 bail!("{} holds no grant on {scope}", args.principal)
935 }
936}
937
938pub fn gc(stores: &ServerStores, args: GcArgs) -> Result<()> {
978 let repos = match &args.path {
979 Some(path) => {
980 let path = repo_ref(path)?;
981 vec![stores.catalog().open(&path)?]
982 }
983 None => stores.catalog().list(None)?,
984 };
985
986 let _ = &args.prune;
1000 let _ = &args.prune_grace;
1001 let mut done = 0usize;
1002 let mut failed = 0usize;
1003 let mut reclaimed: i64 = 0;
1004 for repo in &repos {
1005 match gc_one(stores, repo) {
1006 Ok(report) => {
1007 done += 1;
1008 reclaimed += report.bytes_before as i64 - report.bytes_after as i64;
1009 println!(
1010 "{repo}\t{} -> {} bytes, {} live rows, {} pack(s) retired, {} section(s) \
1011 sealed [{}]",
1012 report.bytes_before,
1013 report.bytes_after,
1014 report.rows,
1015 report.retired_packs,
1016 report.sections_sealed,
1017 report.strategy,
1018 );
1019 }
1020 Err(err) => {
1021 failed += 1;
1022 eprintln!("{repo}\tgc failed: {err:#}");
1023 }
1024 }
1025 }
1026 println!(
1027 "{done} of {} repository(ies) maintained, {reclaimed} bytes reclaimed",
1028 repos.len()
1029 );
1030 if failed > 0 {
1031 bail!("{failed} repository(ies) could not be maintained; see above");
1032 }
1033 Ok(())
1034}
1035
1036pub fn gc_one(stores: &ServerStores, repo: &Repo) -> Result<MaintenanceReport> {
1054 stores.wait_unpinned("a gc");
1057 let span = tracing::info_span!(target: TARGET_STORE, "store.maintain", repo = %repo);
1058 let _entered = span.enter();
1059 let started = std::time::Instant::now();
1060 let store = stores.repo_store(repo)?;
1061 match stores.engine().maintain(store.git_dir(), store.hash_kind()) {
1062 Ok(report) => {
1063 tracing::info!(
1064 target: TARGET_STORE,
1065 event = "store.maintained",
1066 strategy = %report.strategy,
1067 bytes_before = report.bytes_before,
1068 bytes_after = report.bytes_after,
1069 rows = report.rows,
1070 retired_packs = report.retired_packs,
1071 verified = report.verified,
1072 sections_sealed = report.sections_sealed,
1073 elapsed_ms = started.elapsed().as_millis() as u64,
1074 "the store was sealed and compacted"
1075 );
1076 Ok(report)
1077 }
1078 Err(err) => {
1079 tracing::error!(
1080 target: TARGET_STORE,
1081 event = "store.maintain_failed",
1082 %err,
1083 "maintaining the store failed"
1084 );
1085 Err(err)
1086 }
1087 }
1088}
1089
1090pub fn reclaimed_bytes(report: &MaintenanceReport) -> u64 {
1099 report.bytes_before.saturating_sub(report.bytes_after)
1100}
1101
1102pub fn reflog(data_root: &std::path::Path, args: ReflogArgs) -> Result<()> {
1189 let repos_root = data_root.join(crate::stores::REPOS_DIR);
1190 let repos: Vec<(String, std::path::PathBuf)> = match &args.path {
1191 Some(path) => {
1192 let path = repo_ref(path)?;
1193 let dir = path
1194 .path_under(&repos_root)
1195 .with_context(|| format!("resolving the directory for {path}"))?;
1196 if !dir.join("HEAD").exists() {
1197 bail!("there is no repository at {}", dir.display());
1198 }
1199 vec![(path.key(), dir)]
1200 }
1201 None => bare_repositories(&repos_root)?,
1202 };
1203 if repos.is_empty() {
1204 println!("no repositories under {}", repos_root.display());
1205 return Ok(());
1206 }
1207 let seal_is_a_directory = args.seal.is_some() && repos.len() > 1;
1210
1211 let mut read = 0usize;
1212 let mut failed = 0usize;
1213 let mut refused = 0usize;
1214 for (key, git_dir) in &repos {
1215 let store_dir = git_dir.join(LEGACY_STORE_DIR);
1216 if !legacy_reflog_present(&store_dir) {
1220 refused += 1;
1221 eprintln!(
1222 "{key}\tREFUSED: no reference history this verb can read — nothing at {}",
1223 store_dir.display()
1224 );
1225 continue;
1226 }
1227 let hash_kind = match stored_hash_kind(git_dir) {
1228 Ok(kind) => kind,
1229 Err(err) => {
1230 failed += 1;
1231 eprintln!("{key}\tFAILED: {err:#}");
1232 continue;
1233 }
1234 };
1235
1236 let history = match gunnar_core::History::read(&store_dir, hash_kind) {
1237 Ok(history) => history,
1238 Err(err) => {
1239 failed += 1;
1240 eprintln!("{key}\tFAILED: {err}");
1241 continue;
1242 }
1243 };
1244 read += 1;
1245
1246 match &args.seal {
1247 Some(target) => {
1248 let path = if seal_is_a_directory {
1249 target.join(format!("{}.arrow", key.replace('/', "_")))
1250 } else {
1251 target.clone()
1252 };
1253 let report = history.seal(&path)?;
1254 println!(
1255 "{key}\t{} pushes ({} archived, {} live)\t{} rows\t{} B\t{:.1} B/push\t{:.2}x smaller than the {} B it read\t{}",
1256 report.pushes,
1257 history.archived_pushes(),
1258 history.live_pushes(),
1259 report.rows,
1260 report.bytes,
1261 report.bytes_per_push(),
1262 report.shrink_x(),
1263 report.bytes_in,
1264 path.display(),
1265 );
1266 }
1267 None => {
1268 let transactions = history.transactions()?;
1269 println!(
1277 "{key}\t{} pushes ({} from the archive, {} live) in {} segment(s)\t{} rows\t{} B read",
1278 history.pushes(),
1279 history.archived_pushes(),
1280 history.live_pushes(),
1281 history.segments_read(),
1282 transactions.len(),
1283 history.bytes_in(),
1284 );
1285 let show = if args.all {
1286 transactions.as_slice()
1287 } else {
1288 let from = transactions.len().saturating_sub(args.limit);
1289 &transactions[from..]
1290 };
1291 if show.len() < transactions.len() {
1292 println!(
1293 " … {} older transaction(s) not shown; --all for every one",
1294 transactions.len() - show.len()
1295 );
1296 }
1297 for entry in show {
1298 println!(
1299 " {:>6} {} {} -> {}{}",
1300 entry.push_seq,
1301 entry.refname,
1302 value(entry.old_oid.as_ref(), entry.old_symref.as_deref()),
1303 value(entry.new_oid.as_ref(), entry.new_symref.as_deref()),
1304 if entry.is_snapshot() {
1305 " (compaction snapshot, not a move)"
1306 } else {
1307 ""
1308 },
1309 );
1310 }
1311 }
1312 }
1313 }
1314
1315 if refused > 0 {
1319 eprintln!("\n{}", refusal_explanation());
1320 }
1321 if failed > 0 {
1322 bail!(
1323 "{failed} of {} repositor{} could not be read; {read} were",
1324 repos.len(),
1325 if repos.len() == 1 { "y" } else { "ies" }
1326 );
1327 }
1328 if read == 0 {
1332 bail!(
1333 "no reference history was read from any of the {} repositor{} looked at",
1334 repos.len(),
1335 if repos.len() == 1 { "y" } else { "ies" }
1336 );
1337 }
1338 Ok(())
1339}
1340
1341const LEGACY_STORE_DIR: &str = "gunnar-store";
1349
1350fn legacy_reflog_present(store_dir: &std::path::Path) -> bool {
1360 store_dir.join(gunnar_core::arrow_log::REFS_LOG).is_file()
1361 || store_dir
1362 .join(gunnar_core::arrow_log::REFS_ARCHIVE_DIR)
1363 .is_dir()
1364}
1365
1366fn refusal_explanation() -> String {
1372 let engine = match crate::storage::Engine::selected() {
1373 Ok(engine) => engine.name(),
1374 Err(_) => "<unselectable>",
1378 };
1379 format!(
1380 "`reflog` reads the retired filesystem backend's Arrow reflog —\n\
1381 `<git-dir>/{LEGACY_STORE_DIR}/{log}` and `{archive}/` beside it. That backend went with\n\
1382 the packfile engine (the storage restart) and nothing writes those bytes any more, so\n\
1383 a repository created since has none and this verb has nothing to read.\n\
1384 \n\
1385 This process runs the `{engine}` engine. Where reference history is now:\n\
1386 \n\
1387 * znippy — one Arrow IPC frame per push in the store's own `refs.log`. It is real and it\n\
1388 \x20 is complete, but `GitStore::ref_log()` is `pub(crate)`, and its rows carry no actor\n\
1389 \x20 and no previous value, so this verb's output is not merely re-pointable at it.\n\
1390 * gix — none at all. `storage-git-gix` opens its ref store with `WriteReflog::Disable`,\n\
1391 \x20 deliberately: a git server has no use for a per-ref reflog file tree.\n\
1392 \n\
1393 Neither `GitOps` nor `GitServe` carries a ref-history accessor, and the `GitServe`\n\
1394 reading contract's ordering rule says one does not belong on the contract. Making this\n\
1395 verb answer again is an engine-shaped seam plus a decision about what it should\n\
1396 report — see `agentO-report.md`.",
1397 log = gunnar_core::arrow_log::REFS_LOG,
1398 archive = gunnar_core::arrow_log::REFS_ARCHIVE_DIR,
1399 )
1400}
1401
1402fn stored_hash_kind(git_dir: &std::path::Path) -> Result<gix_hash::Kind> {
1420 const OBJECT_FORMAT_KEY: &str = "objectformat";
1421
1422 let config_path = git_dir.join("config");
1423 let text = match std::fs::read_to_string(&config_path) {
1424 Ok(text) => text,
1425 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(gix_hash::Kind::Sha1),
1428 Err(err) => {
1429 return Err(anyhow::Error::from(err))
1430 .with_context(|| format!("reading {}", config_path.display()))
1431 }
1432 };
1433
1434 let mut in_extensions = false;
1435 for line in text.lines() {
1436 let line = line.trim();
1437 if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1438 in_extensions = header.trim().eq_ignore_ascii_case("extensions");
1439 continue;
1440 }
1441 if !in_extensions {
1442 continue;
1443 }
1444 let Some((key, value)) = line.split_once('=') else {
1445 continue;
1446 };
1447 if !key.trim().eq_ignore_ascii_case(OBJECT_FORMAT_KEY) {
1449 continue;
1450 }
1451 return match value.trim() {
1452 "sha256" => Ok(gix_hash::Kind::Sha256),
1453 "sha1" => Ok(gix_hash::Kind::Sha1),
1454 other => bail!(
1455 "{} declares an object format gunnar does not know: {other:?}",
1456 config_path.display()
1457 ),
1458 };
1459 }
1460 Ok(gix_hash::Kind::Sha1)
1461}
1462
1463fn bare_repositories(repos_root: &std::path::Path) -> Result<Vec<(String, std::path::PathBuf)>> {
1470 fn walk(
1471 dir: &std::path::Path,
1472 prefix: &str,
1473 out: &mut Vec<(String, std::path::PathBuf)>,
1474 ) -> Result<()> {
1475 let entries = match std::fs::read_dir(dir) {
1476 Ok(entries) => entries,
1477 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1478 Err(err) => return Err(err.into()),
1479 };
1480 for entry in entries {
1481 let entry = entry?;
1482 if !entry.file_type()?.is_dir() {
1483 continue;
1484 }
1485 let name = entry.file_name().to_string_lossy().into_owned();
1486 let path = entry.path();
1487 if let Some(stem) = name.strip_suffix(".git") {
1488 if path.join("HEAD").exists() {
1489 out.push((format!("{prefix}{stem}"), path));
1490 continue;
1491 }
1492 }
1493 walk(&path, &format!("{prefix}{name}/"), out)?;
1496 }
1497 Ok(())
1498 }
1499 let mut out = Vec::new();
1500 walk(repos_root, "", &mut out)?;
1501 out.sort();
1502 Ok(out)
1503}
1504
1505fn value(oid: Option<&gunnar_store::gix_hash::ObjectId>, symref: Option<&str>) -> String {
1510 match (oid, symref) {
1511 (Some(id), _) => id.to_string(),
1512 (None, Some(name)) => format!("ref: {name}"),
1513 (None, None) => "-".to_owned(),
1514 }
1515}
1516
1517pub fn attest(stores: &ServerStores, command: gunnar_common::cli::AttestCommand) -> Result<()> {
1523 use gunnar_common::cli::AttestCommand;
1524
1525 let log = stores.transparency();
1526 match command {
1527 AttestCommand::Head { builds } => {
1528 let log = if builds { stores.build_log() } else { log };
1529 let key = stores.transparency_key()?;
1530 let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1531 println!("size {}", signed.head.size);
1532 println!("root {}", signed.head.root);
1533 println!("signed by {}", signed.key_fingerprint);
1534 println!("appended {}", log.heads_path().display());
1535 if signed.head.size == 0 {
1536 println!();
1541 println!(
1542 "This head covers an EMPTY log: it says this server had accepted no push \
1543 when it was signed."
1544 );
1545 }
1546 Ok(())
1547 }
1548 AttestCommand::Audit { builds } => {
1549 let log = if builds { stores.build_log() } else { log };
1550 let key = stores.transparency_key()?;
1551 let verdict = log.audit(key.public_key())?;
1552 println!("{verdict}");
1553 if verdict.is_consistent() {
1554 return Ok(());
1555 }
1556 anyhow::bail!("the transparency log did not audit clean")
1560 }
1561 AttestCommand::Key => {
1562 let key = stores.transparency_key()?;
1563 println!(
1564 "{}",
1565 key.public_key()
1566 .to_openssh()
1567 .context("rendering the transparency log's public key")?
1568 );
1569 Ok(())
1570 }
1571 AttestCommand::Prove { pack_hash } => {
1572 let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1573 anyhow::bail!(
1574 "no attestation in this log names pack {pack_hash}. A proof for a record \
1575 the log does not hold is not an empty proof."
1576 )
1577 };
1578 println!("index {}", inclusion.index);
1579 println!("size {}", inclusion.size);
1580 println!("leaf {}", inclusion.leaf);
1581 println!("root {}", inclusion.root);
1582 for (step, hash) in inclusion.proof.iter().enumerate() {
1583 println!("proof[{step}] {hash}");
1584 }
1585 Ok(())
1586 }
1587 }
1588}
1589
1590pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1597 let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1598 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1599 let chain = match (&args.artifact, &args.repo) {
1600 (Some(sha256), _) => {
1601 crate::verify::verify_artifact(&stores, &plane, sha256, args.line.as_deref())?
1602 }
1603 (None, Some(repo)) => {
1604 let (namespace, name) = repo.split_once('/').with_context(|| {
1605 format!(
1606 "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1607 namespace (multi-tenancy)"
1608 )
1609 })?;
1610 crate::verify::verify(
1611 &stores,
1612 &plane,
1613 &gunnar_proto::RepoPath::new(namespace, name),
1614 &args.at,
1615 args.line.as_deref(),
1616 )?
1617 }
1618 (None, None) => anyhow::bail!("name a repository, or an --artifact to start from"),
1621 };
1622 println!("{chain}");
1623 if chain.is_unbroken() {
1624 return Ok(());
1625 }
1626 if chain.is_broken() {
1627 anyhow::bail!("the chain is broken");
1628 }
1629 anyhow::bail!("the chain is incomplete")
1630}
1631
1632pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1637 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1638 format!(
1639 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1640 namespace (multi-tenancy)",
1641 args.repo
1642 )
1643 })?;
1644 let rows = crate::blame::blame_at(
1645 data_root,
1646 &gunnar_proto::RepoPath::new(namespace, name),
1647 &args.rev,
1648 &args.path,
1649 )?;
1650 for row in &rows {
1653 let hex = row.commit.to_hex().to_string();
1654 let short = &hex[..hex.len().min(12)];
1655 println!(
1656 "{short} {}\t{}",
1657 row.author,
1658 String::from_utf8_lossy(&row.line)
1659 );
1660 }
1661 Ok(())
1662}
1663
1664pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1667 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1668 format!(
1669 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1670 namespace (multi-tenancy)",
1671 args.repo
1672 )
1673 })?;
1674 let limits = crate::search::Limits {
1675 max_file_bytes: args.max_file_bytes,
1676 max_matches: args.max_matches,
1677 };
1678 let hits = crate::search::search_at(
1679 data_root,
1680 &gunnar_proto::RepoPath::new(namespace, name),
1681 &args.rev,
1682 &args.pattern,
1683 limits,
1684 )?;
1685 for hit in &hits {
1686 println!(
1687 "{}:{}:{}",
1688 hit.path,
1689 hit.line_number,
1690 String::from_utf8_lossy(&hit.line)
1691 );
1692 }
1693 Ok(())
1694}
1695
1696pub fn build(stores: &ServerStores, command: gunnar_common::cli::BuildCommand) -> Result<()> {
1698 use gunnar_common::cli::BuildCommand;
1699
1700 let log = stores.build_log();
1701 match command {
1702 BuildCommand::Attest {
1703 repo,
1704 commit,
1705 recipe,
1706 toolchain,
1707 builder,
1708 network,
1709 materials,
1710 artifacts,
1711 } => {
1712 let subject = |path: &std::path::Path| -> Result<gunnar_core::Subject> {
1716 Ok(gunnar_core::Subject {
1717 name: path
1718 .file_name()
1719 .map(|n| n.to_string_lossy().into_owned())
1720 .unwrap_or_else(|| path.display().to_string()),
1721 sha256: gunnar_core::provenance::digest_file(path)
1722 .with_context(|| format!("hashing {}", path.display()))?,
1723 })
1724 };
1725 let now = gunnar_grpc::bus::now_unix_ms();
1726 let record = gunnar_core::BuildRecord {
1727 builder,
1728 source_repo: repo,
1729 source_commit: commit,
1730 recipe,
1731 toolchain,
1732 network,
1733 materials: materials
1734 .iter()
1735 .map(|p| subject(p))
1736 .collect::<Result<_>>()?,
1737 subjects: artifacts
1738 .iter()
1739 .map(|p| subject(p))
1740 .collect::<Result<_>>()?,
1741 started_at_unix_ms: now,
1742 finished_at_unix_ms: now,
1743 };
1744 let key = stores.provenance_key()?;
1745 let signed = gunnar_core::provenance::sign_build(&key, record)
1746 .context("signing the build record")?;
1747 let mut line = serde_json::to_string(&signed)?;
1748 line.push('\n');
1749 let _framed = gunnar_core::txlog::sink::append_line(
1750 log.records_path(),
1751 line.trim_end_matches('\n'),
1752 )?;
1753 use std::io::Write as _;
1754 let mut file = std::fs::OpenOptions::new()
1755 .create(true)
1756 .append(true)
1757 .open(log.records_path())?;
1758 file.write_all(line.as_bytes())?;
1759
1760 println!("recorded {}", signed.record.digest());
1761 println!("builder {}", signed.record.builder);
1762 println!(
1763 "source {}@{}",
1764 signed.record.source_repo, signed.record.source_commit
1765 );
1766 println!("network {}", signed.record.network);
1767 for subject in &signed.record.subjects {
1768 println!("produced {} {}", subject.sha256, subject.name);
1769 }
1770 println!("signed by {}", signed.key_fingerprint);
1771 println!();
1772 println!(
1773 "No signed head covers it yet. `gunnar attest head --builds` is what turns this \
1774 record into evidence somebody else can check."
1775 );
1776 Ok(())
1777 }
1778 BuildCommand::Sbom { lock, artifact } => {
1779 let text = std::fs::read_to_string(&lock)
1780 .with_context(|| format!("reading {}", lock.display()))?;
1781 let packages = gunnar_core::sbom::parse_lock(&text);
1782 anyhow::ensure!(
1783 !packages.is_empty(),
1784 "{} yielded no packages. An empty SBOM is not a small one — it is a document \
1785 that says this artifact contains nothing, and that is never true.",
1786 lock.display()
1787 );
1788 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1789 .with_context(|| format!("hashing {}", artifact.display()))?;
1790 let name = artifact
1791 .file_name()
1792 .map(|n| n.to_string_lossy().into_owned())
1793 .unwrap_or_else(|| artifact.display().to_string());
1794 print!(
1798 "{}",
1799 gunnar_core::sbom::cyclonedx(&packages, &name, &sha256, &sha256)
1800 );
1801 Ok(())
1802 }
1803 BuildCommand::Admit { artifact } => {
1804 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1805 .with_context(|| format!("hashing {}", artifact.display()))?;
1806 match crate::registry::admit(stores, &sha256)? {
1807 Ok(admitted) => {
1808 println!("admitted {sha256}");
1809 println!("built by {}", admitted.record.builder);
1810 println!(
1811 "source {}@{}",
1812 admitted.record.source_repo, admitted.record.source_commit
1813 );
1814 println!("record {} of the build log", admitted.index);
1815 println!(
1816 "head size {} root {}",
1817 admitted.head.size, admitted.head.root
1818 );
1819 Ok(())
1820 }
1821 Err(refusal) => {
1822 println!("refused {sha256}");
1826 println!("{refusal}");
1827 anyhow::bail!("the registry refuses this artifact")
1828 }
1829 }
1830 }
1831 BuildCommand::Run {
1832 repo,
1833 at,
1834 workspace,
1835 builder,
1836 allow_network,
1837 } => {
1838 let (namespace, name) = repo
1839 .split_once('/')
1840 .with_context(|| format!("{repo:?} is not `<namespace>/<repo>`"))?;
1841 let repo_ref = gunnar_core::RepoRef::new(namespace, name)
1842 .context("the repository path is not a legal one")?;
1843 let commit = resolve_commit(stores, &repo_ref, &at)?;
1844 let built = crate::runner::build_commit(
1845 stores,
1846 &repo_ref,
1847 commit,
1848 &workspace,
1849 &builder,
1850 allow_network,
1851 )?;
1852 println!(
1853 "built {}@{}",
1854 built.record.source_repo, built.record.source_commit
1855 );
1856 println!("recipe {}", built.record.recipe);
1857 println!("toolchain {}", built.record.toolchain);
1858 println!("network {}", built.record.network);
1859 for subject in &built.record.subjects {
1860 println!("produced {} {}", subject.sha256, subject.name);
1861 }
1862 println!("workdir {}", built.workdir.display());
1863 println!();
1864 println!(
1865 "`gunnar attest head --builds` is what turns this record into evidence \
1866 somebody else can check."
1867 );
1868 Ok(())
1869 }
1870 BuildCommand::Watch {
1871 workspace,
1872 builder,
1873 refs,
1874 allow_network,
1875 max_builds,
1876 } => {
1877 anyhow::bail!(
1878 "`gunnar build watch` needs the event bus the serving process owns, and this \
1879 command opens the stores on its own. Start the server with `--runner \
1880 --runner-workspace {} --runner-builder {}`{}{}; the runner then shares the \
1881 bus every push publishes on.\n\n\
1882 A second process cannot subscribe to an in-process bus, and a runner that \
1883 polled the log instead would build a push some time after it landed rather \
1884 than because it landed.",
1885 workspace.display(),
1886 builder,
1887 if allow_network {
1888 " --runner-allow-network"
1889 } else {
1890 ""
1891 },
1892 match (refs.is_empty(), max_builds) {
1893 (true, None) => String::new(),
1894 _ => " (--ref/--max-builds are `watch` options; the served runner builds \
1895 every branch)"
1896 .to_string(),
1897 }
1898 )
1899 }
1900 BuildCommand::Image {
1901 artifact,
1902 entrypoint,
1903 builder,
1904 } => {
1905 let bytes = std::fs::read(&artifact)
1906 .with_context(|| format!("reading {}", artifact.display()))?;
1907 let artifact_digest = gunnar_core::provenance::digest_bytes(&bytes);
1908
1909 let admitted = match crate::registry::admit(stores, &artifact_digest)? {
1913 Ok(admitted) => admitted,
1914 Err(refusal) => {
1915 anyhow::bail!("the binary this image would carry is not admitted: {refusal}")
1916 }
1917 };
1918
1919 let registry = crate::oci::Registry::under(stores.root());
1920 let image = crate::oci::build_and_store(®istry, &bytes, &entrypoint)?;
1921 registry.put_blob(&image.manifest)?;
1922
1923 let now = gunnar_grpc::bus::now_unix_ms();
1927 let record = gunnar_core::BuildRecord {
1928 builder,
1929 source_repo: admitted.record.source_repo.clone(),
1930 source_commit: admitted.record.source_commit.clone(),
1931 recipe: format!("oci image of {entrypoint}"),
1932 toolchain: "gunnar oci".to_owned(),
1933 network: false,
1937 materials: vec![gunnar_core::Subject {
1938 name: artifact
1939 .file_name()
1940 .map(|n| n.to_string_lossy().into_owned())
1941 .unwrap_or_else(|| artifact.display().to_string()),
1942 sha256: artifact_digest.clone(),
1943 }],
1944 subjects: vec![gunnar_core::Subject {
1945 name: format!("{entrypoint} (oci manifest)"),
1946 sha256: image.digest.trim_start_matches("sha256:").to_owned(),
1947 }],
1948 started_at_unix_ms: now,
1949 finished_at_unix_ms: now,
1950 };
1951 let key = stores.provenance_key()?;
1952 let signed = gunnar_core::provenance::sign_build(&key, record)
1953 .context("signing the image's build record")?;
1954 let mut line = serde_json::to_string(&signed)?;
1955 line.push('\n');
1956 let _framed = gunnar_core::txlog::sink::append_line(
1957 stores.build_log().records_path(),
1958 line.trim_end_matches('\n'),
1959 )?;
1960 use std::io::Write as _;
1961 let mut file = std::fs::OpenOptions::new()
1962 .create(true)
1963 .append(true)
1964 .open(stores.build_log().records_path())?;
1965 file.write_all(line.as_bytes())?;
1966
1967 println!("image {}", image.digest);
1968 println!("config {}", image.config_digest);
1969 println!("layer {}", image.layer_digest);
1970 println!("files {}", image.files);
1971 println!("from {}", artifact_digest);
1972 println!(
1973 "source {}@{}",
1974 signed.record.source_repo, signed.record.source_commit
1975 );
1976 println!();
1977 println!(
1978 "Zero CVEs is this number: {} file(s) in the image's filesystem, on no base at \
1979 all. Next: `gunnar attest head --builds`, then `gunnar registry push`.",
1980 image.files
1981 );
1982 Ok(())
1983 }
1984 BuildCommand::List => {
1985 let key = stores.provenance_key()?;
1986 let mut any = false;
1987 for line in log.lines_of()? {
1988 let signed: gunnar_core::SignedBuild =
1989 serde_json::from_str(&line).context("decoding a build record")?;
1990 let verdict = match gunnar_core::provenance::verify_build(&signed, key.public_key())
1991 {
1992 Ok(()) => "ok ".to_owned(),
1993 Err(problem) => format!("BAD ({problem})"),
1994 };
1995 println!(
1996 "{verdict} {} {}@{} {}",
1997 signed.record.digest(),
1998 signed.record.source_repo,
1999 signed.record.source_commit,
2000 signed.record.recipe
2001 );
2002 any = true;
2003 }
2004 if !any {
2005 println!("this server has attested to no builds");
2008 }
2009 Ok(())
2010 }
2011 BuildCommand::Key => {
2012 let key = stores.provenance_key()?;
2013 println!(
2014 "{}",
2015 key.public_key()
2016 .to_openssh()
2017 .context("rendering the provenance public key")?
2018 );
2019 Ok(())
2020 }
2021 }
2022}
2023
2024pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
2026 use gunnar_common::cli::ForgeCommand;
2027
2028 let log = stores.forge_log();
2029
2030 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2033 log.lines_of()?
2034 .iter()
2035 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2036 .collect()
2037 }
2038
2039 fn append(
2040 stores: &ServerStores,
2041 identity: &std::path::Path,
2042 event: gunnar_core::ForgeEvent,
2043 ) -> Result<gunnar_core::SignedEvent> {
2044 let pem = std::fs::read_to_string(identity)
2045 .with_context(|| format!("reading {}", identity.display()))?;
2046 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2047 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2048 let fingerprint = key
2053 .public_key()
2054 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2055 .to_string();
2056 let known = stores
2057 .auth()
2058 .signing_keys()?
2059 .into_iter()
2060 .any(|row| row.fingerprint_sha256 == fingerprint);
2061 anyhow::ensure!(
2062 known,
2063 "{fingerprint} is not registered on this server, so nobody reading this event later \
2064 could check its signature. Register it with `gunnar key add` first."
2065 );
2066 let signed =
2067 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2068 let mut line = serde_json::to_string(&signed)?;
2069 line.push('\n');
2070 let _framed = gunnar_core::txlog::sink::append_line(
2071 stores.forge_log().records_path(),
2072 line.trim_end_matches('\n'),
2073 )?;
2074 use std::io::Write as _;
2075 let mut file = std::fs::OpenOptions::new()
2076 .create(true)
2077 .append(true)
2078 .open(stores.forge_log().records_path())?;
2079 file.write_all(line.as_bytes())?;
2080 Ok(signed)
2081 }
2082
2083 match command {
2084 ForgeCommand::Comment {
2085 repo,
2086 pull,
2087 body,
2088 actor,
2089 identity,
2090 } => {
2091 append(
2092 stores,
2093 &identity,
2094 gunnar_core::ForgeEvent {
2095 repo: repo.clone(),
2096 pull,
2097 actor,
2098 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2099 event: gunnar_core::Event::Commented {
2100 body,
2101 path: String::new(),
2102 line: 0,
2103 blob_oid: String::new(),
2104 },
2105 },
2106 )?;
2107 println!("commented on {repo}#{pull}");
2108 Ok(())
2109 }
2110 ForgeCommand::Approve {
2111 repo,
2112 pull,
2113 commit,
2114 actor,
2115 identity,
2116 } => {
2117 let signed = append(
2118 stores,
2119 &identity,
2120 gunnar_core::ForgeEvent {
2121 repo: repo.clone(),
2122 pull,
2123 actor: actor.clone(),
2124 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2125 event: gunnar_core::Event::Approved {
2126 commit: commit.clone(),
2127 },
2128 },
2129 )?;
2130 println!("{actor} approved {commit} on {repo}#{pull}");
2131 println!("signed by {}", signed.key_fingerprint);
2132 Ok(())
2133 }
2134 ForgeCommand::Reject {
2135 repo,
2136 pull,
2137 commit,
2138 reason,
2139 actor,
2140 identity,
2141 } => {
2142 let signed = append(
2143 stores,
2144 &identity,
2145 gunnar_core::ForgeEvent {
2146 repo: repo.clone(),
2147 pull,
2148 actor: actor.clone(),
2149 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2150 event: gunnar_core::Event::Rejected {
2151 commit: commit.clone(),
2152 reason,
2153 },
2154 },
2155 )?;
2156 println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
2157 println!("signed by {}", signed.key_fingerprint);
2158 Ok(())
2159 }
2160 ForgeCommand::Show { repo, pull } => {
2161 let all = events(&log)?;
2162 let mine: Vec<gunnar_core::ForgeEvent> = all
2163 .iter()
2164 .filter(|e| e.event.repo == repo)
2165 .map(|e| e.event.clone())
2166 .collect();
2167 let state = gunnar_core::forge::fold(pull, &mine);
2168 println!("{repo}#{pull} review");
2172 println!("comments {}", state.comments);
2173 if state.approvals.is_empty() {
2174 println!("approvals none");
2175 }
2176 let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
2181 .auth()
2182 .signing_keys()?
2183 .iter()
2184 .filter_map(|row| {
2185 gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
2186 .ok()
2187 .map(|key| (row.fingerprint_sha256.clone(), key))
2188 })
2189 .collect();
2190 let attest_key = stores.forge_attest_public()?;
2191 for (actor, commit) in &state.approvals {
2192 let signed = all.iter().rev().find(|e| {
2193 e.event.repo == repo
2194 && e.event.pull == pull
2195 && e.event.actor == *actor
2196 && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
2197 });
2198 let verdict = match signed {
2199 None => "NO EVENT".to_owned(),
2200 Some(signed) if signed.attested.is_some() => match &attest_key {
2203 None => "UNKNOWN KEY".to_owned(),
2204 Some(key) => match gunnar_core::forge::verify_event(signed, key) {
2205 Ok(()) => signed
2206 .attested
2207 .as_ref()
2208 .map(|a| a.words())
2209 .unwrap_or_default(),
2210 Err(problem) => format!("BAD ({problem})"),
2211 },
2212 },
2213 Some(signed) => match keys
2214 .iter()
2215 .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
2216 {
2217 None => "UNKNOWN KEY".to_owned(),
2218 Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
2219 Ok(()) => "verified".to_owned(),
2220 Err(problem) => format!("BAD ({problem})"),
2221 },
2222 },
2223 };
2224 println!("approval {actor} → {commit} [{verdict}]");
2225 }
2226 Ok(())
2227 }
2228 ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
2229 }
2230}
2231
2232fn forge_issue(
2234 stores: &ServerStores,
2235 log: &crate::attest::TransparencyLog,
2236 command: gunnar_common::cli::IssueCommand,
2237) -> Result<()> {
2238 use gunnar_common::cli::IssueCommand;
2239
2240 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2242 log.lines_of()?
2243 .iter()
2244 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2245 .collect()
2246 }
2247
2248 fn repo_events(
2250 log: &crate::attest::TransparencyLog,
2251 repo: &str,
2252 ) -> Result<Vec<gunnar_core::ForgeEvent>> {
2253 Ok(events(log)?
2254 .into_iter()
2255 .filter(|e| e.event.repo == repo)
2256 .map(|e| e.event)
2257 .collect())
2258 }
2259
2260 fn append(
2264 stores: &ServerStores,
2265 identity: &std::path::Path,
2266 event: gunnar_core::ForgeEvent,
2267 ) -> Result<gunnar_core::SignedEvent> {
2268 let pem = std::fs::read_to_string(identity)
2269 .with_context(|| format!("reading {}", identity.display()))?;
2270 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2271 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2272 let fingerprint = key
2273 .public_key()
2274 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2275 .to_string();
2276 let known = stores
2277 .auth()
2278 .signing_keys()?
2279 .into_iter()
2280 .any(|row| row.fingerprint_sha256 == fingerprint);
2281 anyhow::ensure!(
2282 known,
2283 "{fingerprint} is not registered on this server, so nobody reading this event later \
2284 could check its signature. Register it with `gunnar key add` first."
2285 );
2286 let signed =
2287 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2288 let mut line = serde_json::to_string(&signed)?;
2289 line.push('\n');
2290 let _framed = gunnar_core::txlog::sink::append_line(
2291 stores.forge_log().records_path(),
2292 line.trim_end_matches('\n'),
2293 )?;
2294 use std::io::Write as _;
2295 let mut file = std::fs::OpenOptions::new()
2296 .create(true)
2297 .append(true)
2298 .open(stores.forge_log().records_path())?;
2299 file.write_all(line.as_bytes())?;
2300 Ok(signed)
2301 }
2302
2303 match command {
2304 IssueCommand::Open {
2305 repo,
2306 title,
2307 body,
2308 actor,
2309 identity,
2310 } => {
2311 append(
2312 stores,
2313 &identity,
2314 gunnar_core::ForgeEvent {
2315 repo: repo.clone(),
2316 pull: 0,
2318 actor: actor.clone(),
2319 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2320 event: gunnar_core::Event::IssueOpened {
2321 title: title.clone(),
2322 body,
2323 },
2324 },
2325 )?;
2326 let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
2329 .last()
2330 .map(|i| i.number)
2331 .unwrap_or(0);
2332 println!("opened {repo}#{number} {title}");
2333 println!("{actor} filed it, signed with their own key");
2334 Ok(())
2335 }
2336 IssueCommand::Comment {
2337 repo,
2338 number,
2339 body,
2340 actor,
2341 identity,
2342 } => {
2343 anyhow::ensure!(
2344 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2345 .is_some_and(|i| i.open),
2346 "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2347 evidence about nothing"
2348 );
2349 append(
2350 stores,
2351 &identity,
2352 gunnar_core::ForgeEvent {
2353 repo: repo.clone(),
2354 pull: number,
2355 actor,
2356 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2357 event: gunnar_core::Event::IssueCommented { body },
2358 },
2359 )?;
2360 println!("commented on {repo}#{number}");
2361 Ok(())
2362 }
2363 IssueCommand::Close {
2364 repo,
2365 number,
2366 reason,
2367 actor,
2368 identity,
2369 } => {
2370 anyhow::ensure!(
2371 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2372 .is_some_and(|i| i.open),
2373 "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2374 );
2375 append(
2376 stores,
2377 &identity,
2378 gunnar_core::ForgeEvent {
2379 repo: repo.clone(),
2380 pull: number,
2381 actor: actor.clone(),
2382 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2383 event: gunnar_core::Event::IssueClosed { reason },
2384 },
2385 )?;
2386 println!("{actor} closed {repo}#{number}");
2387 Ok(())
2388 }
2389 IssueCommand::List { repo } => {
2390 let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2391 if issues.is_empty() {
2392 println!("{repo} has no issues");
2393 return Ok(());
2394 }
2395 for issue in issues {
2396 let state = if issue.open { "open " } else { "closed" };
2397 println!(
2398 "{repo}#{} [{state}] {} ({} comment(s), by {})",
2399 issue.number, issue.title, issue.comments, issue.author
2400 );
2401 }
2402 Ok(())
2403 }
2404 IssueCommand::Show { repo, number } => {
2405 let all = repo_events(log, &repo)?;
2406 let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2407 anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2408 };
2409 let state = if issue.open { "open" } else { "closed" };
2410 println!("{repo}#{number} [{state}] {}", issue.title);
2411 println!("opened by {}", issue.author);
2412 if !issue.body.is_empty() {
2413 println!();
2414 println!("{}", issue.body);
2415 }
2416 println!();
2417 println!("comments {}", issue.comments);
2418 Ok(())
2419 }
2420 }
2421}
2422
2423fn resolve_commit(
2429 stores: &ServerStores,
2430 repo: &gunnar_core::RepoRef,
2431 rev: &str,
2432) -> Result<gix_hash::ObjectId> {
2433 let entry = stores
2434 .catalog()
2435 .get(repo)?
2436 .with_context(|| format!("no such repository: {repo}"))?;
2437 let store = stores.repo_store(&entry)?;
2438 let git = store.serve();
2439 if let Ok(oid) = gix_hash::ObjectId::from_hex(rev.as_bytes()) {
2440 anyhow::ensure!(
2441 oid.kind() == store.hash_kind(),
2442 "{rev} is a {} oid; this repository is {}",
2443 oid.kind(),
2444 store.hash_kind()
2445 );
2446 return Ok(oid);
2447 }
2448 let snapshot = gunnar_wire::RefSnapshot::read(git)?;
2449 let resolved = if rev == gunnar_store::HEAD {
2450 snapshot.head(git)?
2451 } else {
2452 snapshot.resolve(rev)?
2453 };
2454 resolved.with_context(|| format!("{rev} names no commit in {repo}"))
2455}
2456
2457pub fn registry(
2459 stores: &std::sync::Arc<ServerStores>,
2460 command: gunnar_common::cli::RegistryCommand,
2461) -> Result<()> {
2462 use gunnar_common::cli::RegistryCommand;
2463
2464 let registry = crate::oci::Registry::under(stores.root());
2465 match command {
2466 RegistryCommand::Push { name, tag, digest } => {
2467 let manifest = registry
2468 .get_blob(&digest)?
2469 .with_context(|| format!("{digest} is not in this registry"))?;
2470 match registry.put_manifest(stores, &name, &tag, &manifest)? {
2471 Ok(landed) => {
2472 println!(
2473 "pushed {}:{} → {}",
2474 landed.name, landed.reference, landed.digest
2475 );
2476 Ok(())
2477 }
2478 Err(refusal) => {
2479 println!("refused {digest}");
2480 println!("{refusal}");
2481 anyhow::bail!("the registry refuses this manifest")
2482 }
2483 }
2484 }
2485 RegistryCommand::Resolve { name, tag } => {
2486 let digest = registry
2487 .resolve(&name, &tag)?
2488 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2489 println!("{digest}");
2490 Ok(())
2491 }
2492 RegistryCommand::Pull { name, tag, line } => {
2493 let digest = registry
2494 .resolve(&name, &tag)?
2495 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2496 let manifest = registry
2500 .get_blob(&digest)?
2501 .with_context(|| format!("{digest} is tagged and its manifest is not stored"))?;
2502 println!("{}", String::from_utf8_lossy(&manifest));
2503 println!();
2504
2505 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(stores));
2509 let chain = crate::verify::verify_artifact(
2510 stores,
2511 &plane,
2512 digest.trim_start_matches("sha256:"),
2513 line.as_deref(),
2514 )?;
2515 println!("{chain}");
2516 if chain.is_unbroken() {
2517 return Ok(());
2518 }
2519 if chain.is_broken() {
2520 anyhow::bail!("the chain behind this image is broken");
2521 }
2522 anyhow::bail!("the chain behind this image is incomplete")
2523 }
2524 }
2525}
2526
2527#[cfg(feature = "engine-znippy")]
2532pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2533 format!(
2534 "note: a repository archive does not carry the account's settings, so {path} is NOT \
2535 encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2536 console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2537 set-encrypted {path} on where the control plane is in reach; or restore the control \
2538 document as well"
2539 )
2540}
2541
2542#[cfg(all(test, feature = "engine-znippy"))]
2543mod tests {
2544 use super::*;
2545
2546 #[test]
2547 fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2548 let path = RepoRef::new("acme", "vault").expect("a legal path");
2549 let note = encrypted_flag_note(&path);
2550 assert!(note.contains("encrypted"), "{note}");
2551 assert!(
2552 note.contains("gunnar control repo set-encrypted acme/vault on"),
2553 "the one command that sets it again: {note}"
2554 );
2555 }
2556
2557 #[test]
2561 fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2562 let path = RepoRef::new("acme", "vault").expect("a legal path");
2563 let note = encrypted_flag_note(&path);
2564 assert!(
2565 note.contains("console") && note.contains("Turn encryption on"),
2566 "the console's switch: {note}"
2567 );
2568 }
2569}