1use std::hash::{Hash, Hasher};
14use std::path::{Path, PathBuf};
15
16use crate::fingerprint::{canon, canon_key_in, fingerprint, key_in};
17use crate::format::{
18 canon_claim, field, input_fp, platform_matches, split_blocks, Names, GREEN, LEGACY,
19 LOCAL_PREFIX, MAX_BLOCKS,
20};
21use crate::git;
22use crate::notes::{sync_mirrors_of, Mirror};
23use crate::spec::{plan_exempt, AtError, PlanExempt, Spec, DEPRECATED_OPT_OUT, SPEC_PATHS};
24
25pub const MAX_VERIFICATIONS: usize = 64;
29
30#[non_exhaustive]
38pub struct Verdict {
39 pub gates: Vec<String>,
42 pub trail: Vec<String>,
43 pub notes: Option<(Mirror, Mirror)>,
46 pub legacy_notes: Option<(Mirror, Mirror)>,
49 pub verified_under: Vec<String>,
53 pub loud: Vec<String>,
56 pub warnings: Vec<String>,
60}
61
62impl Verdict {
63 pub fn nothing(reason: impl Into<String>) -> Self {
66 Verdict {
67 gates: Vec::new(),
68 trail: vec![reason.into()],
69 notes: None,
70 legacy_notes: None,
71 verified_under: Vec::new(),
72 loud: Vec::new(),
73 warnings: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum Lookup {
83 NoNote,
84 Found,
85}
86
87fn push_reason(trail: &mut Vec<String>, reason: String) {
89 if trail.last() != Some(&reason) {
90 trail.push(reason);
91 }
92}
93
94fn lock_reason(root: &Path, notes_ref: &str) -> Option<String> {
97 let lock = format!("refs/notes/{notes_ref}.lock");
98 let path = git::stdout_in(root, &["rev-parse", "--git-path", &lock])?;
99 let path = root.join(path).display().to_string();
101 Path::new(&path).exists().then(|| {
102 format!("a git that was killed left a lock on refs/notes/{notes_ref}; if no git is running: rm {path}")
103 })
104}
105
106fn state_reason(notes_ref: &str, state: Mirror) -> Option<String> {
108 match state {
109 Mirror::Unreachable => Some(format!(
110 "cannot fetch refs/notes/{notes_ref} from origin (no credentials? persist-credentials: false?); local mirror not judged, running everything"
111 )),
112 Mirror::TimedOut => Some(format!(
113 "origin did not answer within {} s for refs/notes/{notes_ref}; local mirror not judged, running everything",
114 git::REMOTE_BUDGET_SECS
115 )),
116 Mirror::Unwritable => Some(format!(
117 "cannot update refs/notes/{notes_ref} to origin's copy; local mirror not judged"
118 )),
119 Mirror::Undeletable => Some(format!(
120 "origin has no refs/notes/{notes_ref} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
121 )),
122 _ => None,
123 }
124}
125
126pub fn default_signers(dir: &Path) -> Option<PathBuf> {
135 let root = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from);
136 [".forgejo/allowed_signers", ".github/allowed_signers"]
137 .into_iter()
138 .map(|rel| match &root {
139 Some(root) => root.join(rel),
140 None => dir.join(rel),
141 })
142 .find(|p| p.exists())
143}
144
145pub fn resolve_signers(dir: &Path, given: &str) -> PathBuf {
149 let path = PathBuf::from(given);
150 if path.is_absolute() {
151 return path;
152 }
153 match git::stdout_in(dir, &["rev-parse", "--show-toplevel"]) {
154 Some(root) => PathBuf::from(root).join(path),
155 None => dir.join(path),
156 }
157}
158
159pub(crate) fn union<'a>(into: &mut Vec<String>, names: impl Iterator<Item = &'a str>) {
161 for name in names {
162 if !into.iter().any(|g| g == name) {
163 into.push(name.to_string());
164 }
165 }
166}
167
168pub fn covered_gates<'a>(
173 tree_matches: bool,
174 canon_matches: bool,
175 gates: &'a str,
176 fp_of_block: impl Fn(&str) -> Option<&'a str>,
177 mut fp_head: impl FnMut(&str) -> Option<String>,
178) -> Vec<String> {
179 let mut out = Vec::new();
180 for g in gates.split_whitespace() {
181 let ok = tree_matches
182 || canon_matches
183 || match (fp_of_block(g), fp_head(g)) {
184 (Some(claimed), Some(here)) => claimed == here,
185 _ => false,
186 };
187 if ok {
188 union(&mut out, std::iter::once(g));
189 }
190 }
191 out
192}
193
194struct Judge<'a> {
197 root: PathBuf,
198 head_tree: String,
199 signers: &'a Path,
200 principal: Option<&'a str>,
201 require_platform: Option<&'a str>,
202 anywhere: &'a [String],
203 spec: Option<Spec>,
204 fp_memo: Vec<(String, Option<String>)>,
205 canon_memo: Option<Option<String>>,
206 canon_used: bool,
207 warnings: Vec<String>,
208 covered: Vec<String>,
209 trail: Vec<String>,
210 seen_notes: Vec<String>,
211 seen_blocks: Vec<u64>,
212 verified_under: Vec<String>,
213 verifications: usize,
214 exhausted: bool,
215 tried: bool,
216}
217
218impl Judge<'_> {
219 fn fp_head(&mut self, gate: &str) -> Option<String> {
221 if let Some((_, fp)) = self.fp_memo.iter().find(|(g, _)| g == gate) {
222 return fp.clone();
223 }
224 let fp = self
225 .spec
226 .as_ref()
227 .and_then(|s| fingerprint(&self.root, &self.head_tree, s, gate).ok());
228 self.fp_memo.push((gate.to_string(), fp.clone()));
229 fp
230 }
231
232 fn canon_here(&mut self) -> Option<String> {
235 if let Some(c) = &self.canon_memo {
236 return c.clone();
237 }
238 let c = match plan_exempt(&self.root, &self.head_tree) {
239 how @ (PlanExempt::Off | PlanExempt::OffByAmontConf) => {
240 push_reason(
241 &mut self.trail,
242 "planExempt is off here; canonical trees are not used".into(),
243 );
244 if how == PlanExempt::OffByAmontConf {
245 push_reason(&mut self.trail, DEPRECATED_OPT_OUT.into());
246 union(&mut self.warnings, std::iter::once(DEPRECATED_OPT_OUT));
247 }
248 None
249 }
250 PlanExempt::Unreadable => {
251 push_reason(
252 &mut self.trail,
253 "cannot read amont.conf or attest-inputs; canonical trees are not used".into(),
254 );
255 None
256 }
257 PlanExempt::On => {
258 let c = canon(&self.root, &self.head_tree).ok();
259 if c.is_none() {
260 push_reason(
261 &mut self.trail,
262 "cannot compute the canonical tree; canonical trees are not used".into(),
263 );
264 }
265 c
266 }
267 };
268 self.canon_memo = Some(c.clone());
269 c
270 }
271
272 fn judge(&mut self, names: &Names, notes_ref: &str, object: &str, at: &str) -> Lookup {
278 let Some(listing) =
279 git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "list", object])
280 else {
281 return Lookup::NoNote;
282 };
283 if self.exhausted {
284 return Lookup::Found;
285 }
286 let note_oid = listing.split_whitespace().next().unwrap_or("").to_string();
287 if self.seen_notes.contains(¬e_oid) {
288 return Lookup::Found;
289 }
290 self.seen_notes.push(note_oid);
291 let Some(body) = git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "show", object])
292 else {
293 push_reason(
296 &mut self.trail,
297 format!("cannot read the note on {at} in refs/notes/{notes_ref}"),
298 );
299 return Lookup::Found;
300 };
301 self.tried = true;
302
303 if body.contains('\r') {
306 push_reason(
307 &mut self.trail,
308 format!("note on {at} contains carriage returns; the format is LF-only"),
309 );
310 return Lookup::Found;
311 }
312
313 let (blocks, truncated) = split_blocks(&body);
314 if truncated {
315 push_reason(
316 &mut self.trail,
317 format!("note on {at} has more than {MAX_BLOCKS} blocks; the rest were ignored"),
318 );
319 }
320 if blocks.is_empty() {
321 push_reason(
322 &mut self.trail,
323 format!("note on {at} carries no signature block"),
324 );
325 return Lookup::Found;
326 }
327 let several = blocks.len() > 1;
328 for (i, block) in blocks.iter().enumerate() {
329 let where_ = if several {
330 format!("{at} block {}", i + 1)
331 } else {
332 at.to_string()
333 };
334 let payload = &block.payload;
335 if payload.split('\n').next() != Some(names.format) {
336 push_reason(
337 &mut self.trail,
338 format!(
339 "note on {where_} is not {} — a newer producer, or the other name set, wrote it",
340 names.format
341 ),
342 );
343 continue;
344 }
345 let (Some(tree), Some(gates), Some(ran_on)) = (
346 field(payload, "tree"),
347 field(payload, "gates"),
348 field(payload, "platform"),
349 ) else {
350 push_reason(
351 &mut self.trail,
352 format!("note on {where_} is missing a required field"),
353 );
354 continue;
355 };
356 if gates.is_empty() {
357 push_reason(
358 &mut self.trail,
359 "attestation lists no gates — a signed way of saying nothing".into(),
360 );
361 continue;
362 }
363 let mut h = std::collections::hash_map::DefaultHasher::new();
366 block.payload.hash(&mut h);
367 block.signature.hash(&mut h);
368 let bh = h.finish();
369 if self.seen_blocks.contains(&bh) {
370 continue;
371 }
372 self.seen_blocks.push(bh);
373 if self.verifications >= MAX_VERIFICATIONS {
374 push_reason(
375 &mut self.trail,
376 format!("the budget of {MAX_VERIFICATIONS} signature verifications is spent; remaining candidates were not read"),
377 );
378 self.exhausted = true;
379 return Lookup::Found;
380 }
381 self.verifications += 1;
382 let signer = match crate::sign::verify(
386 names.namespace,
387 payload,
388 &block.signature,
389 self.signers,
390 self.principal,
391 ) {
392 Ok(signer) => signer,
393 Err(why) => {
394 push_reason(&mut self.trail, format!("note on {where_}: {why}"));
395 continue;
396 }
397 };
398 let tree_matches = tree == self.head_tree;
402 let canon_matches = !tree_matches
406 && match (
407 canon_claim(payload),
408 canon_claim(payload).and_then(|_| self.canon_here()),
409 ) {
410 (Some(claimed), Some(here)) => claimed == here,
411 _ => false,
412 };
413 let kept = covered_gates(
414 tree_matches,
415 canon_matches,
416 gates,
417 |g| input_fp(payload, g),
418 |g| self.fp_head(g),
419 );
420 if kept.is_empty() {
421 push_reason(
422 &mut self.trail,
423 format!(
424 "attested tree {tree} is not the checked-out tree {} and neither its canonical tree nor an input fingerprint of {where_} matches",
425 self.head_tree
426 ),
427 );
428 continue;
429 }
430 let how = if tree_matches {
431 ""
432 } else if canon_matches {
433 " (by canonical tree)"
434 } else {
435 " (by input fingerprint)"
436 };
437 let kept_str = kept.join(" ");
438 let verified_under = names.namespace;
439 if platform_matches(self.require_platform, ran_on) {
442 union(&mut self.covered, kept.iter().map(String::as_str));
443 union(&mut self.verified_under, std::iter::once(verified_under));
444 self.canon_used |= canon_matches;
445 push_reason(
446 &mut self.trail,
447 format!("covered by {signer} on {ran_on}{how}: {kept_str}"),
448 );
449 continue;
450 }
451 let accepted: Vec<&str> = kept
452 .iter()
453 .map(String::as_str)
454 .filter(|g| self.anywhere.iter().any(|a| a == g))
455 .collect();
456 union(&mut self.covered, accepted.iter().copied());
457 if !accepted.is_empty() {
458 union(&mut self.verified_under, std::iter::once(verified_under));
459 }
460 self.canon_used |= canon_matches && !accepted.is_empty();
461 let want = self.require_platform.unwrap_or("any");
462 let mut reason = format!("attested on {ran_on} by {signer}{how}, this leg is {want}");
463 if !accepted.is_empty() {
464 reason.push_str("; accepted anywhere: ");
465 reason.push_str(&accepted.join(" "));
466 }
467 push_reason(&mut self.trail, reason);
468 }
469 Lookup::Found
470 }
471}
472
473fn load_spec(root: &Path, head_tree: &str, trail: &mut Vec<String>) -> Option<Spec> {
475 match crate::spec::at(root, head_tree) {
476 Ok(found) => found.map(|(_, spec)| spec),
477 Err(AtError::Ambiguous) => {
478 push_reason(
479 trail,
480 format!(
481 "both {} and {} exist; input fingerprints disabled",
482 SPEC_PATHS[0], SPEC_PATHS[1]
483 ),
484 );
485 None
486 }
487 Err(AtError::Unreadable(path)) => {
488 push_reason(
489 trail,
490 format!("cannot read {path} from HEAD's tree; input fingerprints disabled"),
491 );
492 None
493 }
494 Err(AtError::Invalid(path, why)) => {
495 push_reason(trail, format!("{path} {why}; input fingerprints disabled"));
496 None
497 }
498 Err(AtError::Unlisted) => {
499 push_reason(
500 trail,
501 "cannot list HEAD's tree; input fingerprints disabled".into(),
502 );
503 None
504 }
505 }
506}
507
508pub fn evaluate(
517 dir: &Path,
518 signers: &Path,
519 principal: Option<&str>,
520 require_platform: Option<&str>,
521 anywhere: &[String],
522 include_local: bool,
523) -> Verdict {
524 let mut trail = Vec::new();
525 let mut loud = Vec::new();
526
527 if !signers.is_file() {
528 return Verdict::nothing(format!("{} does not exist", signers.display()));
529 }
530
531 let Some(root) = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
534 else {
535 push_reason(&mut trail, "not a git repository".into());
536 return Verdict {
537 gates: Vec::new(),
538 trail,
539 notes: None,
540 legacy_notes: None,
541 verified_under: Vec::new(),
542 loud,
543 warnings: Vec::new(),
544 };
545 };
546
547 let green_sync = sync_mirrors_of(&root, &GREEN);
554 let green = (green_sync.notes, green_sync.inputs);
555 let silent = green_sync.origin_silent();
556 loud.extend(green_sync.loud);
557 let legacy = match silent {
560 Some(state) => (state, state),
561 None => {
562 let legacy_sync = sync_mirrors_of(&root, &LEGACY);
563 loud.extend(legacy_sync.loud);
564 (legacy_sync.notes, legacy_sync.inputs)
565 }
566 };
567 let notes = Some(green);
568 let legacy_notes = Some(legacy);
569 let states = [(GREEN, green), (LEGACY, legacy)];
570 for (names, (notes_mirror, inputs_mirror)) in &states {
571 for (r, st) in [
572 (names.notes_ref, *notes_mirror),
573 (names.inputs_ref, *inputs_mirror),
574 ] {
575 if let Some(why) = state_reason(r, st) {
576 push_reason(&mut trail, why);
577 }
578 if let Some(why) = lock_reason(&root, r) {
579 push_reason(&mut trail, why);
580 }
581 }
582 if !include_local {
583 for r in [names.notes_ref, names.inputs_ref] {
584 let local = format!("refs/notes/{LOCAL_PREFIX}{r}");
585 if git::stdout_in(
588 &root,
589 &["notes", "--ref", &format!("{LOCAL_PREFIX}{r}"), "list"],
590 )
591 .is_some()
592 {
593 push_reason(
594 &mut trail,
595 format!("{local} holds unpushed blocks, ignored without --include-local"),
596 );
597 }
598 }
599 }
600 }
601 let refs = |mirror: &str, st: Mirror| -> Vec<String> {
604 let mut v = Vec::new();
605 if st.judged() {
606 v.push(mirror.to_string());
607 }
608 if include_local {
609 v.push(format!("{LOCAL_PREFIX}{mirror}"));
610 }
611 v
612 };
613 let sets: Vec<(Names, Vec<String>, Vec<String>)> = states
614 .iter()
615 .map(|(names, (notes_mirror, inputs_mirror))| {
616 (
617 *names,
618 refs(names.notes_ref, *notes_mirror),
619 refs(names.inputs_ref, *inputs_mirror),
620 )
621 })
622 .collect();
623
624 let Some(head_tree) = git::stdout_in(&root, &["rev-parse", "HEAD^{tree}"]) else {
625 push_reason(
626 &mut trail,
627 "cannot resolve HEAD^{tree} — not a git repository?".into(),
628 );
629 return Verdict {
630 gates: Vec::new(),
631 trail,
632 notes,
633 legacy_notes,
634 verified_under: Vec::new(),
635 loud,
636 warnings: Vec::new(),
637 };
638 };
639
640 let spec = load_spec(&root, &head_tree, &mut trail);
641 let mut judge = Judge {
642 root,
643 head_tree: head_tree.clone(),
644 signers,
645 principal,
646 require_platform,
647 anywhere,
648 spec,
649 fp_memo: Vec::new(),
650 canon_memo: None,
651 canon_used: false,
652 warnings: Vec::new(),
653 covered: Vec::new(),
654 trail,
655 seen_notes: Vec::new(),
656 seen_blocks: Vec::new(),
657 verified_under: Vec::new(),
658 verifications: 0,
659 exhausted: false,
660 tried: false,
661 };
662
663 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
672 let Some(object) = git::stdout_in(
673 &judge.root,
674 &["rev-parse", "--verify", "--quiet", candidate],
675 ) else {
676 continue;
677 };
678 for (names, refs_main, _) in &sets {
679 let mut found = false;
680 for r in refs_main {
681 found |= Lookup::Found == judge.judge(names, r, &object, &label(r, candidate));
682 }
683 if found {
684 break;
685 }
686 }
687 }
688
689 let spec_gates: Vec<String> = judge
694 .spec
695 .as_ref()
696 .map(|s| s.gates.iter().map(|(g, _)| g.clone()).collect())
697 .unwrap_or_default();
698 for gate in spec_gates {
699 if judge.exhausted || judge.covered.contains(&gate) {
700 continue;
701 }
702 let Some(fp) = judge.fp_head(&gate) else {
703 push_reason(
704 &mut judge.trail,
705 format!("gate {gate}: no fingerprint here (a declared path does not exist in this tree)"),
706 );
707 continue;
708 };
709 let at = format!("input {gate} {}", &fp[..12]);
710 for (names, _, refs_inputs) in &sets {
711 let Ok(k) = key_in(&judge.root, names, &gate, &fp) else {
712 continue;
713 };
714 let mut found = false;
715 for r in refs_inputs {
716 found |= Lookup::Found == judge.judge(names, r, &k, &label(r, &at));
717 }
718 if found {
719 break;
720 }
721 }
722 }
723
724 if !judge.exhausted {
727 if let Some(c) = judge.canon_here() {
728 let at = format!("canon {}", &c[..12]);
729 for (names, _, refs_inputs) in &sets {
730 let Ok(k) = canon_key_in(&judge.root, names, &c) else {
731 continue;
732 };
733 let mut found = false;
734 for r in refs_inputs {
735 found |= Lookup::Found == judge.judge(names, r, &k, &label(r, &at));
736 }
737 if found {
738 if judge.canon_used {
739 push_reason(&mut judge.trail, format!("canonical tree {c}, key {k}"));
740 }
741 break;
742 }
743 }
744 }
745 }
746
747 if !judge.tried {
748 push_reason(
749 &mut judge.trail,
750 format!("no attestation found for tree {head_tree}"),
751 );
752 }
753 Verdict {
754 gates: judge.covered,
755 trail: judge.trail,
756 notes,
757 legacy_notes,
758 verified_under: judge.verified_under,
759 loud,
760 warnings: judge.warnings,
761 }
762}
763
764fn label(notes_ref: &str, at: &str) -> String {
766 if notes_ref.starts_with(LOCAL_PREFIX) {
767 format!("{at} (unpushed)")
768 } else {
769 at.to_string()
770 }
771}