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 verified_manifests: usize,
15    pub ok: bool,
16    pub errors: Vec<String>,
17}
18
19fn failed_receipt(source: &str, remote: bool, error: impl ToString) -> RegistryVerificationReceipt {
20    RegistryVerificationReceipt {
21        schema_version: "harn.package_registry_verification.v1",
22        source: source.to_string(),
23        index_version: REGISTRY_INDEX_VERSION,
24        package_count: 0,
25        version_count: 0,
26        remote_resolution: remote,
27        resolved_git_versions: 0,
28        verified_manifests: 0,
29        ok: false,
30        errors: vec![error.to_string()],
31    }
32}
33
34fn resolve_git_tag(git: &str, tag: &str) -> Result<String, PackageError> {
35    let output = git_output(
36        [
37            "ls-remote".to_string(),
38            "--exit-code".to_string(),
39            "--tags".to_string(),
40            git.to_string(),
41            format!("refs/tags/{tag}"),
42            format!("refs/tags/{tag}^{{}}"),
43        ],
44        Cwd::Detached,
45    )?;
46    if !output.status.success() {
47        return Err(format!(
48            "{git} tag {tag} does not resolve: {}",
49            String::from_utf8_lossy(&output.stderr).trim()
50        )
51        .into());
52    }
53    let stdout = String::from_utf8(output.stdout)
54        .map_err(|error| format!("git ls-remote returned non-UTF-8 output: {error}"))?;
55    let mut direct = None;
56    let mut peeled = None;
57    for line in stdout.lines() {
58        let mut fields = line.split_whitespace();
59        let Some(commit) = fields.next() else {
60            continue;
61        };
62        match fields.next() {
63            Some(reference) if reference.ends_with("^{}") => peeled = Some(commit.to_owned()),
64            Some(_) => direct = Some(commit.to_owned()),
65            None => {}
66        }
67    }
68    peeled
69        .or(direct)
70        .ok_or_else(|| format!("{git} tag {tag} did not return a commit").into())
71}
72
73/// Prove the pinned revision's manifest declares the identity the index sells.
74///
75/// A registry entry makes two separate claims: that a package version exists,
76/// and that its bytes live at some commit. Nothing structural ties them
77/// together. An entry can advertise `0.1.0` while the manifest at its own
78/// `rev` still says `0.0.0`, and uniqueness, provenance shape, and even
79/// remote tag identity all still pass — the tag really does resolve to that
80/// commit; the commit just isn't the version being sold. Reading the manifest
81/// is the only check that closes the gap, so remote verification reads it.
82fn verify_manifest_identity(
83    git: &str,
84    commit: &str,
85    expected_package: Option<&str>,
86    expected_version: &str,
87) -> Result<(), PackageError> {
88    let checkout = unique_temp_dir(&std::env::temp_dir(), "harn-registry-verify")?;
89    let verified = (|| -> Result<(), PackageError> {
90        clone_git_commit_to(git, commit, &checkout)?;
91        let Some(manifest) = read_package_manifest_from_dir(&checkout)? else {
92            return Err(format!("{commit} has no {MANIFEST}").into());
93        };
94        let Some(package) = manifest.package else {
95            return Err(format!("{MANIFEST} at {commit} has no [package] section").into());
96        };
97        if let Some(expected) = expected_package {
98            match package.name.as_deref() {
99                Some(name) if name == expected => {}
100                Some(name) => {
101                    return Err(format!(
102                        "{MANIFEST} at {commit} declares package {name}, expected {expected}"
103                    )
104                    .into())
105                }
106                None => {
107                    return Err(format!(
108                        "{MANIFEST} at {commit} declares no package name, expected {expected}"
109                    )
110                    .into())
111                }
112            }
113        }
114        match package.version.as_deref() {
115            Some(version) if version == expected_version => Ok(()),
116            Some(version) => Err(format!(
117                "{MANIFEST} at {commit} declares version {version}, expected {expected_version}"
118            )
119            .into()),
120            None => Err(format!(
121                "{MANIFEST} at {commit} declares no version, expected {expected_version}"
122            )
123            .into()),
124        }
125    })();
126    // A verification checkout is scratch space; leaving it behind would grow
127    // the temp directory by one tree per registry version per run.
128    let _ = fs::remove_dir_all(&checkout);
129    verified
130}
131
132pub(crate) fn verify_package_registry_impl(
133    source: &str,
134    remote: bool,
135) -> RegistryVerificationReceipt {
136    let content = match read_registry_source(source) {
137        Ok(content) => content,
138        Err(error) => return failed_receipt(source, remote, error),
139    };
140    let index = match parse_package_registry_index(source, &content) {
141        Ok(index) => index,
142        Err(error) => return failed_receipt(source, remote, error),
143    };
144    let package_count = index.packages.len();
145    let version_count = index
146        .packages
147        .iter()
148        .map(|package| package.versions.len())
149        .sum();
150    let mut errors = Vec::new();
151    let mut resolved_git_versions = 0;
152    let mut verified_manifests = 0;
153    if remote {
154        for package in &index.packages {
155            for version in &package.versions {
156                // Remote verification proves what the registry can resolve. A
157                // yanked version is unresolvable by construction — resolution
158                // filters it out of range selection and refuses it by name —
159                // so the registry makes no live claim about its source and
160                // there is nothing left to prove. This is also the only way to
161                // retire a record whose upstream history can no longer be
162                // corrected, without deleting the record or restating it as
163                // something it never was.
164                if version.yanked {
165                    continue;
166                }
167                let (Some(git), Some(tag), Some(expected)) = (
168                    version.git.as_deref(),
169                    version.tag.as_deref(),
170                    version.rev.as_deref(),
171                ) else {
172                    continue;
173                };
174                match resolve_git_tag(git, tag) {
175                    Ok(actual) if actual.eq_ignore_ascii_case(expected) => {
176                        resolved_git_versions += 1;
177                        match verify_manifest_identity(
178                            git,
179                            expected,
180                            version.package.as_deref(),
181                            &version.version,
182                        ) {
183                            Ok(()) => verified_manifests += 1,
184                            Err(error) => errors.push(format!(
185                                "{}@{} manifest identity failed: {error}",
186                                package.name, version.version
187                            )),
188                        }
189                    }
190                    Ok(actual) => errors.push(format!(
191                        "{}@{} tag {} resolves to {}, expected {}",
192                        package.name, version.version, tag, actual, expected
193                    )),
194                    Err(error) => errors.push(format!(
195                        "{}@{} remote identity failed: {error}",
196                        package.name, version.version
197                    )),
198                }
199            }
200        }
201    }
202    RegistryVerificationReceipt {
203        schema_version: "harn.package_registry_verification.v1",
204        source: source.to_string(),
205        index_version: index.version,
206        package_count,
207        version_count,
208        remote_resolution: remote,
209        resolved_git_versions,
210        verified_manifests,
211        ok: errors.is_empty(),
212        errors,
213    }
214}
215
216pub fn verify_package_registry(source: &str, remote: bool, json: bool, receipt_out: Option<&Path>) {
217    let receipt = verify_package_registry_impl(source, remote);
218    let rendered = serde_json::to_string_pretty(&receipt)
219        .unwrap_or_else(|error| format!(r#"{{"ok":false,"errors":["{error}"]}}"#));
220    if let Some(path) = receipt_out {
221        if let Err(error) = fs::write(path, format!("{rendered}\n")) {
222            eprintln!(
223                "error: failed to write registry verification receipt {}: {error}",
224                path.display()
225            );
226            process::exit(1);
227        }
228    }
229    if json {
230        println!("{rendered}");
231    } else if receipt.ok {
232        println!(
233            "Verified registry v{}: {} packages, {} versions{}.",
234            receipt.index_version,
235            receipt.package_count,
236            receipt.version_count,
237            if remote {
238                format!(
239                    ", {} Git tag identities, {} manifest identities",
240                    receipt.resolved_git_versions, receipt.verified_manifests
241                )
242            } else {
243                String::new()
244            }
245        );
246    } else {
247        for error in &receipt.errors {
248            eprintln!("error: {error}");
249        }
250    }
251    if !receipt.ok {
252        process::exit(1);
253    }
254}