Skip to main content

harn_cli/package/registry/
verify.rs

1//! Registry-v2 verification receipts and optional remote tag identity proof.
2
3use crate::package::*;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
6pub(crate) struct RegistryVerificationReceipt {
7    pub schema_version: &'static str,
8    pub source: String,
9    pub index_version: u32,
10    pub package_count: usize,
11    pub version_count: usize,
12    pub remote_resolution: bool,
13    pub resolved_git_versions: usize,
14    pub ok: bool,
15    pub errors: Vec<String>,
16}
17
18fn failed_receipt(source: &str, remote: bool, error: impl ToString) -> RegistryVerificationReceipt {
19    RegistryVerificationReceipt {
20        schema_version: "harn.package_registry_verification.v1",
21        source: source.to_string(),
22        index_version: REGISTRY_INDEX_VERSION,
23        package_count: 0,
24        version_count: 0,
25        remote_resolution: remote,
26        resolved_git_versions: 0,
27        ok: false,
28        errors: vec![error.to_string()],
29    }
30}
31
32fn resolve_git_tag(git: &str, tag: &str) -> Result<String, PackageError> {
33    let output = git_output(
34        [
35            "ls-remote".to_string(),
36            "--exit-code".to_string(),
37            "--tags".to_string(),
38            git.to_string(),
39            format!("refs/tags/{tag}"),
40            format!("refs/tags/{tag}^{{}}"),
41        ],
42        Cwd::Detached,
43    )?;
44    if !output.status.success() {
45        return Err(format!(
46            "{git} tag {tag} does not resolve: {}",
47            String::from_utf8_lossy(&output.stderr).trim()
48        )
49        .into());
50    }
51    let stdout = String::from_utf8(output.stdout)
52        .map_err(|error| format!("git ls-remote returned non-UTF-8 output: {error}"))?;
53    let mut direct = None;
54    let mut peeled = None;
55    for line in stdout.lines() {
56        let mut fields = line.split_whitespace();
57        let Some(commit) = fields.next() else {
58            continue;
59        };
60        match fields.next() {
61            Some(reference) if reference.ends_with("^{}") => peeled = Some(commit.to_owned()),
62            Some(_) => direct = Some(commit.to_owned()),
63            None => {}
64        }
65    }
66    peeled
67        .or(direct)
68        .ok_or_else(|| format!("{git} tag {tag} did not return a commit").into())
69}
70
71pub(crate) fn verify_package_registry_impl(
72    source: &str,
73    remote: bool,
74) -> RegistryVerificationReceipt {
75    let content = match read_registry_source(source) {
76        Ok(content) => content,
77        Err(error) => return failed_receipt(source, remote, error),
78    };
79    let index = match parse_package_registry_index(source, &content) {
80        Ok(index) => index,
81        Err(error) => return failed_receipt(source, remote, error),
82    };
83    let package_count = index.packages.len();
84    let version_count = index
85        .packages
86        .iter()
87        .map(|package| package.versions.len())
88        .sum();
89    let mut errors = Vec::new();
90    let mut resolved_git_versions = 0;
91    if remote {
92        for package in &index.packages {
93            for version in &package.versions {
94                let (Some(git), Some(tag), Some(expected)) = (
95                    version.git.as_deref(),
96                    version.tag.as_deref(),
97                    version.rev.as_deref(),
98                ) else {
99                    continue;
100                };
101                match resolve_git_tag(git, tag) {
102                    Ok(actual) if actual.eq_ignore_ascii_case(expected) => {
103                        resolved_git_versions += 1;
104                    }
105                    Ok(actual) => errors.push(format!(
106                        "{}@{} tag {} resolves to {}, expected {}",
107                        package.name, version.version, tag, actual, expected
108                    )),
109                    Err(error) => errors.push(format!(
110                        "{}@{} remote identity failed: {error}",
111                        package.name, version.version
112                    )),
113                }
114            }
115        }
116    }
117    RegistryVerificationReceipt {
118        schema_version: "harn.package_registry_verification.v1",
119        source: source.to_string(),
120        index_version: index.version,
121        package_count,
122        version_count,
123        remote_resolution: remote,
124        resolved_git_versions,
125        ok: errors.is_empty(),
126        errors,
127    }
128}
129
130pub fn verify_package_registry(source: &str, remote: bool, json: bool, receipt_out: Option<&Path>) {
131    let receipt = verify_package_registry_impl(source, remote);
132    let rendered = serde_json::to_string_pretty(&receipt)
133        .unwrap_or_else(|error| format!(r#"{{"ok":false,"errors":["{error}"]}}"#));
134    if let Some(path) = receipt_out {
135        if let Err(error) = fs::write(path, format!("{rendered}\n")) {
136            eprintln!(
137                "error: failed to write registry verification receipt {}: {error}",
138                path.display()
139            );
140            process::exit(1);
141        }
142    }
143    if json {
144        println!("{rendered}");
145    } else if receipt.ok {
146        println!(
147            "Verified registry v{}: {} packages, {} versions{}.",
148            receipt.index_version,
149            receipt.package_count,
150            receipt.version_count,
151            if remote {
152                format!(", {} Git tag identities", receipt.resolved_git_versions)
153            } else {
154                String::new()
155            }
156        );
157    } else {
158        for error in &receipt.errors {
159            eprintln!("error: {error}");
160        }
161    }
162    if !receipt.ok {
163        process::exit(1);
164    }
165}