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 other => Some(
298 crate::storage::znippy_engine::parse_explode_budget(other)
299 .context("reading the explode budget")?,
300 ),
301 };
302 let ns = stores
303 .catalog()
304 .set_namespace_explode_budget(&name, bytes)?;
305 for repo in stores.catalog().list(Some(&name))? {
309 if let Ok(dir) = stores.repo_dir(&repo.path()) {
310 stores.apply_explode_quota(&name, &dir);
311 }
312 }
313 match ns.explode_budget_bytes {
314 Some(0) => println!("{name}: explode budget unlimited"),
315 Some(b) => println!(
316 "{name}: explode budget {}",
317 crate::storage::znippy_engine::human_bytes(b)
318 ),
319 None => println!("{name}: explode budget back to the server default"),
320 }
321 }
322 }
323 Ok(())
324}
325
326pub fn repo(stores: &ServerStores, command: RepoCommand) -> Result<()> {
330 match command {
331 RepoCommand::Create {
332 path,
333 hash,
334 default_branch,
335 public,
336 description,
337 adopt,
338 } => {
339 let path = repo_ref(&path)?;
340 let mut spec = NewRepo::new(path.clone())
341 .hash_kind(hash.into())
342 .visibility(if public {
343 Visibility::Public
344 } else {
345 Visibility::Private
346 });
347 spec.description = description;
348 if let Some(branch) = default_branch {
349 spec = spec.default_branch(full_branch(&branch));
350 }
351 let created = stores.create_repo(spec, adopt)?;
357 let store = stores.repo_store(&created)?;
358 println!(
359 "created {} ({}, default branch {}) at {}",
360 created.key(),
361 created.hash_kind,
362 created.default_branch,
363 store.git_dir().display()
364 );
365 }
366 RepoCommand::List { namespace } => {
367 let mut repos = stores.catalog().list(namespace.as_deref())?;
368 repos.sort_by_key(|repo| repo.key());
369 for repo in repos {
370 println!(
371 "{}\t{}\t{}\t{:?}",
372 repo.key(),
373 repo.hash_kind,
374 repo.default_branch,
375 repo.visibility
376 );
377 }
378 }
379 RepoCommand::Delete {
380 path,
381 purge,
382 retention_days,
383 } => {
384 let path = repo_ref(&path)?;
385 let orphan = stores.is_orphan(&path)?;
392 anyhow::ensure!(
393 purge || !orphan,
394 "{}",
395 crate::stores::ServerStores::orphan_soft_delete_refusal(&path)
396 );
397 let removed = match stores.catalog().delete(&path) {
401 Ok(repo) => Some(repo),
402 Err(gunnar_core::Error::NoSuchRepo(_)) if orphan => None,
403 Err(err) => return Err(err.into()),
404 };
405 stores.close_repo(&path);
408 if purge {
409 let dir = stores.repo_dir(&path)?;
410 let (before, after) = stores.purge_repo(&path)?;
411 println!(
412 "deleted {}{} and purged {} ({} bytes reclaimed)",
413 path,
414 if removed.is_none() { " (an ORPHAN: it had no catalog row)" } else { "" },
415 dir.display(),
416 before.saturating_sub(after)
417 );
418 } else {
419 let removed = removed.expect("a soft delete of an orphan is refused above");
420 let days = retention_days.unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS);
421 let record = stores.soft_delete(
422 &path,
423 Some(&removed),
424 "",
425 std::time::Duration::from_secs(u64::from(days) * 86_400),
426 )?;
427 let bytes = crate::stores::dir_bytes(&stores.repo_dir(&path)?);
428 println!(
429 "deleted {} — its {bytes} bytes are KEPT for {days} day(s)",
430 removed.key()
431 );
432 println!(
433 " undelete: gunnar repo undelete {} purge now: gunnar repo purge {}",
434 removed.key(),
435 removed.key()
436 );
437 if let Some(at) = record.purge_after_unix_ms {
438 println!(" the sweep may reclaim them after unix_ms {at}");
439 }
440 println!(" until then they still count against the account's storage.");
441 }
442 }
443 RepoCommand::Undelete { path } => {
444 let path = repo_ref(&path)?;
445 let spec = stores.undelete_spec(&path)?;
446 let repo = stores.create_repo(spec, true)?;
450 stores.clear_tombstone(&path)?;
451 println!(
452 "undeleted {} ({}, default branch {})",
453 repo.key(),
454 repo.hash_kind,
455 repo.default_branch
456 );
457 }
458 RepoCommand::Purge { path } => {
459 let path = repo_ref(&path)?;
460 anyhow::ensure!(
461 stores.catalog().get(&path)?.is_none(),
462 "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
463 deletes and reclaims it in one step; purge alone only ends a retention window."
464 );
465 anyhow::ensure!(
466 stores.tombstone_of(&path)?.is_some(),
467 "{path} is neither catalogued nor tombstoned; there is nothing to purge"
468 );
469 let (before, after) = stores.purge_repo(&path)?;
470 println!(
471 "purged {path} ({} bytes reclaimed); it can no longer be undeleted",
472 before.saturating_sub(after)
473 );
474 }
475 RepoCommand::ListDeleted {} => {
476 let rows = stores.tombstones()?;
477 if rows.is_empty() {
478 println!("nothing is deleted");
479 }
480 let now = gunnar_core::now_ms();
481 let mut total = 0u64;
482 for row in &rows {
483 total += row.bytes;
484 let left = match row.tombstone.seconds_left(now) {
485 Some(secs) => format!("{} day(s) left", secs / 86_400),
486 None => "no expiry".to_owned(),
487 };
488 println!("{}\t{} bytes\t{left}", row.path, row.bytes);
489 }
490 if !rows.is_empty() {
491 println!(
492 "{} deleted repository(ies) holding {total} bytes — still on the account",
493 rows.len()
494 );
495 }
496 }
497 RepoCommand::ListOrphans { namespace } => {
498 let rows = stores.orphans()?;
499 let rows: Vec<_> = rows
500 .into_iter()
501 .filter(|row| {
502 namespace
503 .as_deref()
504 .is_none_or(|want| row.path.namespace() == want)
505 })
506 .collect();
507 if rows.is_empty() {
508 println!("no orphan stores: every directory under the repository root is either \
509 catalogued (`repo list`) or soft-deleted (`repo list-deleted`)");
510 }
511 let mut total = 0u64;
512 for row in &rows {
513 total += row.bytes;
514 println!(
515 "{}\t{} bytes\t{}\tmtime {}\t{}{}",
516 row.path,
517 row.bytes,
518 row.hash_marker.as_deref().unwrap_or("-"),
519 row.modified_unix_ms,
520 row.dir.display(),
521 match &row.failed_reclaim {
522 Some(note) => format!("\tRECLAIM FAILED: {note}"),
523 None => String::new(),
524 }
525 );
526 }
527 if !rows.is_empty() {
528 println!(
529 "{} orphan store(s) holding {total} bytes — no catalog row, no tombstone",
530 rows.len()
531 );
532 println!(
533 " reclaim the bytes: gunnar repo reclaim <path> keep them: gunnar repo \
534 create <path> --adopt --hash <the HASH column>"
535 );
536 }
537 }
538 RepoCommand::Reclaim { path } => {
539 let path = repo_ref(&path)?;
540 let bytes = stores.reclaim_orphan(&path)?;
541 println!("reclaimed {path} ({bytes} bytes); the name is usable again");
542 }
543 #[cfg(feature = "engine-znippy")]
544 RepoCommand::ArchiveOut { .. } => {
545 unreachable!("archive-out is handled by repo_archive_out_without_stores")
549 }
550 #[cfg(feature = "engine-znippy")]
551 RepoCommand::ArchiveIn {
552 file,
553 as_path,
554 no_catalog,
555 } => {
556 let path = repo_ref(&as_path)?;
557 let dir = stores.repo_dir(&path)?;
558 let r = crate::storage::znippy_engine::archive_in(&file, &dir)?;
559 println!(
560 "restored {} bytes into {} (generation {})",
561 r.bytes_in,
562 r.repo_dir.display(),
563 r.to_generation
564 );
565 if !no_catalog {
566 let marker =
571 std::fs::read_to_string(dir.join(crate::storage::znippy_engine::OBJECT_FORMAT))
572 .unwrap_or_default();
573 let hash = match marker.trim() {
574 "sha1" => gunnar_core::HashKind::Sha1,
575 "sha256" | "" => gunnar_core::HashKind::Sha256,
576 other => bail!("the archive names an unknown object format {other:?}"),
577 };
578 let spec = NewRepo::new(path.clone()).hash_kind(hash);
579 match stores.create_repo(spec, true) {
591 Ok(created) => {
592 println!(
593 "catalogued {} ({}, default branch {})",
594 created.key(),
595 created.hash_kind,
596 created.default_branch
597 );
598 println!("{}", encrypted_flag_note(&path));
599 }
600 Err(e) if format!("{e}").contains("already exists") => {
601 match stores.catalog().get(&path)? {
602 Some(have) => println!(
603 "the catalog already has {} ({}, default branch {}) — restored \
604 from the control document, and left alone",
605 have.key(),
606 have.hash_kind,
607 have.default_branch
608 ),
609 None => println!("the catalog already has {path}"),
610 }
611 }
612 Err(e) => return Err(e),
613 }
614 }
615 }
616 }
617 Ok(())
618}
619
620#[cfg(feature = "engine-znippy")]
622fn hex_head(d: &[u8; 32]) -> String {
623 d[..8].iter().map(|b| format!("{b:02x}")).collect()
624}
625
626#[cfg(feature = "engine-znippy")]
635pub fn repo_archive_out_without_stores(
636 data_root: &std::path::Path,
637 command: RepoCommand,
638) -> Result<()> {
639 let RepoCommand::ArchiveOut {
640 path,
641 to,
642 from_generation,
643 } = command
644 else {
645 unreachable!("called with something other than archive-out")
646 };
647 let path = repo_ref(&path)?;
648 let dir = path
649 .path_under(&data_root.join("repos"))
650 .with_context(|| format!("resolving the directory for {path}"))?;
651 if !dir.is_dir() {
652 bail!("{path} has nothing on disk at {}", dir.display());
653 }
654 let r = crate::storage::znippy_engine::archive_out(&dir, &to, from_generation)?;
655 for s in &r.segments {
656 println!(
657 " {:<44} {:>12} bytes from {}",
658 s.name, s.len, s.from_offset
659 );
660 }
661 println!(
662 "archived {path} -> {} {} bytes, generation {} -> {} sha256 {}",
663 to.display(),
664 r.bytes_out,
665 r.from_generation,
666 r.to_generation,
667 hex_head(&r.file_sha256)
668 );
669 println!("next incremental: --from-generation {}", r.to_generation);
670 Ok(())
671}
672
673fn full_branch(name: &str) -> String {
675 if name.starts_with("refs/") {
676 name.to_owned()
677 } else {
678 format!("refs/heads/{name}")
679 }
680}
681
682pub fn principal(stores: &ServerStores, command: PrincipalCommand) -> Result<()> {
686 match command {
687 PrincipalCommand::Add {
688 id,
689 name,
690 kind,
691 csr,
692 server_generated_key,
693 out,
694 days,
695 } => {
696 let display = name.unwrap_or_else(|| id.clone());
697 let created = stores.auth().add_principal(gunnar_core::Principal::new(
698 &id,
699 display,
700 kind.into(),
701 )?)?;
702 println!("added principal {} ({:?})", created.id, created.kind);
703
704 match (csr, server_generated_key) {
709 (Some(path), _) => {
710 let request = std::fs::read_to_string(&path).with_context(|| {
711 format!("reading the signing request at {}", path.display())
712 })?;
713 if let Some(claimed) = gunnar_core::issuer::csr_claimed_common_name(&request)? {
714 if claimed != created.id {
715 println!(
720 "note: the request claims CN={claimed}; it is being issued as \
721 {} — a request does not choose its own name",
722 created.id
723 );
724 }
725 }
726 let issued = stores.pki()?.issue_from_csr(&created.id, &request, days)?;
727 hand_over(stores, &out, &issued)?;
728 }
729 (None, true) => {
730 let issued = stores.pki()?.issue_with_server_key(&created.id, days)?;
731 hand_over(stores, &out, &issued)?;
732 }
733 (None, false) => {
734 if stores
735 .control_store()
736 .read_doc(gunnar_core::ISSUER_REF)?
737 .is_some()
738 {
739 println!(
740 "no certificate was issued. `--csr <file>` signs a request whose \
741 key never leaves {}'s machine; `--server-generated-key` mints one \
742 here instead",
743 created.id
744 );
745 }
746 }
747 }
748 }
749 PrincipalCommand::List => {
750 let mut principals = stores.auth().principals()?;
751 principals.sort_by(|a, b| a.id.cmp(&b.id));
752 for principal in principals {
753 println!("{}\t{}\t{:?}", principal.id, principal.name, principal.kind);
754 }
755 }
756 PrincipalCommand::Remove { id } => {
757 let removed = stores.auth().remove_principal(&id)?;
758 println!("removed principal {}", removed.id);
759 }
760 }
761 Ok(())
762}
763
764pub fn key(stores: &ServerStores, command: KeyCommand) -> Result<()> {
766 match command {
767 KeyCommand::Add {
768 principal,
769 key,
770 cert,
771 } => {
772 let registered = match (key, cert) {
773 (_, Some(path)) => {
774 let der = read_certificate(&path)?;
775 stores.auth().register_certificate(&principal, &der, None)?
776 }
777 (Some(key), None) => {
778 let line = key_argument(&key)?;
779 stores.auth().register_key(&principal, &line, None)?
780 }
781 (None, None) => bail!("give a public key or --cert"),
782 };
783 println!(
784 "registered {} ({}) to {}",
785 registered.fingerprint_sha256, registered.algo, registered.principal_id
786 );
787 }
788 KeyCommand::List { principal } => {
789 let mut keys = match principal {
790 Some(id) => stores.auth().keys_of(&id)?,
791 None => stores.auth().keys()?,
792 };
793 keys.sort_by(|a, b| a.fingerprint_sha256.cmp(&b.fingerprint_sha256));
794 for key in keys {
795 println!(
796 "{}\t{}\t{}",
797 key.fingerprint_sha256, key.algo, key.principal_id
798 );
799 }
800 }
801 KeyCommand::Revoke { fingerprint } => {
802 let parsed = Fingerprint::parse(&fingerprint)?;
803 match stores.auth().revoke_key(&parsed)? {
804 Some(key) => println!("revoked {} from {}", parsed, key.principal_id),
805 None => bail!("no key with fingerprint {parsed} is registered"),
806 }
807 }
808 }
809 Ok(())
810}
811
812pub fn token(stores: &ServerStores, command: gunnar_common::cli::TokenCommand) -> Result<()> {
821 use gunnar_common::cli::TokenCommand;
822
823 match command {
824 TokenCommand::Add {
825 principal,
826 label,
827 expires_in,
828 } => {
829 let secret = gunnar_core::mint_token_secret();
830 let expires =
831 expires_in.map(|days| gunnar_core::now_ms() + (days as i64) * 24 * 60 * 60 * 1000);
832 let registered = stores
833 .auth()
834 .register_token(&principal, &secret, &label, expires)?;
835 println!("{secret}");
839 eprintln!(
840 "minted for {} (hash {}); this is the only time the token is printed",
841 registered.principal_id, registered.hash
842 );
843 }
844 TokenCommand::List { principal } => {
845 let mut tokens = stores.auth().tokens()?;
846 if let Some(id) = &principal {
847 tokens.retain(|token| &token.principal_id == id);
848 }
849 tokens.sort_by(|a, b| a.hash.cmp(&b.hash));
850 for token in tokens {
851 println!("{}\t{}\t{}", token.hash, token.principal_id, token.label);
852 }
853 }
854 TokenCommand::Revoke { hash } => match stores.auth().revoke_token(&hash)? {
855 Some(token) => println!("revoked {} from {}", token.hash, token.principal_id),
856 None => bail!("no token with hash {hash} is registered"),
857 },
858 }
859 Ok(())
860}
861
862fn scope_of(server: bool, namespace: Option<&str>, repo: Option<&str>) -> Result<Scope> {
865 match (server, namespace, repo) {
866 (true, _, _) => Ok(Scope::Server),
867 (_, Some(name), _) => Ok(Scope::Namespace(name.to_owned())),
868 (_, _, Some(path)) => Ok(Scope::Repo(repo_ref(path)?)),
869 _ => bail!("a grant needs a scope: --server, --namespace <ns>, or --repo <ns>/<name>"),
870 }
871}
872
873pub fn grant(stores: &ServerStores, args: GrantArgs) -> Result<()> {
875 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
876 let granted = stores.auth().grant(Grant {
877 principal: args.principal.clone(),
878 scope,
879 level: args.level.into(),
880 })?;
881 println!(
882 "{} now holds {} on {}",
883 granted.principal, granted.level, granted.scope
884 );
885 if let Scope::Namespace(name) = &granted.scope {
889 if name.contains('/')
890 || stores
891 .catalog()
892 .namespaces()?
893 .iter()
894 .any(|ns| ns.name.starts_with(&format!("{name}/")))
895 {
896 println!(
897 "note: namespace grants match EXACTLY; this does not reach nested namespaces \
898 under {name}/"
899 );
900 }
901 }
902 Ok(())
903}
904
905pub fn revoke(stores: &ServerStores, args: RevokeArgs) -> Result<()> {
907 let scope = scope_of(args.server, args.namespace.as_deref(), args.repo.as_deref())?;
908 if stores.auth().revoke_grant(&args.principal, &scope)? {
909 println!("revoked {}'s access to {scope}", args.principal);
910 Ok(())
911 } else {
912 bail!("{} holds no grant on {scope}", args.principal)
913 }
914}
915
916pub fn gc(stores: &ServerStores, args: GcArgs) -> Result<()> {
956 let repos = match &args.path {
957 Some(path) => {
958 let path = repo_ref(path)?;
959 vec![stores.catalog().open(&path)?]
960 }
961 None => stores.catalog().list(None)?,
962 };
963
964 let _ = &args.prune;
978 let _ = &args.prune_grace;
979 let mut done = 0usize;
980 let mut failed = 0usize;
981 let mut reclaimed: i64 = 0;
982 for repo in &repos {
983 match gc_one(stores, repo) {
984 Ok(report) => {
985 done += 1;
986 reclaimed += report.bytes_before as i64 - report.bytes_after as i64;
987 println!(
988 "{repo}\t{} -> {} bytes, {} live rows, {} pack(s) retired, {} section(s) \
989 sealed [{}]",
990 report.bytes_before,
991 report.bytes_after,
992 report.rows,
993 report.retired_packs,
994 report.sections_sealed,
995 report.strategy,
996 );
997 }
998 Err(err) => {
999 failed += 1;
1000 eprintln!("{repo}\tgc failed: {err:#}");
1001 }
1002 }
1003 }
1004 println!(
1005 "{done} of {} repository(ies) maintained, {reclaimed} bytes reclaimed",
1006 repos.len()
1007 );
1008 if failed > 0 {
1009 bail!("{failed} repository(ies) could not be maintained; see above");
1010 }
1011 Ok(())
1012}
1013
1014pub fn gc_one(stores: &ServerStores, repo: &Repo) -> Result<MaintenanceReport> {
1032 stores.wait_unpinned("a gc");
1035 let span = tracing::info_span!(target: TARGET_STORE, "store.maintain", repo = %repo);
1036 let _entered = span.enter();
1037 let started = std::time::Instant::now();
1038 let store = stores.repo_store(repo)?;
1039 match stores.engine().maintain(store.git_dir(), store.hash_kind()) {
1040 Ok(report) => {
1041 tracing::info!(
1042 target: TARGET_STORE,
1043 event = "store.maintained",
1044 strategy = %report.strategy,
1045 bytes_before = report.bytes_before,
1046 bytes_after = report.bytes_after,
1047 rows = report.rows,
1048 retired_packs = report.retired_packs,
1049 verified = report.verified,
1050 sections_sealed = report.sections_sealed,
1051 elapsed_ms = started.elapsed().as_millis() as u64,
1052 "the store was sealed and compacted"
1053 );
1054 Ok(report)
1055 }
1056 Err(err) => {
1057 tracing::error!(
1058 target: TARGET_STORE,
1059 event = "store.maintain_failed",
1060 %err,
1061 "maintaining the store failed"
1062 );
1063 Err(err)
1064 }
1065 }
1066}
1067
1068pub fn reclaimed_bytes(report: &MaintenanceReport) -> u64 {
1077 report.bytes_before.saturating_sub(report.bytes_after)
1078}
1079
1080pub fn reflog(data_root: &std::path::Path, args: ReflogArgs) -> Result<()> {
1167 let repos_root = data_root.join(crate::stores::REPOS_DIR);
1168 let repos: Vec<(String, std::path::PathBuf)> = match &args.path {
1169 Some(path) => {
1170 let path = repo_ref(path)?;
1171 let dir = path
1172 .path_under(&repos_root)
1173 .with_context(|| format!("resolving the directory for {path}"))?;
1174 if !dir.join("HEAD").exists() {
1175 bail!("there is no repository at {}", dir.display());
1176 }
1177 vec![(path.key(), dir)]
1178 }
1179 None => bare_repositories(&repos_root)?,
1180 };
1181 if repos.is_empty() {
1182 println!("no repositories under {}", repos_root.display());
1183 return Ok(());
1184 }
1185 let seal_is_a_directory = args.seal.is_some() && repos.len() > 1;
1188
1189 let mut read = 0usize;
1190 let mut failed = 0usize;
1191 let mut refused = 0usize;
1192 for (key, git_dir) in &repos {
1193 let store_dir = git_dir.join(LEGACY_STORE_DIR);
1194 if !legacy_reflog_present(&store_dir) {
1198 refused += 1;
1199 eprintln!(
1200 "{key}\tREFUSED: no reference history this verb can read — nothing at {}",
1201 store_dir.display()
1202 );
1203 continue;
1204 }
1205 let hash_kind = match stored_hash_kind(git_dir) {
1206 Ok(kind) => kind,
1207 Err(err) => {
1208 failed += 1;
1209 eprintln!("{key}\tFAILED: {err:#}");
1210 continue;
1211 }
1212 };
1213
1214 let history = match gunnar_core::History::read(&store_dir, hash_kind) {
1215 Ok(history) => history,
1216 Err(err) => {
1217 failed += 1;
1218 eprintln!("{key}\tFAILED: {err}");
1219 continue;
1220 }
1221 };
1222 read += 1;
1223
1224 match &args.seal {
1225 Some(target) => {
1226 let path = if seal_is_a_directory {
1227 target.join(format!("{}.arrow", key.replace('/', "_")))
1228 } else {
1229 target.clone()
1230 };
1231 let report = history.seal(&path)?;
1232 println!(
1233 "{key}\t{} pushes ({} archived, {} live)\t{} rows\t{} B\t{:.1} B/push\t{:.2}x smaller than the {} B it read\t{}",
1234 report.pushes,
1235 history.archived_pushes(),
1236 history.live_pushes(),
1237 report.rows,
1238 report.bytes,
1239 report.bytes_per_push(),
1240 report.shrink_x(),
1241 report.bytes_in,
1242 path.display(),
1243 );
1244 }
1245 None => {
1246 let transactions = history.transactions()?;
1247 println!(
1255 "{key}\t{} pushes ({} from the archive, {} live) in {} segment(s)\t{} rows\t{} B read",
1256 history.pushes(),
1257 history.archived_pushes(),
1258 history.live_pushes(),
1259 history.segments_read(),
1260 transactions.len(),
1261 history.bytes_in(),
1262 );
1263 let show = if args.all {
1264 transactions.as_slice()
1265 } else {
1266 let from = transactions.len().saturating_sub(args.limit);
1267 &transactions[from..]
1268 };
1269 if show.len() < transactions.len() {
1270 println!(
1271 " … {} older transaction(s) not shown; --all for every one",
1272 transactions.len() - show.len()
1273 );
1274 }
1275 for entry in show {
1276 println!(
1277 " {:>6} {} {} -> {}{}",
1278 entry.push_seq,
1279 entry.refname,
1280 value(entry.old_oid.as_ref(), entry.old_symref.as_deref()),
1281 value(entry.new_oid.as_ref(), entry.new_symref.as_deref()),
1282 if entry.is_snapshot() {
1283 " (compaction snapshot, not a move)"
1284 } else {
1285 ""
1286 },
1287 );
1288 }
1289 }
1290 }
1291 }
1292
1293 if refused > 0 {
1297 eprintln!("\n{}", refusal_explanation());
1298 }
1299 if failed > 0 {
1300 bail!(
1301 "{failed} of {} repositor{} could not be read; {read} were",
1302 repos.len(),
1303 if repos.len() == 1 { "y" } else { "ies" }
1304 );
1305 }
1306 if read == 0 {
1310 bail!(
1311 "no reference history was read from any of the {} repositor{} looked at",
1312 repos.len(),
1313 if repos.len() == 1 { "y" } else { "ies" }
1314 );
1315 }
1316 Ok(())
1317}
1318
1319const LEGACY_STORE_DIR: &str = "gunnar-store";
1327
1328fn legacy_reflog_present(store_dir: &std::path::Path) -> bool {
1338 store_dir.join(gunnar_core::arrow_log::REFS_LOG).is_file()
1339 || store_dir
1340 .join(gunnar_core::arrow_log::REFS_ARCHIVE_DIR)
1341 .is_dir()
1342}
1343
1344fn refusal_explanation() -> String {
1350 let engine = match crate::storage::Engine::selected() {
1351 Ok(engine) => engine.name(),
1352 Err(_) => "<unselectable>",
1356 };
1357 format!(
1358 "`reflog` reads the retired filesystem backend's Arrow reflog —\n\
1359 `<git-dir>/{LEGACY_STORE_DIR}/{log}` and `{archive}/` beside it. That backend went with\n\
1360 the packfile engine (the storage restart) and nothing writes those bytes any more, so\n\
1361 a repository created since has none and this verb has nothing to read.\n\
1362 \n\
1363 This process runs the `{engine}` engine. Where reference history is now:\n\
1364 \n\
1365 * znippy — one Arrow IPC frame per push in the store's own `refs.log`. It is real and it\n\
1366 \x20 is complete, but `GitStore::ref_log()` is `pub(crate)`, and its rows carry no actor\n\
1367 \x20 and no previous value, so this verb's output is not merely re-pointable at it.\n\
1368 * gix — none at all. `storage-git-gix` opens its ref store with `WriteReflog::Disable`,\n\
1369 \x20 deliberately: a git server has no use for a per-ref reflog file tree.\n\
1370 \n\
1371 Neither `GitOps` nor `GitServe` carries a ref-history accessor, and the `GitServe`\n\
1372 reading contract's ordering rule says one does not belong on the contract. Making this\n\
1373 verb answer again is an engine-shaped seam plus a decision about what it should\n\
1374 report — see `agentO-report.md`.",
1375 log = gunnar_core::arrow_log::REFS_LOG,
1376 archive = gunnar_core::arrow_log::REFS_ARCHIVE_DIR,
1377 )
1378}
1379
1380fn stored_hash_kind(git_dir: &std::path::Path) -> Result<gix_hash::Kind> {
1398 const OBJECT_FORMAT_KEY: &str = "objectformat";
1399
1400 let config_path = git_dir.join("config");
1401 let text = match std::fs::read_to_string(&config_path) {
1402 Ok(text) => text,
1403 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(gix_hash::Kind::Sha1),
1406 Err(err) => {
1407 return Err(anyhow::Error::from(err))
1408 .with_context(|| format!("reading {}", config_path.display()))
1409 }
1410 };
1411
1412 let mut in_extensions = false;
1413 for line in text.lines() {
1414 let line = line.trim();
1415 if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1416 in_extensions = header.trim().eq_ignore_ascii_case("extensions");
1417 continue;
1418 }
1419 if !in_extensions {
1420 continue;
1421 }
1422 let Some((key, value)) = line.split_once('=') else {
1423 continue;
1424 };
1425 if !key.trim().eq_ignore_ascii_case(OBJECT_FORMAT_KEY) {
1427 continue;
1428 }
1429 return match value.trim() {
1430 "sha256" => Ok(gix_hash::Kind::Sha256),
1431 "sha1" => Ok(gix_hash::Kind::Sha1),
1432 other => bail!(
1433 "{} declares an object format gunnar does not know: {other:?}",
1434 config_path.display()
1435 ),
1436 };
1437 }
1438 Ok(gix_hash::Kind::Sha1)
1439}
1440
1441fn bare_repositories(repos_root: &std::path::Path) -> Result<Vec<(String, std::path::PathBuf)>> {
1448 fn walk(
1449 dir: &std::path::Path,
1450 prefix: &str,
1451 out: &mut Vec<(String, std::path::PathBuf)>,
1452 ) -> Result<()> {
1453 let entries = match std::fs::read_dir(dir) {
1454 Ok(entries) => entries,
1455 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1456 Err(err) => return Err(err.into()),
1457 };
1458 for entry in entries {
1459 let entry = entry?;
1460 if !entry.file_type()?.is_dir() {
1461 continue;
1462 }
1463 let name = entry.file_name().to_string_lossy().into_owned();
1464 let path = entry.path();
1465 if let Some(stem) = name.strip_suffix(".git") {
1466 if path.join("HEAD").exists() {
1467 out.push((format!("{prefix}{stem}"), path));
1468 continue;
1469 }
1470 }
1471 walk(&path, &format!("{prefix}{name}/"), out)?;
1474 }
1475 Ok(())
1476 }
1477 let mut out = Vec::new();
1478 walk(repos_root, "", &mut out)?;
1479 out.sort();
1480 Ok(out)
1481}
1482
1483fn value(oid: Option<&gunnar_store::gix_hash::ObjectId>, symref: Option<&str>) -> String {
1488 match (oid, symref) {
1489 (Some(id), _) => id.to_string(),
1490 (None, Some(name)) => format!("ref: {name}"),
1491 (None, None) => "-".to_owned(),
1492 }
1493}
1494
1495pub fn attest(stores: &ServerStores, command: gunnar_common::cli::AttestCommand) -> Result<()> {
1501 use gunnar_common::cli::AttestCommand;
1502
1503 let log = stores.transparency();
1504 match command {
1505 AttestCommand::Head { builds } => {
1506 let log = if builds { stores.build_log() } else { log };
1507 let key = stores.transparency_key()?;
1508 let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1509 println!("size {}", signed.head.size);
1510 println!("root {}", signed.head.root);
1511 println!("signed by {}", signed.key_fingerprint);
1512 println!("appended {}", log.heads_path().display());
1513 if signed.head.size == 0 {
1514 println!();
1519 println!(
1520 "This head covers an EMPTY log: it says this server had accepted no push \
1521 when it was signed."
1522 );
1523 }
1524 Ok(())
1525 }
1526 AttestCommand::Audit { builds } => {
1527 let log = if builds { stores.build_log() } else { log };
1528 let key = stores.transparency_key()?;
1529 let verdict = log.audit(key.public_key())?;
1530 println!("{verdict}");
1531 if verdict.is_consistent() {
1532 return Ok(());
1533 }
1534 anyhow::bail!("the transparency log did not audit clean")
1538 }
1539 AttestCommand::Key => {
1540 let key = stores.transparency_key()?;
1541 println!(
1542 "{}",
1543 key.public_key()
1544 .to_openssh()
1545 .context("rendering the transparency log's public key")?
1546 );
1547 Ok(())
1548 }
1549 AttestCommand::Prove { pack_hash } => {
1550 let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1551 anyhow::bail!(
1552 "no attestation in this log names pack {pack_hash}. A proof for a record \
1553 the log does not hold is not an empty proof."
1554 )
1555 };
1556 println!("index {}", inclusion.index);
1557 println!("size {}", inclusion.size);
1558 println!("leaf {}", inclusion.leaf);
1559 println!("root {}", inclusion.root);
1560 for (step, hash) in inclusion.proof.iter().enumerate() {
1561 println!("proof[{step}] {hash}");
1562 }
1563 Ok(())
1564 }
1565 }
1566}
1567
1568pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1575 let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1576 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1577 let chain = match (&args.artifact, &args.repo) {
1578 (Some(sha256), _) => {
1579 crate::verify::verify_artifact(&stores, &plane, sha256, args.line.as_deref())?
1580 }
1581 (None, Some(repo)) => {
1582 let (namespace, name) = repo.split_once('/').with_context(|| {
1583 format!(
1584 "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1585 namespace (multi-tenancy)"
1586 )
1587 })?;
1588 crate::verify::verify(
1589 &stores,
1590 &plane,
1591 &gunnar_proto::RepoPath::new(namespace, name),
1592 &args.at,
1593 args.line.as_deref(),
1594 )?
1595 }
1596 (None, None) => anyhow::bail!("name a repository, or an --artifact to start from"),
1599 };
1600 println!("{chain}");
1601 if chain.is_unbroken() {
1602 return Ok(());
1603 }
1604 if chain.is_broken() {
1605 anyhow::bail!("the chain is broken");
1606 }
1607 anyhow::bail!("the chain is incomplete")
1608}
1609
1610pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1615 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1616 format!(
1617 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1618 namespace (multi-tenancy)",
1619 args.repo
1620 )
1621 })?;
1622 let rows = crate::blame::blame_at(
1623 data_root,
1624 &gunnar_proto::RepoPath::new(namespace, name),
1625 &args.rev,
1626 &args.path,
1627 )?;
1628 for row in &rows {
1631 let hex = row.commit.to_hex().to_string();
1632 let short = &hex[..hex.len().min(12)];
1633 println!("{short} {}\t{}", row.author, String::from_utf8_lossy(&row.line));
1634 }
1635 Ok(())
1636}
1637
1638pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1641 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1642 format!(
1643 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1644 namespace (multi-tenancy)",
1645 args.repo
1646 )
1647 })?;
1648 let limits = crate::search::Limits {
1649 max_file_bytes: args.max_file_bytes,
1650 max_matches: args.max_matches,
1651 };
1652 let hits = crate::search::search_at(
1653 data_root,
1654 &gunnar_proto::RepoPath::new(namespace, name),
1655 &args.rev,
1656 &args.pattern,
1657 limits,
1658 )?;
1659 for hit in &hits {
1660 println!(
1661 "{}:{}:{}",
1662 hit.path,
1663 hit.line_number,
1664 String::from_utf8_lossy(&hit.line)
1665 );
1666 }
1667 Ok(())
1668}
1669
1670pub fn build(stores: &ServerStores, command: gunnar_common::cli::BuildCommand) -> Result<()> {
1672 use gunnar_common::cli::BuildCommand;
1673
1674 let log = stores.build_log();
1675 match command {
1676 BuildCommand::Attest {
1677 repo,
1678 commit,
1679 recipe,
1680 toolchain,
1681 builder,
1682 network,
1683 materials,
1684 artifacts,
1685 } => {
1686 let subject = |path: &std::path::Path| -> Result<gunnar_core::Subject> {
1690 Ok(gunnar_core::Subject {
1691 name: path
1692 .file_name()
1693 .map(|n| n.to_string_lossy().into_owned())
1694 .unwrap_or_else(|| path.display().to_string()),
1695 sha256: gunnar_core::provenance::digest_file(path)
1696 .with_context(|| format!("hashing {}", path.display()))?,
1697 })
1698 };
1699 let now = gunnar_grpc::bus::now_unix_ms();
1700 let record = gunnar_core::BuildRecord {
1701 builder,
1702 source_repo: repo,
1703 source_commit: commit,
1704 recipe,
1705 toolchain,
1706 network,
1707 materials: materials
1708 .iter()
1709 .map(|p| subject(p))
1710 .collect::<Result<_>>()?,
1711 subjects: artifacts
1712 .iter()
1713 .map(|p| subject(p))
1714 .collect::<Result<_>>()?,
1715 started_at_unix_ms: now,
1716 finished_at_unix_ms: now,
1717 };
1718 let key = stores.provenance_key()?;
1719 let signed = gunnar_core::provenance::sign_build(&key, record)
1720 .context("signing the build record")?;
1721 let mut line = serde_json::to_string(&signed)?;
1722 line.push('\n');
1723 let _framed = gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))?;
1724 use std::io::Write as _;
1725 let mut file = std::fs::OpenOptions::new()
1726 .create(true)
1727 .append(true)
1728 .open(log.records_path())?;
1729 file.write_all(line.as_bytes())?;
1730
1731 println!("recorded {}", signed.record.digest());
1732 println!("builder {}", signed.record.builder);
1733 println!(
1734 "source {}@{}",
1735 signed.record.source_repo, signed.record.source_commit
1736 );
1737 println!("network {}", signed.record.network);
1738 for subject in &signed.record.subjects {
1739 println!("produced {} {}", subject.sha256, subject.name);
1740 }
1741 println!("signed by {}", signed.key_fingerprint);
1742 println!();
1743 println!(
1744 "No signed head covers it yet. `gunnar attest head --builds` is what turns this \
1745 record into evidence somebody else can check."
1746 );
1747 Ok(())
1748 }
1749 BuildCommand::Sbom { lock, artifact } => {
1750 let text = std::fs::read_to_string(&lock)
1751 .with_context(|| format!("reading {}", lock.display()))?;
1752 let packages = gunnar_core::sbom::parse_lock(&text);
1753 anyhow::ensure!(
1754 !packages.is_empty(),
1755 "{} yielded no packages. An empty SBOM is not a small one — it is a document \
1756 that says this artifact contains nothing, and that is never true.",
1757 lock.display()
1758 );
1759 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1760 .with_context(|| format!("hashing {}", artifact.display()))?;
1761 let name = artifact
1762 .file_name()
1763 .map(|n| n.to_string_lossy().into_owned())
1764 .unwrap_or_else(|| artifact.display().to_string());
1765 print!(
1769 "{}",
1770 gunnar_core::sbom::cyclonedx(&packages, &name, &sha256, &sha256)
1771 );
1772 Ok(())
1773 }
1774 BuildCommand::Admit { artifact } => {
1775 let sha256 = gunnar_core::provenance::digest_file(&artifact)
1776 .with_context(|| format!("hashing {}", artifact.display()))?;
1777 match crate::registry::admit(stores, &sha256)? {
1778 Ok(admitted) => {
1779 println!("admitted {sha256}");
1780 println!("built by {}", admitted.record.builder);
1781 println!(
1782 "source {}@{}",
1783 admitted.record.source_repo, admitted.record.source_commit
1784 );
1785 println!("record {} of the build log", admitted.index);
1786 println!(
1787 "head size {} root {}",
1788 admitted.head.size, admitted.head.root
1789 );
1790 Ok(())
1791 }
1792 Err(refusal) => {
1793 println!("refused {sha256}");
1797 println!("{refusal}");
1798 anyhow::bail!("the registry refuses this artifact")
1799 }
1800 }
1801 }
1802 BuildCommand::Run {
1803 repo,
1804 at,
1805 workspace,
1806 builder,
1807 allow_network,
1808 } => {
1809 let (namespace, name) = repo
1810 .split_once('/')
1811 .with_context(|| format!("{repo:?} is not `<namespace>/<repo>`"))?;
1812 let repo_ref = gunnar_core::RepoRef::new(namespace, name)
1813 .context("the repository path is not a legal one")?;
1814 let commit = resolve_commit(stores, &repo_ref, &at)?;
1815 let built = crate::runner::build_commit(
1816 stores,
1817 &repo_ref,
1818 commit,
1819 &workspace,
1820 &builder,
1821 allow_network,
1822 )?;
1823 println!(
1824 "built {}@{}",
1825 built.record.source_repo, built.record.source_commit
1826 );
1827 println!("recipe {}", built.record.recipe);
1828 println!("toolchain {}", built.record.toolchain);
1829 println!("network {}", built.record.network);
1830 for subject in &built.record.subjects {
1831 println!("produced {} {}", subject.sha256, subject.name);
1832 }
1833 println!("workdir {}", built.workdir.display());
1834 println!();
1835 println!(
1836 "`gunnar attest head --builds` is what turns this record into evidence \
1837 somebody else can check."
1838 );
1839 Ok(())
1840 }
1841 BuildCommand::Watch {
1842 workspace,
1843 builder,
1844 refs,
1845 allow_network,
1846 max_builds,
1847 } => {
1848 anyhow::bail!(
1849 "`gunnar build watch` needs the event bus the serving process owns, and this \
1850 command opens the stores on its own. Start the server with `--runner \
1851 --runner-workspace {} --runner-builder {}`{}{}; the runner then shares the \
1852 bus every push publishes on.\n\n\
1853 A second process cannot subscribe to an in-process bus, and a runner that \
1854 polled the log instead would build a push some time after it landed rather \
1855 than because it landed.",
1856 workspace.display(),
1857 builder,
1858 if allow_network {
1859 " --runner-allow-network"
1860 } else {
1861 ""
1862 },
1863 match (refs.is_empty(), max_builds) {
1864 (true, None) => String::new(),
1865 _ => " (--ref/--max-builds are `watch` options; the served runner builds \
1866 every branch)".to_string(),
1867 }
1868 )
1869 }
1870 BuildCommand::Image {
1871 artifact,
1872 entrypoint,
1873 builder,
1874 } => {
1875 let bytes = std::fs::read(&artifact)
1876 .with_context(|| format!("reading {}", artifact.display()))?;
1877 let artifact_digest = gunnar_core::provenance::digest_bytes(&bytes);
1878
1879 let admitted = match crate::registry::admit(stores, &artifact_digest)? {
1883 Ok(admitted) => admitted,
1884 Err(refusal) => {
1885 anyhow::bail!("the binary this image would carry is not admitted: {refusal}")
1886 }
1887 };
1888
1889 let registry = crate::oci::Registry::under(stores.root());
1890 let image = crate::oci::build_and_store(®istry, &bytes, &entrypoint)?;
1891 registry.put_blob(&image.manifest)?;
1892
1893 let now = gunnar_grpc::bus::now_unix_ms();
1897 let record = gunnar_core::BuildRecord {
1898 builder,
1899 source_repo: admitted.record.source_repo.clone(),
1900 source_commit: admitted.record.source_commit.clone(),
1901 recipe: format!("oci image of {entrypoint}"),
1902 toolchain: "gunnar oci".to_owned(),
1903 network: false,
1907 materials: vec![gunnar_core::Subject {
1908 name: artifact
1909 .file_name()
1910 .map(|n| n.to_string_lossy().into_owned())
1911 .unwrap_or_else(|| artifact.display().to_string()),
1912 sha256: artifact_digest.clone(),
1913 }],
1914 subjects: vec![gunnar_core::Subject {
1915 name: format!("{entrypoint} (oci manifest)"),
1916 sha256: image.digest.trim_start_matches("sha256:").to_owned(),
1917 }],
1918 started_at_unix_ms: now,
1919 finished_at_unix_ms: now,
1920 };
1921 let key = stores.provenance_key()?;
1922 let signed = gunnar_core::provenance::sign_build(&key, record)
1923 .context("signing the image's build record")?;
1924 let mut line = serde_json::to_string(&signed)?;
1925 line.push('\n');
1926 let _framed = gunnar_core::txlog::sink::append_line(stores.build_log().records_path(), line.trim_end_matches('\n'))?;
1927 use std::io::Write as _;
1928 let mut file = std::fs::OpenOptions::new()
1929 .create(true)
1930 .append(true)
1931 .open(stores.build_log().records_path())?;
1932 file.write_all(line.as_bytes())?;
1933
1934 println!("image {}", image.digest);
1935 println!("config {}", image.config_digest);
1936 println!("layer {}", image.layer_digest);
1937 println!("files {}", image.files);
1938 println!("from {}", artifact_digest);
1939 println!(
1940 "source {}@{}",
1941 signed.record.source_repo, signed.record.source_commit
1942 );
1943 println!();
1944 println!(
1945 "Zero CVEs is this number: {} file(s) in the image's filesystem, on no base at \
1946 all. Next: `gunnar attest head --builds`, then `gunnar registry push`.",
1947 image.files
1948 );
1949 Ok(())
1950 }
1951 BuildCommand::List => {
1952 let key = stores.provenance_key()?;
1953 let mut any = false;
1954 for line in log.lines_of()? {
1955 let signed: gunnar_core::SignedBuild =
1956 serde_json::from_str(&line).context("decoding a build record")?;
1957 let verdict = match gunnar_core::provenance::verify_build(&signed, key.public_key())
1958 {
1959 Ok(()) => "ok ".to_owned(),
1960 Err(problem) => format!("BAD ({problem})"),
1961 };
1962 println!(
1963 "{verdict} {} {}@{} {}",
1964 signed.record.digest(),
1965 signed.record.source_repo,
1966 signed.record.source_commit,
1967 signed.record.recipe
1968 );
1969 any = true;
1970 }
1971 if !any {
1972 println!("this server has attested to no builds");
1975 }
1976 Ok(())
1977 }
1978 BuildCommand::Key => {
1979 let key = stores.provenance_key()?;
1980 println!(
1981 "{}",
1982 key.public_key()
1983 .to_openssh()
1984 .context("rendering the provenance public key")?
1985 );
1986 Ok(())
1987 }
1988 }
1989}
1990
1991pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
1993 use gunnar_common::cli::ForgeCommand;
1994
1995 let log = stores.forge_log();
1996
1997 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2000 log.lines_of()?
2001 .iter()
2002 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2003 .collect()
2004 }
2005
2006 fn append(
2007 stores: &ServerStores,
2008 identity: &std::path::Path,
2009 event: gunnar_core::ForgeEvent,
2010 ) -> Result<gunnar_core::SignedEvent> {
2011 let pem = std::fs::read_to_string(identity)
2012 .with_context(|| format!("reading {}", identity.display()))?;
2013 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2014 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2015 let fingerprint = key
2020 .public_key()
2021 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2022 .to_string();
2023 let known = stores
2024 .auth()
2025 .signing_keys()?
2026 .into_iter()
2027 .any(|row| row.fingerprint_sha256 == fingerprint);
2028 anyhow::ensure!(
2029 known,
2030 "{fingerprint} is not registered on this server, so nobody reading this event later \
2031 could check its signature. Register it with `gunnar key add` first."
2032 );
2033 let signed =
2034 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2035 let mut line = serde_json::to_string(&signed)?;
2036 line.push('\n');
2037 let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2038 use std::io::Write as _;
2039 let mut file = std::fs::OpenOptions::new()
2040 .create(true)
2041 .append(true)
2042 .open(stores.forge_log().records_path())?;
2043 file.write_all(line.as_bytes())?;
2044 Ok(signed)
2045 }
2046
2047 match command {
2048 ForgeCommand::Comment {
2049 repo,
2050 pull,
2051 body,
2052 actor,
2053 identity,
2054 } => {
2055 append(
2056 stores,
2057 &identity,
2058 gunnar_core::ForgeEvent {
2059 repo: repo.clone(),
2060 pull,
2061 actor,
2062 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2063 event: gunnar_core::Event::Commented {
2064 body,
2065 path: String::new(),
2066 line: 0,
2067 blob_oid: String::new(),
2068 },
2069 },
2070 )?;
2071 println!("commented on {repo}#{pull}");
2072 Ok(())
2073 }
2074 ForgeCommand::Approve {
2075 repo,
2076 pull,
2077 commit,
2078 actor,
2079 identity,
2080 } => {
2081 let signed = append(
2082 stores,
2083 &identity,
2084 gunnar_core::ForgeEvent {
2085 repo: repo.clone(),
2086 pull,
2087 actor: actor.clone(),
2088 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2089 event: gunnar_core::Event::Approved {
2090 commit: commit.clone(),
2091 },
2092 },
2093 )?;
2094 println!("{actor} approved {commit} on {repo}#{pull}");
2095 println!("signed by {}", signed.key_fingerprint);
2096 Ok(())
2097 }
2098 ForgeCommand::Reject {
2099 repo,
2100 pull,
2101 commit,
2102 reason,
2103 actor,
2104 identity,
2105 } => {
2106 let signed = append(
2107 stores,
2108 &identity,
2109 gunnar_core::ForgeEvent {
2110 repo: repo.clone(),
2111 pull,
2112 actor: actor.clone(),
2113 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2114 event: gunnar_core::Event::Rejected {
2115 commit: commit.clone(),
2116 reason,
2117 },
2118 },
2119 )?;
2120 println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
2121 println!("signed by {}", signed.key_fingerprint);
2122 Ok(())
2123 }
2124 ForgeCommand::Show { repo, pull } => {
2125 let all = events(&log)?;
2126 let mine: Vec<gunnar_core::ForgeEvent> = all
2127 .iter()
2128 .filter(|e| e.event.repo == repo)
2129 .map(|e| e.event.clone())
2130 .collect();
2131 let state = gunnar_core::forge::fold(pull, &mine);
2132 println!("{repo}#{pull} review");
2136 println!("comments {}", state.comments);
2137 if state.approvals.is_empty() {
2138 println!("approvals none");
2139 }
2140 let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
2145 .auth()
2146 .signing_keys()?
2147 .iter()
2148 .filter_map(|row| {
2149 gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
2150 .ok()
2151 .map(|key| (row.fingerprint_sha256.clone(), key))
2152 })
2153 .collect();
2154 for (actor, commit) in &state.approvals {
2155 let signed = all.iter().rev().find(|e| {
2156 e.event.repo == repo
2157 && e.event.pull == pull
2158 && e.event.actor == *actor
2159 && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
2160 });
2161 let verdict = match signed {
2162 None => "NO EVENT".to_owned(),
2163 Some(signed) => match keys
2164 .iter()
2165 .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
2166 {
2167 None => "UNKNOWN KEY".to_owned(),
2168 Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
2169 Ok(()) => "verified".to_owned(),
2170 Err(problem) => format!("BAD ({problem})"),
2171 },
2172 },
2173 };
2174 println!("approval {actor} → {commit} [{verdict}]");
2175 }
2176 Ok(())
2177 }
2178 ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
2179 }
2180}
2181
2182fn forge_issue(
2184 stores: &ServerStores,
2185 log: &crate::attest::TransparencyLog,
2186 command: gunnar_common::cli::IssueCommand,
2187) -> Result<()> {
2188 use gunnar_common::cli::IssueCommand;
2189
2190 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
2192 log.lines_of()?
2193 .iter()
2194 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
2195 .collect()
2196 }
2197
2198 fn repo_events(
2200 log: &crate::attest::TransparencyLog,
2201 repo: &str,
2202 ) -> Result<Vec<gunnar_core::ForgeEvent>> {
2203 Ok(events(log)?
2204 .into_iter()
2205 .filter(|e| e.event.repo == repo)
2206 .map(|e| e.event)
2207 .collect())
2208 }
2209
2210 fn append(
2214 stores: &ServerStores,
2215 identity: &std::path::Path,
2216 event: gunnar_core::ForgeEvent,
2217 ) -> Result<gunnar_core::SignedEvent> {
2218 let pem = std::fs::read_to_string(identity)
2219 .with_context(|| format!("reading {}", identity.display()))?;
2220 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
2221 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
2222 let fingerprint = key
2223 .public_key()
2224 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
2225 .to_string();
2226 let known = stores
2227 .auth()
2228 .signing_keys()?
2229 .into_iter()
2230 .any(|row| row.fingerprint_sha256 == fingerprint);
2231 anyhow::ensure!(
2232 known,
2233 "{fingerprint} is not registered on this server, so nobody reading this event later \
2234 could check its signature. Register it with `gunnar key add` first."
2235 );
2236 let signed =
2237 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
2238 let mut line = serde_json::to_string(&signed)?;
2239 line.push('\n');
2240 let _framed = gunnar_core::txlog::sink::append_line(stores.forge_log().records_path(), line.trim_end_matches('\n'))?;
2241 use std::io::Write as _;
2242 let mut file = std::fs::OpenOptions::new()
2243 .create(true)
2244 .append(true)
2245 .open(stores.forge_log().records_path())?;
2246 file.write_all(line.as_bytes())?;
2247 Ok(signed)
2248 }
2249
2250 match command {
2251 IssueCommand::Open {
2252 repo,
2253 title,
2254 body,
2255 actor,
2256 identity,
2257 } => {
2258 append(
2259 stores,
2260 &identity,
2261 gunnar_core::ForgeEvent {
2262 repo: repo.clone(),
2263 pull: 0,
2265 actor: actor.clone(),
2266 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2267 event: gunnar_core::Event::IssueOpened {
2268 title: title.clone(),
2269 body,
2270 },
2271 },
2272 )?;
2273 let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
2276 .last()
2277 .map(|i| i.number)
2278 .unwrap_or(0);
2279 println!("opened {repo}#{number} {title}");
2280 println!("{actor} filed it, signed with their own key");
2281 Ok(())
2282 }
2283 IssueCommand::Comment {
2284 repo,
2285 number,
2286 body,
2287 actor,
2288 identity,
2289 } => {
2290 anyhow::ensure!(
2291 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2292 .is_some_and(|i| i.open),
2293 "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2294 evidence about nothing"
2295 );
2296 append(
2297 stores,
2298 &identity,
2299 gunnar_core::ForgeEvent {
2300 repo: repo.clone(),
2301 pull: number,
2302 actor,
2303 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2304 event: gunnar_core::Event::IssueCommented { body },
2305 },
2306 )?;
2307 println!("commented on {repo}#{number}");
2308 Ok(())
2309 }
2310 IssueCommand::Close {
2311 repo,
2312 number,
2313 reason,
2314 actor,
2315 identity,
2316 } => {
2317 anyhow::ensure!(
2318 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2319 .is_some_and(|i| i.open),
2320 "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2321 );
2322 append(
2323 stores,
2324 &identity,
2325 gunnar_core::ForgeEvent {
2326 repo: repo.clone(),
2327 pull: number,
2328 actor: actor.clone(),
2329 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2330 event: gunnar_core::Event::IssueClosed { reason },
2331 },
2332 )?;
2333 println!("{actor} closed {repo}#{number}");
2334 Ok(())
2335 }
2336 IssueCommand::List { repo } => {
2337 let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2338 if issues.is_empty() {
2339 println!("{repo} has no issues");
2340 return Ok(());
2341 }
2342 for issue in issues {
2343 let state = if issue.open { "open " } else { "closed" };
2344 println!(
2345 "{repo}#{} [{state}] {} ({} comment(s), by {})",
2346 issue.number, issue.title, issue.comments, issue.author
2347 );
2348 }
2349 Ok(())
2350 }
2351 IssueCommand::Show { repo, number } => {
2352 let all = repo_events(log, &repo)?;
2353 let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2354 anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2355 };
2356 let state = if issue.open { "open" } else { "closed" };
2357 println!("{repo}#{number} [{state}] {}", issue.title);
2358 println!("opened by {}", issue.author);
2359 if !issue.body.is_empty() {
2360 println!();
2361 println!("{}", issue.body);
2362 }
2363 println!();
2364 println!("comments {}", issue.comments);
2365 Ok(())
2366 }
2367 }
2368}
2369
2370fn resolve_commit(
2376 stores: &ServerStores,
2377 repo: &gunnar_core::RepoRef,
2378 rev: &str,
2379) -> Result<gix_hash::ObjectId> {
2380 let entry = stores
2381 .catalog()
2382 .get(repo)?
2383 .with_context(|| format!("no such repository: {repo}"))?;
2384 let store = stores.repo_store(&entry)?;
2385 let git = store.serve();
2386 if let Ok(oid) = gix_hash::ObjectId::from_hex(rev.as_bytes()) {
2387 anyhow::ensure!(
2388 oid.kind() == store.hash_kind(),
2389 "{rev} is a {} oid; this repository is {}",
2390 oid.kind(),
2391 store.hash_kind()
2392 );
2393 return Ok(oid);
2394 }
2395 let snapshot = gunnar_wire::RefSnapshot::read(git)?;
2396 let resolved = if rev == gunnar_store::HEAD {
2397 snapshot.head(git)?
2398 } else {
2399 snapshot.resolve(rev)?
2400 };
2401 resolved.with_context(|| format!("{rev} names no commit in {repo}"))
2402}
2403
2404pub fn registry(
2406 stores: &std::sync::Arc<ServerStores>,
2407 command: gunnar_common::cli::RegistryCommand,
2408) -> Result<()> {
2409 use gunnar_common::cli::RegistryCommand;
2410
2411 let registry = crate::oci::Registry::under(stores.root());
2412 match command {
2413 RegistryCommand::Push { name, tag, digest } => {
2414 let manifest = registry
2415 .get_blob(&digest)?
2416 .with_context(|| format!("{digest} is not in this registry"))?;
2417 match registry.put_manifest(stores, &name, &tag, &manifest)? {
2418 Ok(landed) => {
2419 println!(
2420 "pushed {}:{} → {}",
2421 landed.name, landed.reference, landed.digest
2422 );
2423 Ok(())
2424 }
2425 Err(refusal) => {
2426 println!("refused {digest}");
2427 println!("{refusal}");
2428 anyhow::bail!("the registry refuses this manifest")
2429 }
2430 }
2431 }
2432 RegistryCommand::Resolve { name, tag } => {
2433 let digest = registry
2434 .resolve(&name, &tag)?
2435 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2436 println!("{digest}");
2437 Ok(())
2438 }
2439 RegistryCommand::Pull { name, tag, line } => {
2440 let digest = registry
2441 .resolve(&name, &tag)?
2442 .with_context(|| format!("{name}:{tag} is not in this registry"))?;
2443 let manifest = registry
2447 .get_blob(&digest)?
2448 .with_context(|| format!("{digest} is tagged and its manifest is not stored"))?;
2449 println!("{}", String::from_utf8_lossy(&manifest));
2450 println!();
2451
2452 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(stores));
2456 let chain = crate::verify::verify_artifact(
2457 stores,
2458 &plane,
2459 digest.trim_start_matches("sha256:"),
2460 line.as_deref(),
2461 )?;
2462 println!("{chain}");
2463 if chain.is_unbroken() {
2464 return Ok(());
2465 }
2466 if chain.is_broken() {
2467 anyhow::bail!("the chain behind this image is broken");
2468 }
2469 anyhow::bail!("the chain behind this image is incomplete")
2470 }
2471 }
2472}
2473
2474#[cfg(feature = "engine-znippy")]
2479pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2480 format!(
2481 "note: a repository archive does not carry the account's settings, so {path} is NOT \
2482 encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2483 console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2484 set-encrypted {path} on where the control plane is in reach; or restore the control \
2485 document as well"
2486 )
2487}
2488
2489#[cfg(all(test, feature = "engine-znippy"))]
2490mod tests {
2491 use super::*;
2492
2493 #[test]
2494 fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2495 let path = RepoRef::new("acme", "vault").expect("a legal path");
2496 let note = encrypted_flag_note(&path);
2497 assert!(note.contains("encrypted"), "{note}");
2498 assert!(note.contains("gunnar control repo set-encrypted acme/vault on"), "the one command that sets it again: {note}");
2499 }
2500
2501 #[test]
2505 fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2506 let path = RepoRef::new("acme", "vault").expect("a legal path");
2507 let note = encrypted_flag_note(&path);
2508 assert!(note.contains("console") && note.contains("Turn encryption on"), "the console's switch: {note}");
2509 }
2510}