gha-github-service-proof 1.0.1

GitHub Actions service/API compatibility checker for offline CI: GITHUB_TOKEN permissions, REST/GraphQL/gh CLI calls, OIDC, checks/statuses, releases, and issues
Documentation
use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::Utc;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;

use crate::call::{ClassifyOptions, classify};
use crate::gh_log;
use crate::model::{
    CallReport, Check, GithubServiceReceipt, OidcReport, PermissionScope, PermissionSet,
    ReceiptSummary, SCHEMA_VERSION, ToolInfo,
};
use crate::oidc::{self, IssueOptions};
use crate::permissions;
use crate::workflow;
use crate::{TOOL_NAME, TOOL_VERSION};

#[derive(Debug, Clone)]
pub struct CheckWorkflowOptions {
    pub repo_root: Utf8PathBuf,
    pub workflows: Vec<Utf8PathBuf>,
}

pub fn check_workflows(options: &CheckWorkflowOptions) -> Result<GithubServiceReceipt> {
    let workflows = workflow::scan_workflows(&options.repo_root, &options.workflows)?;
    let mut summary = ReceiptSummary::default();
    for workflow in &workflows {
        summary.add(&workflow.summary);
    }
    Ok(GithubServiceReceipt {
        schema_version: SCHEMA_VERSION,
        tool: tool_info(),
        checked_at: Utc::now(),
        mode: "check-workflow".to_owned(),
        summary,
        permissions: None,
        workflows,
        calls: Vec::new(),
        oidc: None,
        gh_log: None,
        checks: Vec::new(),
    })
}

#[derive(Debug, Clone)]
pub struct PermissionsOptions {
    pub workflow_path: Utf8PathBuf,
    pub job: Option<String>,
}

pub fn permissions_command(options: &PermissionsOptions) -> Result<GithubServiceReceipt> {
    let text = fs::read_to_string(&options.workflow_path)
        .with_context(|| format!("reading workflow {}", options.workflow_path))?;
    let stripped = text.strip_prefix('\u{feff}').unwrap_or(&text);
    let value: serde_yaml::Value = serde_yaml::from_str(stripped)
        .with_context(|| format!("parsing YAML {}", options.workflow_path))?;
    let serde_yaml::Value::Mapping(root) = value else {
        bail!("workflow root must be a YAML mapping");
    };

    let workflow_yaml = mapping_get(&root, "permissions");
    let (workflow_permissions, mut all_checks) = permissions::parse_yaml_block(workflow_yaml);

    let (job_permissions, scope) = if let Some(job_id) = &options.job {
        let jobs = mapping_get(&root, "jobs");
        let Some(serde_yaml::Value::Mapping(jobs_map)) = jobs else {
            bail!("workflow has no `jobs:` mapping");
        };
        let Some(serde_yaml::Value::Mapping(job_map)) = mapping_get(jobs_map, job_id) else {
            bail!("job '{job_id}' not found in workflow");
        };
        let job_yaml = mapping_get(job_map, "permissions");
        let (job_perms, job_checks) = permissions::parse_yaml_block(job_yaml);
        all_checks.extend(job_checks);
        (job_perms, PermissionScope::Job)
    } else {
        (None, PermissionScope::Workflow)
    };

    let resolution = permissions::resolve(workflow_permissions, job_permissions, scope);
    let mut summary = ReceiptSummary::from_checks(&all_checks);
    summary.merge_checks(&resolution.checks);

    Ok(GithubServiceReceipt {
        schema_version: SCHEMA_VERSION,
        tool: tool_info(),
        checked_at: Utc::now(),
        mode: "permissions".to_owned(),
        summary,
        permissions: Some(resolution),
        workflows: Vec::new(),
        calls: Vec::new(),
        oidc: None,
        gh_log: None,
        checks: all_checks,
    })
}

#[derive(Debug, Clone)]
pub struct CallOptions {
    pub method: String,
    pub path: String,
    pub url: Option<String>,
    pub permissions: Option<PermissionSet>,
    pub origin: Option<String>,
}

pub fn classify_call(options: &CallOptions) -> Result<GithubServiceReceipt> {
    let report = classify(ClassifyOptions {
        method: options.method.clone(),
        path: options.path.clone(),
        url: options.url.clone(),
        origin: options.origin.clone(),
        permissions: options.permissions.clone(),
    });
    let summary = ReceiptSummary::from_checks(&report.checks);
    Ok(GithubServiceReceipt {
        schema_version: SCHEMA_VERSION,
        tool: tool_info(),
        checked_at: Utc::now(),
        mode: "call".to_owned(),
        summary,
        permissions: None,
        workflows: Vec::new(),
        calls: vec![report],
        oidc: None,
        gh_log: None,
        checks: Vec::new(),
    })
}

#[derive(Debug, Clone)]
pub struct OidcOptions {
    pub audience: String,
    pub repository: String,
    pub git_ref: String,
    pub sha: String,
    pub workflow: String,
    pub job: String,
    pub run_id: String,
    pub job_workflow_ref: Option<String>,
    pub permissions: Option<PermissionSet>,
    pub ttl_seconds: Option<i64>,
    pub extra_claims: BTreeMap<String, Value>,
}

pub fn issue_oidc(options: &OidcOptions) -> Result<GithubServiceReceipt> {
    let report: OidcReport = oidc::issue(IssueOptions {
        audience: options.audience.clone(),
        repository: options.repository.clone(),
        git_ref: options.git_ref.clone(),
        sha: options.sha.clone(),
        workflow: options.workflow.clone(),
        job: options.job.clone(),
        run_id: options.run_id.clone(),
        job_workflow_ref: options.job_workflow_ref.clone(),
        permissions: options.permissions.clone(),
        now: None,
        ttl_seconds: options.ttl_seconds,
        extra_claims: options.extra_claims.clone(),
    });
    let mut summary = ReceiptSummary::from_checks(&report.checks);
    summary.merge_checks(&[]);
    Ok(GithubServiceReceipt {
        schema_version: SCHEMA_VERSION,
        tool: tool_info(),
        checked_at: Utc::now(),
        mode: "oidc".to_owned(),
        summary,
        permissions: None,
        workflows: Vec::new(),
        calls: Vec::new(),
        oidc: Some(report),
        gh_log: None,
        checks: Vec::new(),
    })
}

#[derive(Debug, Clone)]
pub struct GhLogOptions {
    pub log_path: Utf8PathBuf,
    pub permissions: Option<PermissionSet>,
    pub unsafe_full_payloads: bool,
}

pub fn replay_gh_log(options: &GhLogOptions) -> Result<GithubServiceReceipt> {
    let raw = fs::read_to_string(&options.log_path)
        .with_context(|| format!("reading gh-log bundle {}", options.log_path))?;
    let bundle = gh_log::parse_bundle(&raw)?;
    let report = gh_log::replay(gh_log::ReplayOptions {
        bundle,
        permissions: options.permissions.clone(),
        unsafe_full_payloads: options.unsafe_full_payloads,
    });
    let summary = report.summary.clone();
    Ok(GithubServiceReceipt {
        schema_version: SCHEMA_VERSION,
        tool: tool_info(),
        checked_at: Utc::now(),
        mode: "gh-log".to_owned(),
        summary,
        permissions: None,
        workflows: Vec::new(),
        calls: Vec::new(),
        oidc: None,
        gh_log: Some(report),
        checks: Vec::new(),
    })
}

fn mapping_get<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
    for (k, v) in map {
        if let serde_yaml::Value::String(s) = k {
            if s == key {
                return Some(v);
            }
        }
    }
    None
}

fn tool_info() -> ToolInfo {
    ToolInfo {
        name: TOOL_NAME.to_owned(),
        version: TOOL_VERSION.to_owned(),
    }
}

pub fn check_repo_root(path: &Utf8Path) -> Result<()> {
    if !path.is_dir() {
        bail!("--repo must be an existing directory: {path}");
    }
    Ok(())
}

#[allow(dead_code)]
pub fn enrich_call_with_location(call: &mut CallReport, location: String) {
    if call.origin.is_none() {
        call.origin = Some(location);
    }
}

#[allow(dead_code)]
fn ensure_call_has_check(_: &Check) {}