Skip to content

Commit f8f67f6

Browse files
committed
tree: collapse 3-into-1 commits fetch; honor repo-root .hasp.yml; no-slsa tag
- collect_online_signals: replace verify_commit + is_commit_signed + get_commit_date with a single get_commit_signals call. All three were hitting /commits/{sha} and parsing the same body. PER_REF_CALL_COST drops 6→4, halving wall time for online runs. - Api trait: new get_commit_signals method with a default impl that preserves backward compat with existing mocks; Client overrides it with a single get_commit_metadata fetch. - run_tree: load .hasp.yml from the repo root (via find_repo_root) instead of the workflow dir, matching launcher/diff behavior. - signals_tags: emit a "no-slsa" tag for slsa_verified=Some(false) so the publisher-has-none case is visible alongside the existing "slsa" tag for verified. - tree help: document that --paranoid and --no-oidc don't affect tree output (always runs static audit at policy-configured levels).
1 parent 5320fce commit f8f67f6

3 files changed

Lines changed: 84 additions & 18 deletions

File tree

‎src/cli.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,11 @@ OPTIONS:
508508
therefore always pass.
509509
--no-verify Skip all online (GitHub API) signals;
510510
render the graph from offline data only
511+
512+
NOTE:
513+
`hasp tree` always runs the static audit at the policy-configured
514+
levels to populate `findings_here`. `--paranoid` and `--no-oidc`
515+
are honored by `hasp` / `hasp diff` but do not affect tree output.
511516
-d, --dir <DIR> Workflow directory [default: .github/workflows]
512517
--allow-unsandboxed Skip sandbox preflight (dev mode)
513518
-h, --help Print this help

‎src/github/client.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,41 @@ pub(crate) trait Api {
9999
fn get_attestation(&self, _owner: &str, _repo: &str, _sha: &str) -> Result<Option<String>> {
100100
Ok(None)
101101
}
102+
103+
/// Combined commit-existence + signing + authored-date lookup. The real
104+
/// `Client` impl hits `/commits/{sha}` once and parses all three fields
105+
/// from the same response. Default impl falls back to the existing trio
106+
/// of calls so test mocks keep working unchanged.
107+
fn get_commit_signals(&self, owner: &str, repo: &str, sha: &str) -> Result<CommitSignals> {
108+
let exists = self.verify_commit(owner, repo, sha)?;
109+
if !exists {
110+
return Ok(CommitSignals::missing());
111+
}
112+
let signed = self.is_commit_signed(owner, repo, sha)?;
113+
let authored_date = self.get_commit_date(owner, repo, sha)?;
114+
Ok(CommitSignals {
115+
exists,
116+
signed,
117+
authored_date,
118+
})
119+
}
120+
}
121+
122+
#[derive(Debug, Clone, PartialEq, Eq)]
123+
pub(crate) struct CommitSignals {
124+
pub(crate) exists: bool,
125+
pub(crate) signed: bool,
126+
pub(crate) authored_date: Option<String>,
127+
}
128+
129+
impl CommitSignals {
130+
pub(crate) const fn missing() -> Self {
131+
Self {
132+
exists: false,
133+
signed: false,
134+
authored_date: None,
135+
}
136+
}
102137
}
103138

104139
// ─── Internal types ──────────────────────────────────────────────────────────
@@ -881,4 +916,20 @@ impl Api for Client {
881916
validate_sha_param(sha)?;
882917
Self::get_attestation(self, owner, repo, sha)
883918
}
919+
920+
/// One HTTP fetch against `/commits/{sha}` populates all three fields,
921+
/// replacing the 3 separate hits that the default trait impl would do.
922+
fn get_commit_signals(&self, owner: &str, repo: &str, sha: &str) -> Result<CommitSignals> {
923+
validate_component(owner, "owner")?;
924+
validate_component(repo, "repo")?;
925+
validate_sha_param(sha)?;
926+
let Some(meta) = Self::get_commit_metadata(self, owner, repo, sha)? else {
927+
return Ok(CommitSignals::missing());
928+
};
929+
Ok(CommitSignals {
930+
exists: true,
931+
signed: meta.verified,
932+
authored_date: meta.authored_date,
933+
})
934+
}
884935
}

‎src/supply_chain_graph.rs‎

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ fn signals_tags(signals: &TrustSignals) -> String {
348348
}
349349
if matches!(signals.slsa_verified, Some(true)) {
350350
parts.push("slsa");
351+
} else if matches!(signals.slsa_verified, Some(false)) {
352+
// Distinct from None ("we didn't check / API errored"): publisher
353+
// queried, no attestation published.
354+
parts.push("no-slsa");
351355
}
352356
if matches!(signals.reachable, Some(true)) {
353357
parts.push("reachable");
@@ -473,7 +477,10 @@ pub(crate) fn run_tree(args: &crate::cli::Args) -> crate::error::Result<()> {
473477
.context("Cannot resolve workflow dir")?;
474478
let scan = crate::scanner::scan_directory(&canonical_dir)?;
475479

476-
let policy = match crate::policy::Policy::load(&canonical_dir) {
480+
// Load policy from the repo root so a `.hasp.yml` at the top of the repo
481+
// is honored when --dir points at a subdirectory (matches launcher/diff).
482+
let policy_root = crate::git_util::find_repo_root(&canonical_dir);
483+
let policy = match crate::policy::Policy::load(&policy_root) {
477484
Ok(Some(p)) => p,
478485
Ok(None) => crate::policy::Policy::default(),
479486
Err(e) => {
@@ -600,9 +607,23 @@ fn collect_online_signals_with_api<A: crate::github::Api>(
600607
..TrustSignals::default()
601608
};
602609

603-
signals.sha_exists = client
604-
.verify_commit(&key.owner, &key.repo, &key.sha)
610+
// One fetch against /commits/{sha} populates exists/signed/date
611+
// instead of three separate calls hitting the same endpoint.
612+
let commit = client
613+
.get_commit_signals(&key.owner, &key.repo, &key.sha)
605614
.ok();
615+
signals.sha_exists = commit.as_ref().map(|c| c.exists);
616+
if let Some(c) = commit.as_ref()
617+
&& c.exists
618+
{
619+
signals.signed = Some(c.signed);
620+
if let Some(date) = c.authored_date.as_deref()
621+
&& let Some(commit_secs) = parse_iso8601_utc(date)
622+
&& now > 0
623+
{
624+
signals.commit_age_days = Some(((now - commit_secs) / 86_400).max(0));
625+
}
626+
}
606627

607628
let repo_info = repo_cache
608629
.entry((key.owner.clone(), key.repo.clone()))
@@ -614,8 +635,6 @@ fn collect_online_signals_with_api<A: crate::github::Api>(
614635
&& let Some(created_secs) = parse_iso8601_utc(created_at)
615636
&& now > 0
616637
{
617-
// Clamp to >= 0 so a future-dated commit / clock skew doesn't
618-
// trip the `< 30` "recent repo" penalty in score_signals.
619638
signals.repo_age_days = Some(((now - created_secs) / 86_400).max(0));
620639
}
621640
if signals.sha_exists == Some(true) {
@@ -632,15 +651,6 @@ fn collect_online_signals_with_api<A: crate::github::Api>(
632651
}
633652

634653
if signals.sha_exists == Some(true) {
635-
signals.signed = client
636-
.is_commit_signed(&key.owner, &key.repo, &key.sha)
637-
.ok();
638-
if let Ok(Some(date)) = client.get_commit_date(&key.owner, &key.repo, &key.sha)
639-
&& let Some(commit_secs) = parse_iso8601_utc(&date)
640-
&& now > 0
641-
{
642-
signals.commit_age_days = Some(((now - commit_secs) / 86_400).max(0));
643-
}
644654
// Three-way:
645655
// Ok(Some) → run the verifier, Some(true)/Some(false) by verdict.
646656
// Ok(None) → no attestation published, Some(false).
@@ -663,11 +673,11 @@ fn collect_online_signals_with_api<A: crate::github::Api>(
663673
out
664674
}
665675

666-
/// Per-ref API cost in `collect_online_signals_with_api`: `verify_commit`,
676+
/// Per-ref API cost in `collect_online_signals_with_api`: `get_commit_signals`
677+
/// (one fetch against `/commits/{sha}` for exists+signed+date),
667678
/// `get_repo_info` (cached per repo, amortizes to ~1 over many refs),
668-
/// `is_commit_reachable`, `is_commit_signed`, `get_commit_date`,
669-
/// `get_attestation`.
670-
const PER_REF_CALL_COST: usize = 6;
679+
/// `is_commit_reachable`, `get_attestation`.
680+
const PER_REF_CALL_COST: usize = 4;
671681
/// Hard cap on GitHub API calls per `hasp tree` run. Sized for ~80 unique
672682
/// refs without letting a pathological repo spin.
673683
const TREE_CALL_BUDGET: usize = 500;

0 commit comments

Comments
 (0)