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 => {
1528 let key = stores.transparency_key()?;
1529 let signed = log.issue_head(&key, gunnar_grpc::bus::now_unix_ms())?;
1530 println!("size {}", signed.head.size);
1531 println!("root {}", signed.head.root);
1532 println!("signed by {}", signed.key_fingerprint);
1533 println!("appended {}", log.heads_path().display());
1534 if signed.head.size == 0 {
1535 println!();
1540 println!(
1541 "This head covers an EMPTY log: it says this server had accepted no push \
1542 when it was signed."
1543 );
1544 }
1545 Ok(())
1546 }
1547 AttestCommand::Audit => {
1548 let key = stores.transparency_key()?;
1549 let verdict = log.audit(key.public_key())?;
1550 println!("{verdict}");
1551 if verdict.is_consistent() {
1552 return Ok(());
1553 }
1554 anyhow::bail!("the transparency log did not audit clean")
1558 }
1559 AttestCommand::Key => {
1560 let key = stores.transparency_key()?;
1561 println!(
1562 "{}",
1563 key.public_key()
1564 .to_openssh()
1565 .context("rendering the transparency log's public key")?
1566 );
1567 Ok(())
1568 }
1569 AttestCommand::Prove { pack_hash } => {
1570 let Some(inclusion) = log.prove_pack(&pack_hash)? else {
1571 anyhow::bail!(
1572 "no attestation in this log names pack {pack_hash}. A proof for a record \
1573 the log does not hold is not an empty proof."
1574 )
1575 };
1576 println!("index {}", inclusion.index);
1577 println!("size {}", inclusion.size);
1578 println!("leaf {}", inclusion.leaf);
1579 println!("root {}", inclusion.root);
1580 for (step, hash) in inclusion.proof.iter().enumerate() {
1581 println!("proof[{step}] {hash}");
1582 }
1583 Ok(())
1584 }
1585 }
1586}
1587
1588pub fn verify(data_root: &std::path::Path, args: gunnar_common::cli::VerifyArgs) -> Result<()> {
1595 let stores = std::sync::Arc::new(crate::stores::ServerStores::open(data_root)?);
1596 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
1597 let repo = &args.repo;
1598 let (namespace, name) = repo.split_once('/').with_context(|| {
1599 format!(
1600 "{repo:?} is not `<namespace>/<repo>`; every repository lives in a \
1601 namespace (multi-tenancy)"
1602 )
1603 })?;
1604 let chain = crate::verify::verify(
1605 &stores,
1606 &plane,
1607 &gunnar_proto::RepoPath::new(namespace, name),
1608 &args.at,
1609 args.line.as_deref(),
1610 )?;
1611 println!("{chain}");
1612 if chain.is_unbroken() {
1613 return Ok(());
1614 }
1615 if chain.is_broken() {
1616 anyhow::bail!("the chain is broken");
1617 }
1618 anyhow::bail!("the chain is incomplete")
1619}
1620
1621pub fn blame(data_root: &std::path::Path, args: gunnar_common::cli::BlameArgs) -> Result<()> {
1626 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1627 format!(
1628 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1629 namespace (multi-tenancy)",
1630 args.repo
1631 )
1632 })?;
1633 let rows = crate::blame::blame_at(
1634 data_root,
1635 &gunnar_proto::RepoPath::new(namespace, name),
1636 &args.rev,
1637 &args.path,
1638 )?;
1639 for row in &rows {
1642 let hex = row.commit.to_hex().to_string();
1643 let short = &hex[..hex.len().min(12)];
1644 println!(
1645 "{short} {}\t{}",
1646 row.author,
1647 String::from_utf8_lossy(&row.line)
1648 );
1649 }
1650 Ok(())
1651}
1652
1653pub fn search(data_root: &std::path::Path, args: gunnar_common::cli::SearchArgs) -> Result<()> {
1656 let (namespace, name) = args.repo.split_once('/').with_context(|| {
1657 format!(
1658 "{:?} is not `<namespace>/<repo>`; every repository lives in a \
1659 namespace (multi-tenancy)",
1660 args.repo
1661 )
1662 })?;
1663 let limits = crate::search::Limits {
1664 max_file_bytes: args.max_file_bytes,
1665 max_matches: args.max_matches,
1666 };
1667 let hits = crate::search::search_at(
1668 data_root,
1669 &gunnar_proto::RepoPath::new(namespace, name),
1670 &args.rev,
1671 &args.pattern,
1672 limits,
1673 )?;
1674 for hit in &hits {
1675 println!(
1676 "{}:{}:{}",
1677 hit.path,
1678 hit.line_number,
1679 String::from_utf8_lossy(&hit.line)
1680 );
1681 }
1682 Ok(())
1683}
1684
1685pub fn forge(stores: &ServerStores, command: gunnar_common::cli::ForgeCommand) -> Result<()> {
1687 use gunnar_common::cli::ForgeCommand;
1688
1689 let log = stores.forge_log();
1690
1691 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
1694 log.lines_of()?
1695 .iter()
1696 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
1697 .collect()
1698 }
1699
1700 fn append(
1701 stores: &ServerStores,
1702 identity: &std::path::Path,
1703 event: gunnar_core::ForgeEvent,
1704 ) -> Result<gunnar_core::SignedEvent> {
1705 let pem = std::fs::read_to_string(identity)
1706 .with_context(|| format!("reading {}", identity.display()))?;
1707 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1708 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
1709 let fingerprint = key
1714 .public_key()
1715 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
1716 .to_string();
1717 let known = stores
1718 .auth()
1719 .signing_keys()?
1720 .into_iter()
1721 .any(|row| row.fingerprint_sha256 == fingerprint);
1722 anyhow::ensure!(
1723 known,
1724 "{fingerprint} is not registered on this server, so nobody reading this event later \
1725 could check its signature. Register it with `gunnar key add` first."
1726 );
1727 let signed =
1728 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
1729 let mut line = serde_json::to_string(&signed)?;
1730 line.push('\n');
1731 let _framed = gunnar_core::txlog::sink::append_line(
1732 stores.forge_log().records_path(),
1733 line.trim_end_matches('\n'),
1734 )?;
1735 use std::io::Write as _;
1736 let mut file = std::fs::OpenOptions::new()
1737 .create(true)
1738 .append(true)
1739 .open(stores.forge_log().records_path())?;
1740 file.write_all(line.as_bytes())?;
1741 Ok(signed)
1742 }
1743
1744 match command {
1745 ForgeCommand::Comment {
1746 repo,
1747 pull,
1748 body,
1749 actor,
1750 identity,
1751 } => {
1752 append(
1753 stores,
1754 &identity,
1755 gunnar_core::ForgeEvent {
1756 repo: repo.clone(),
1757 pull,
1758 actor,
1759 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1760 event: gunnar_core::Event::Commented {
1761 body,
1762 path: String::new(),
1763 line: 0,
1764 blob_oid: String::new(),
1765 },
1766 },
1767 )?;
1768 println!("commented on {repo}#{pull}");
1769 Ok(())
1770 }
1771 ForgeCommand::Approve {
1772 repo,
1773 pull,
1774 commit,
1775 actor,
1776 identity,
1777 } => {
1778 let signed = append(
1779 stores,
1780 &identity,
1781 gunnar_core::ForgeEvent {
1782 repo: repo.clone(),
1783 pull,
1784 actor: actor.clone(),
1785 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1786 event: gunnar_core::Event::Approved {
1787 commit: commit.clone(),
1788 },
1789 },
1790 )?;
1791 println!("{actor} approved {commit} on {repo}#{pull}");
1792 println!("signed by {}", signed.key_fingerprint);
1793 Ok(())
1794 }
1795 ForgeCommand::Reject {
1796 repo,
1797 pull,
1798 commit,
1799 reason,
1800 actor,
1801 identity,
1802 } => {
1803 let signed = append(
1804 stores,
1805 &identity,
1806 gunnar_core::ForgeEvent {
1807 repo: repo.clone(),
1808 pull,
1809 actor: actor.clone(),
1810 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1811 event: gunnar_core::Event::Rejected {
1812 commit: commit.clone(),
1813 reason,
1814 },
1815 },
1816 )?;
1817 println!("{actor} withdrew their approval of {commit} on {repo}#{pull}");
1818 println!("signed by {}", signed.key_fingerprint);
1819 Ok(())
1820 }
1821 ForgeCommand::Show { repo, pull } => {
1822 let all = events(&log)?;
1823 let mine: Vec<gunnar_core::ForgeEvent> = all
1824 .iter()
1825 .filter(|e| e.event.repo == repo)
1826 .map(|e| e.event.clone())
1827 .collect();
1828 let state = gunnar_core::forge::fold(pull, &mine);
1829 println!("{repo}#{pull} review");
1833 println!("comments {}", state.comments);
1834 if state.approvals.is_empty() {
1835 println!("approvals none");
1836 }
1837 let keys: Vec<(String, gunnar_core::ssh_key::PublicKey)> = stores
1842 .auth()
1843 .signing_keys()?
1844 .iter()
1845 .filter_map(|row| {
1846 gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
1847 .ok()
1848 .map(|key| (row.fingerprint_sha256.clone(), key))
1849 })
1850 .collect();
1851 let attest_key = stores.forge_attest_public()?;
1852 for (actor, commit) in &state.approvals {
1853 let signed = all.iter().rev().find(|e| {
1854 e.event.repo == repo
1855 && e.event.pull == pull
1856 && e.event.actor == *actor
1857 && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
1858 });
1859 let verdict = match signed {
1860 None => "NO EVENT".to_owned(),
1861 Some(signed) if signed.attested.is_some() => match &attest_key {
1864 None => "UNKNOWN KEY".to_owned(),
1865 Some(key) => match gunnar_core::forge::verify_event(signed, key) {
1866 Ok(()) => signed
1867 .attested
1868 .as_ref()
1869 .map(|a| a.words())
1870 .unwrap_or_default(),
1871 Err(problem) => format!("BAD ({problem})"),
1872 },
1873 },
1874 Some(signed) => match keys
1875 .iter()
1876 .find(|(fingerprint, _)| *fingerprint == signed.key_fingerprint)
1877 {
1878 None => "UNKNOWN KEY".to_owned(),
1879 Some((_, key)) => match gunnar_core::forge::verify_event(signed, key) {
1880 Ok(()) => "verified".to_owned(),
1881 Err(problem) => format!("BAD ({problem})"),
1882 },
1883 },
1884 };
1885 println!("approval {actor} → {commit} [{verdict}]");
1886 }
1887 Ok(())
1888 }
1889 ForgeCommand::Issue(issue) => forge_issue(stores, &log, issue),
1890 }
1891}
1892
1893fn forge_issue(
1895 stores: &ServerStores,
1896 log: &crate::attest::TransparencyLog,
1897 command: gunnar_common::cli::IssueCommand,
1898) -> Result<()> {
1899 use gunnar_common::cli::IssueCommand;
1900
1901 fn events(log: &crate::attest::TransparencyLog) -> Result<Vec<gunnar_core::SignedEvent>> {
1903 log.lines_of()?
1904 .iter()
1905 .map(|line| serde_json::from_str(line).context("decoding a forge event"))
1906 .collect()
1907 }
1908
1909 fn repo_events(
1911 log: &crate::attest::TransparencyLog,
1912 repo: &str,
1913 ) -> Result<Vec<gunnar_core::ForgeEvent>> {
1914 Ok(events(log)?
1915 .into_iter()
1916 .filter(|e| e.event.repo == repo)
1917 .map(|e| e.event)
1918 .collect())
1919 }
1920
1921 fn append(
1925 stores: &ServerStores,
1926 identity: &std::path::Path,
1927 event: gunnar_core::ForgeEvent,
1928 ) -> Result<gunnar_core::SignedEvent> {
1929 let pem = std::fs::read_to_string(identity)
1930 .with_context(|| format!("reading {}", identity.display()))?;
1931 let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1932 .with_context(|| format!("{} is not an OpenSSH private key", identity.display()))?;
1933 let fingerprint = key
1934 .public_key()
1935 .fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
1936 .to_string();
1937 let known = stores
1938 .auth()
1939 .signing_keys()?
1940 .into_iter()
1941 .any(|row| row.fingerprint_sha256 == fingerprint);
1942 anyhow::ensure!(
1943 known,
1944 "{fingerprint} is not registered on this server, so nobody reading this event later \
1945 could check its signature. Register it with `gunnar key add` first."
1946 );
1947 let signed =
1948 gunnar_core::forge::sign_event(&key, event).context("signing the forge event")?;
1949 let mut line = serde_json::to_string(&signed)?;
1950 line.push('\n');
1951 let _framed = gunnar_core::txlog::sink::append_line(
1952 stores.forge_log().records_path(),
1953 line.trim_end_matches('\n'),
1954 )?;
1955 use std::io::Write as _;
1956 let mut file = std::fs::OpenOptions::new()
1957 .create(true)
1958 .append(true)
1959 .open(stores.forge_log().records_path())?;
1960 file.write_all(line.as_bytes())?;
1961 Ok(signed)
1962 }
1963
1964 match command {
1965 IssueCommand::Open {
1966 repo,
1967 title,
1968 body,
1969 actor,
1970 identity,
1971 } => {
1972 append(
1973 stores,
1974 &identity,
1975 gunnar_core::ForgeEvent {
1976 repo: repo.clone(),
1977 pull: 0,
1979 actor: actor.clone(),
1980 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
1981 event: gunnar_core::Event::IssueOpened {
1982 title: title.clone(),
1983 body,
1984 },
1985 },
1986 )?;
1987 let number = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?)
1990 .last()
1991 .map(|i| i.number)
1992 .unwrap_or(0);
1993 println!("opened {repo}#{number} {title}");
1994 println!("{actor} filed it, signed with their own key");
1995 Ok(())
1996 }
1997 IssueCommand::Comment {
1998 repo,
1999 number,
2000 body,
2001 actor,
2002 identity,
2003 } => {
2004 anyhow::ensure!(
2005 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2006 .is_some_and(|i| i.open),
2007 "no OPEN issue #{number} in {repo}: a comment on a closed or absent issue is \
2008 evidence about nothing"
2009 );
2010 append(
2011 stores,
2012 &identity,
2013 gunnar_core::ForgeEvent {
2014 repo: repo.clone(),
2015 pull: number,
2016 actor,
2017 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2018 event: gunnar_core::Event::IssueCommented { body },
2019 },
2020 )?;
2021 println!("commented on {repo}#{number}");
2022 Ok(())
2023 }
2024 IssueCommand::Close {
2025 repo,
2026 number,
2027 reason,
2028 actor,
2029 identity,
2030 } => {
2031 anyhow::ensure!(
2032 gunnar_core::forge::fold_issue(number, &repo_events(log, &repo)?)
2033 .is_some_and(|i| i.open),
2034 "no OPEN issue #{number} in {repo}: it is already closed, or was never opened"
2035 );
2036 append(
2037 stores,
2038 &identity,
2039 gunnar_core::ForgeEvent {
2040 repo: repo.clone(),
2041 pull: number,
2042 actor: actor.clone(),
2043 at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
2044 event: gunnar_core::Event::IssueClosed { reason },
2045 },
2046 )?;
2047 println!("{actor} closed {repo}#{number}");
2048 Ok(())
2049 }
2050 IssueCommand::List { repo } => {
2051 let issues = gunnar_core::forge::fold_issues(&repo_events(log, &repo)?);
2052 if issues.is_empty() {
2053 println!("{repo} has no issues");
2054 return Ok(());
2055 }
2056 for issue in issues {
2057 let state = if issue.open { "open " } else { "closed" };
2058 println!(
2059 "{repo}#{} [{state}] {} ({} comment(s), by {})",
2060 issue.number, issue.title, issue.comments, issue.author
2061 );
2062 }
2063 Ok(())
2064 }
2065 IssueCommand::Show { repo, number } => {
2066 let all = repo_events(log, &repo)?;
2067 let Some(issue) = gunnar_core::forge::fold_issue(number, &all) else {
2068 anyhow::bail!("no issue #{number} in {repo}: nobody has opened one");
2069 };
2070 let state = if issue.open { "open" } else { "closed" };
2071 println!("{repo}#{number} [{state}] {}", issue.title);
2072 println!("opened by {}", issue.author);
2073 if !issue.body.is_empty() {
2074 println!();
2075 println!("{}", issue.body);
2076 }
2077 println!();
2078 println!("comments {}", issue.comments);
2079 Ok(())
2080 }
2081 }
2082}
2083
2084#[cfg(feature = "engine-znippy")]
2089pub(crate) fn encrypted_flag_note(path: &RepoRef) -> String {
2090 format!(
2091 "note: a repository archive does not carry the account's settings, so {path} is NOT \
2092 encrypted now, whatever it was. If it was, the account admin turns it on again: at the \
2093 console (the repository's Settings, \"Turn encryption on\"), or gunnar control repo \
2094 set-encrypted {path} on where the control plane is in reach; or restore the control \
2095 document as well"
2096 )
2097}
2098
2099#[cfg(all(test, feature = "engine-znippy"))]
2100mod tests {
2101 use super::*;
2102
2103 #[test]
2104 fn a_repository_restored_without_its_control_document_is_told_its_encryption_is_not_back() {
2105 let path = RepoRef::new("acme", "vault").expect("a legal path");
2106 let note = encrypted_flag_note(&path);
2107 assert!(note.contains("encrypted"), "{note}");
2108 assert!(
2109 note.contains("gunnar control repo set-encrypted acme/vault on"),
2110 "the one command that sets it again: {note}"
2111 );
2112 }
2113
2114 #[test]
2118 fn the_note_names_the_consoles_switch_an_estate_admin_can_reach() {
2119 let path = RepoRef::new("acme", "vault").expect("a legal path");
2120 let note = encrypted_flag_note(&path);
2121 assert!(
2122 note.contains("console") && note.contains("Turn encryption on"),
2123 "the console's switch: {note}"
2124 );
2125 }
2126}