harn_cli/package/registry/
verify.rs1use 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
73fn 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 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 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}