1use std::hash::{Hash, Hasher};
14use std::path::{Path, PathBuf};
15
16use crate::fingerprint::{canon, canon_key, fingerprint, key};
17use crate::format::{
18 canon_claim, field, input_fp, platform_matches, split_blocks, FORMAT, INPUTS_REF, LOCAL_PREFIX,
19 MAX_BLOCKS, NAMESPACE, NOTES_REF,
20};
21use crate::git;
22use crate::notes::{sync_mirrors, Mirror};
23use crate::spec::{plan_exempt, AtError, PlanExempt, Spec, DEPRECATED_OPT_OUT, SPEC_PATHS};
24
25pub const MAX_VERIFICATIONS: usize = 64;
29
30pub struct Verdict {
38 pub gates: Vec<String>,
41 pub trail: Vec<String>,
42 pub notes: Option<(Mirror, Mirror)>,
45 pub loud: Vec<String>,
48 pub warnings: Vec<String>,
52}
53
54impl Verdict {
55 fn nothing(reason: impl Into<String>) -> Self {
56 Verdict {
57 gates: Vec::new(),
58 trail: vec![reason.into()],
59 notes: None,
60 loud: Vec::new(),
61 warnings: Vec::new(),
62 }
63 }
64}
65
66fn push_reason(trail: &mut Vec<String>, reason: String) {
68 if trail.last() != Some(&reason) {
69 trail.push(reason);
70 }
71}
72
73fn lock_reason(root: &Path, notes_ref: &str) -> Option<String> {
76 let lock = format!("refs/notes/{notes_ref}.lock");
77 let path = git::stdout_in(root, &["rev-parse", "--git-path", &lock])?;
78 let path = root.join(path).display().to_string();
80 Path::new(&path).exists().then(|| {
81 format!("a git that was killed left a lock on refs/notes/{notes_ref}; if no git is running: rm {path}")
82 })
83}
84
85fn state_reason(notes_ref: &str, state: Mirror) -> Option<String> {
87 match state {
88 Mirror::Unreachable => Some(format!(
89 "cannot fetch refs/notes/{notes_ref} from origin (no credentials? persist-credentials: false?); local mirror not judged, running everything"
90 )),
91 Mirror::TimedOut => Some(format!(
92 "origin did not answer within {} s for refs/notes/{notes_ref}; local mirror not judged, running everything",
93 git::REMOTE_BUDGET_SECS
94 )),
95 Mirror::Unwritable => Some(format!(
96 "cannot update refs/notes/{notes_ref} to origin's copy; local mirror not judged"
97 )),
98 Mirror::Undeletable => Some(format!(
99 "origin has no refs/notes/{notes_ref} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
100 )),
101 _ => None,
102 }
103}
104
105pub fn default_signers(dir: &Path) -> Option<PathBuf> {
114 let root = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from);
115 [".forgejo/allowed_signers", ".github/allowed_signers"]
116 .into_iter()
117 .map(|rel| match &root {
118 Some(root) => root.join(rel),
119 None => dir.join(rel),
120 })
121 .find(|p| p.exists())
122}
123
124pub fn resolve_signers(dir: &Path, given: &str) -> PathBuf {
128 let path = PathBuf::from(given);
129 if path.is_absolute() {
130 return path;
131 }
132 match git::stdout_in(dir, &["rev-parse", "--show-toplevel"]) {
133 Some(root) => PathBuf::from(root).join(path),
134 None => dir.join(path),
135 }
136}
137
138pub(crate) fn union<'a>(into: &mut Vec<String>, names: impl Iterator<Item = &'a str>) {
140 for name in names {
141 if !into.iter().any(|g| g == name) {
142 into.push(name.to_string());
143 }
144 }
145}
146
147pub fn covered_gates<'a>(
152 tree_matches: bool,
153 canon_matches: bool,
154 gates: &'a str,
155 fp_of_block: impl Fn(&str) -> Option<&'a str>,
156 mut fp_head: impl FnMut(&str) -> Option<String>,
157) -> Vec<String> {
158 let mut out = Vec::new();
159 for g in gates.split_whitespace() {
160 let ok = tree_matches
161 || canon_matches
162 || match (fp_of_block(g), fp_head(g)) {
163 (Some(claimed), Some(here)) => claimed == here,
164 _ => false,
165 };
166 if ok {
167 union(&mut out, std::iter::once(g));
168 }
169 }
170 out
171}
172
173struct Judge<'a> {
176 root: PathBuf,
177 head_tree: String,
178 signers: &'a Path,
179 principal: Option<&'a str>,
180 require_platform: Option<&'a str>,
181 anywhere: &'a [String],
182 spec: Option<Spec>,
183 fp_memo: Vec<(String, Option<String>)>,
184 canon_memo: Option<Option<String>>,
185 canon_used: bool,
186 warnings: Vec<String>,
187 covered: Vec<String>,
188 trail: Vec<String>,
189 seen_notes: Vec<String>,
190 seen_blocks: Vec<u64>,
191 verifications: usize,
192 exhausted: bool,
193 tried: bool,
194}
195
196impl Judge<'_> {
197 fn fp_head(&mut self, gate: &str) -> Option<String> {
199 if let Some((_, fp)) = self.fp_memo.iter().find(|(g, _)| g == gate) {
200 return fp.clone();
201 }
202 let fp = self
203 .spec
204 .as_ref()
205 .and_then(|s| fingerprint(&self.root, &self.head_tree, s, gate).ok());
206 self.fp_memo.push((gate.to_string(), fp.clone()));
207 fp
208 }
209
210 fn canon_here(&mut self) -> Option<String> {
213 if let Some(c) = &self.canon_memo {
214 return c.clone();
215 }
216 let c = match plan_exempt(&self.root, &self.head_tree) {
217 how @ (PlanExempt::Off | PlanExempt::OffByAmontConf) => {
218 push_reason(
219 &mut self.trail,
220 "planExempt is off here; canonical trees are not used".into(),
221 );
222 if how == PlanExempt::OffByAmontConf {
223 push_reason(&mut self.trail, DEPRECATED_OPT_OUT.into());
224 union(&mut self.warnings, std::iter::once(DEPRECATED_OPT_OUT));
225 }
226 None
227 }
228 PlanExempt::Unreadable => {
229 push_reason(
230 &mut self.trail,
231 "cannot read amont.conf or attest-inputs; canonical trees are not used".into(),
232 );
233 None
234 }
235 PlanExempt::On => {
236 let c = canon(&self.root, &self.head_tree).ok();
237 if c.is_none() {
238 push_reason(
239 &mut self.trail,
240 "cannot compute the canonical tree; canonical trees are not used".into(),
241 );
242 }
243 c
244 }
245 };
246 self.canon_memo = Some(c.clone());
247 c
248 }
249
250 fn judge(&mut self, notes_ref: &str, object: &str, at: &str) {
252 if self.exhausted {
253 return;
254 }
255 let Some(listing) =
256 git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "list", object])
257 else {
258 return;
259 };
260 let note_oid = listing.split_whitespace().next().unwrap_or("").to_string();
261 if self.seen_notes.contains(¬e_oid) {
262 return;
263 }
264 self.seen_notes.push(note_oid);
265 let Some(body) = git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "show", object])
266 else {
267 return;
268 };
269 self.tried = true;
270
271 if body.contains('\r') {
274 push_reason(
275 &mut self.trail,
276 format!("note on {at} contains carriage returns; the format is LF-only"),
277 );
278 return;
279 }
280
281 let (blocks, truncated) = split_blocks(&body);
282 if truncated {
283 push_reason(
284 &mut self.trail,
285 format!("note on {at} has more than {MAX_BLOCKS} blocks; the rest were ignored"),
286 );
287 }
288 if blocks.is_empty() {
289 push_reason(
290 &mut self.trail,
291 format!("note on {at} carries no signature block"),
292 );
293 return;
294 }
295 let several = blocks.len() > 1;
296 for (i, block) in blocks.iter().enumerate() {
297 let where_ = if several {
298 format!("{at} block {}", i + 1)
299 } else {
300 at.to_string()
301 };
302 let payload = &block.payload;
303 if payload.split('\n').next() != Some(FORMAT) {
304 push_reason(
305 &mut self.trail,
306 format!("note on {where_} is not {FORMAT} — a newer producer wrote it"),
307 );
308 continue;
309 }
310 let (Some(tree), Some(gates), Some(ran_on)) = (
311 field(payload, "tree"),
312 field(payload, "gates"),
313 field(payload, "platform"),
314 ) else {
315 push_reason(
316 &mut self.trail,
317 format!("note on {where_} is missing a required field"),
318 );
319 continue;
320 };
321 if gates.is_empty() {
322 push_reason(
323 &mut self.trail,
324 "attestation lists no gates — a signed way of saying nothing".into(),
325 );
326 continue;
327 }
328 let mut h = std::collections::hash_map::DefaultHasher::new();
331 block.payload.hash(&mut h);
332 block.signature.hash(&mut h);
333 let bh = h.finish();
334 if self.seen_blocks.contains(&bh) {
335 continue;
336 }
337 self.seen_blocks.push(bh);
338 if self.verifications >= MAX_VERIFICATIONS {
339 push_reason(
340 &mut self.trail,
341 format!("the budget of {MAX_VERIFICATIONS} signature verifications is spent; remaining candidates were not read"),
342 );
343 self.exhausted = true;
344 return;
345 }
346 self.verifications += 1;
347 let signer = match crate::sign::verify(
351 NAMESPACE,
352 payload,
353 &block.signature,
354 self.signers,
355 self.principal,
356 ) {
357 Ok(signer) => signer,
358 Err(why) => {
359 push_reason(&mut self.trail, format!("note on {where_}: {why}"));
360 continue;
361 }
362 };
363 let tree_matches = tree == self.head_tree;
367 let canon_matches = !tree_matches
371 && match (
372 canon_claim(payload),
373 canon_claim(payload).and_then(|_| self.canon_here()),
374 ) {
375 (Some(claimed), Some(here)) => claimed == here,
376 _ => false,
377 };
378 let kept = covered_gates(
379 tree_matches,
380 canon_matches,
381 gates,
382 |g| input_fp(payload, g),
383 |g| self.fp_head(g),
384 );
385 if kept.is_empty() {
386 push_reason(
387 &mut self.trail,
388 format!(
389 "attested tree {tree} is not the checked-out tree {} and neither its canonical tree nor an input fingerprint of {where_} matches",
390 self.head_tree
391 ),
392 );
393 continue;
394 }
395 let how = if tree_matches {
396 ""
397 } else if canon_matches {
398 " (by canonical tree)"
399 } else {
400 " (by input fingerprint)"
401 };
402 let kept_str = kept.join(" ");
403 if platform_matches(self.require_platform, ran_on) {
406 union(&mut self.covered, kept.iter().map(String::as_str));
407 self.canon_used |= canon_matches;
408 push_reason(
409 &mut self.trail,
410 format!("covered by {signer} on {ran_on}{how}: {kept_str}"),
411 );
412 continue;
413 }
414 let accepted: Vec<&str> = kept
415 .iter()
416 .map(String::as_str)
417 .filter(|g| self.anywhere.iter().any(|a| a == g))
418 .collect();
419 union(&mut self.covered, accepted.iter().copied());
420 self.canon_used |= canon_matches && !accepted.is_empty();
421 let want = self.require_platform.unwrap_or("any");
422 let mut reason = format!("attested on {ran_on} by {signer}{how}, this leg is {want}");
423 if !accepted.is_empty() {
424 reason.push_str("; accepted anywhere: ");
425 reason.push_str(&accepted.join(" "));
426 }
427 push_reason(&mut self.trail, reason);
428 }
429 }
430}
431
432fn load_spec(root: &Path, head_tree: &str, trail: &mut Vec<String>) -> Option<Spec> {
434 match crate::spec::at(root, head_tree) {
435 Ok(found) => found.map(|(_, spec)| spec),
436 Err(AtError::Ambiguous) => {
437 push_reason(
438 trail,
439 format!(
440 "both {} and {} exist; input fingerprints disabled",
441 SPEC_PATHS[0], SPEC_PATHS[1]
442 ),
443 );
444 None
445 }
446 Err(AtError::Unreadable(path)) => {
447 push_reason(
448 trail,
449 format!("cannot read {path} from HEAD's tree; input fingerprints disabled"),
450 );
451 None
452 }
453 Err(AtError::Invalid(path, why)) => {
454 push_reason(trail, format!("{path} {why}; input fingerprints disabled"));
455 None
456 }
457 Err(AtError::Unlisted) => {
458 push_reason(
459 trail,
460 "cannot list HEAD's tree; input fingerprints disabled".into(),
461 );
462 None
463 }
464 }
465}
466
467pub fn evaluate(
476 dir: &Path,
477 signers: &Path,
478 principal: Option<&str>,
479 require_platform: Option<&str>,
480 anywhere: &[String],
481 include_local: bool,
482) -> Verdict {
483 let mut trail = Vec::new();
484 let mut loud = Vec::new();
485
486 if !signers.is_file() {
487 return Verdict::nothing(format!("{} does not exist", signers.display()));
488 }
489
490 let Some(root) = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
493 else {
494 push_reason(&mut trail, "not a git repository".into());
495 return Verdict {
496 gates: Vec::new(),
497 trail,
498 notes: None,
499 loud,
500 warnings: Vec::new(),
501 };
502 };
503
504 let (st_main, st_inputs) = sync_mirrors(&root, &mut loud);
505 let notes = Some((st_main, st_inputs));
506 for (r, st) in [(NOTES_REF, st_main), (INPUTS_REF, st_inputs)] {
507 if let Some(why) = state_reason(r, st) {
508 push_reason(&mut trail, why);
509 }
510 if let Some(why) = lock_reason(&root, r) {
511 push_reason(&mut trail, why);
512 }
513 }
514 if !include_local {
515 for r in [NOTES_REF, INPUTS_REF] {
516 let local = format!("refs/notes/{LOCAL_PREFIX}{r}");
517 if git::stdout_in(
520 &root,
521 &["notes", "--ref", &format!("{LOCAL_PREFIX}{r}"), "list"],
522 )
523 .is_some()
524 {
525 push_reason(
526 &mut trail,
527 format!("{local} holds unpushed blocks, ignored without --include-local"),
528 );
529 }
530 }
531 }
532 let refs = |mirror: &str, st: Mirror| -> Vec<String> {
534 let mut v = Vec::new();
535 if st.judged() {
536 v.push(mirror.to_string());
537 }
538 if include_local {
539 v.push(format!("{LOCAL_PREFIX}{mirror}"));
540 }
541 v
542 };
543 let refs_main = refs(NOTES_REF, st_main);
544 let refs_inputs = refs(INPUTS_REF, st_inputs);
545
546 let Some(head_tree) = git::stdout_in(&root, &["rev-parse", "HEAD^{tree}"]) else {
547 push_reason(
548 &mut trail,
549 "cannot resolve HEAD^{tree} — not a git repository?".into(),
550 );
551 return Verdict {
552 gates: Vec::new(),
553 trail,
554 notes,
555 loud,
556 warnings: Vec::new(),
557 };
558 };
559
560 let spec = load_spec(&root, &head_tree, &mut trail);
561 let mut judge = Judge {
562 root,
563 head_tree: head_tree.clone(),
564 signers,
565 principal,
566 require_platform,
567 anywhere,
568 spec,
569 fp_memo: Vec::new(),
570 canon_memo: None,
571 canon_used: false,
572 warnings: Vec::new(),
573 covered: Vec::new(),
574 trail,
575 seen_notes: Vec::new(),
576 seen_blocks: Vec::new(),
577 verifications: 0,
578 exhausted: false,
579 tried: false,
580 };
581
582 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
588 let Some(object) = git::stdout_in(
589 &judge.root,
590 &["rev-parse", "--verify", "--quiet", candidate],
591 ) else {
592 continue;
593 };
594 for r in &refs_main {
595 judge.judge(r, &object, &label(r, candidate));
596 }
597 }
598
599 let spec_gates: Vec<String> = judge
603 .spec
604 .as_ref()
605 .map(|s| s.gates.iter().map(|(g, _)| g.clone()).collect())
606 .unwrap_or_default();
607 for gate in spec_gates {
608 if judge.exhausted || judge.covered.contains(&gate) {
609 continue;
610 }
611 let Some(fp) = judge.fp_head(&gate) else {
612 push_reason(
613 &mut judge.trail,
614 format!("gate {gate}: no fingerprint here (a declared path does not exist in this tree)"),
615 );
616 continue;
617 };
618 let Ok(k) = key(&judge.root, &gate, &fp) else {
619 continue;
620 };
621 let at = format!("input {gate} {}", &fp[..12]);
622 for r in &refs_inputs {
623 judge.judge(r, &k, &label(r, &at));
624 }
625 }
626
627 if !judge.exhausted {
630 if let Some(c) = judge.canon_here() {
631 if let Ok(k) = canon_key(&judge.root, &c) {
632 let at = format!("canon {}", &c[..12]);
633 for r in &refs_inputs {
634 judge.judge(r, &k, &label(r, &at));
635 }
636 if judge.canon_used {
637 push_reason(&mut judge.trail, format!("canonical tree {c}, key {k}"));
638 }
639 }
640 }
641 }
642
643 if !judge.tried {
644 push_reason(
645 &mut judge.trail,
646 format!("no attestation found for tree {head_tree}"),
647 );
648 }
649 Verdict {
650 gates: judge.covered,
651 trail: judge.trail,
652 notes,
653 loud,
654 warnings: judge.warnings,
655 }
656}
657
658fn label(notes_ref: &str, at: &str) -> String {
660 if notes_ref.starts_with(LOCAL_PREFIX) {
661 format!("{at} (unpushed)")
662 } else {
663 at.to_string()
664 }
665}