1use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14use std::process;
15
16use serde::Serialize;
17
18use super::*;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct OutdatedReport {
22 pub manifest_path: String,
23 pub generator_version: String,
24 pub current_harn: String,
25 pub entries: Vec<OutdatedEntry>,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct OutdatedEntry {
30 pub alias: String,
31 pub kind: String,
32 pub source: String,
33 pub current_rev: Option<String>,
34 pub current_version: Option<String>,
35 pub latest_rev: Option<String>,
36 pub latest_version: Option<String>,
37 pub status: OutdatedStatus,
38 pub registry_name: Option<String>,
39 pub note: Option<String>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum OutdatedStatus {
45 Current,
46 Outdated,
47 Unknown,
48 Skipped,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct AuditReport {
53 pub manifest_path: String,
54 pub lock_path: String,
55 pub current_harn: String,
56 pub generator_version: String,
57 pub protocol_artifact_version: String,
58 pub findings: Vec<AuditFinding>,
59 pub ok: bool,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct AuditFinding {
64 pub alias: Option<String>,
65 pub severity: AuditSeverity,
66 pub code: AuditCode,
67 pub message: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum AuditSeverity {
73 Error,
74 Warning,
75 Info,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "kebab-case")]
80pub enum AuditCode {
81 LockfileMissing,
82 LockfileStale,
83 LockfileGeneratorMismatch,
84 LockfileProtocolMismatch,
85 EntryMissingProvenance,
86 HarnCompatViolation,
87 PathDependencyInPublishable,
88 YankedRegistryVersion,
89 ContentHashMismatch,
90 ManifestDigestMismatch,
91 PackageMissing,
92 RegistryUnavailable,
93}
94
95#[derive(Debug, Clone, Serialize)]
96pub struct ArtifactDriftReport {
97 pub current_artifact_version: String,
98 pub vendored_artifact_version: Option<String>,
99 pub schema_version: u32,
100 pub vendored_schema_version: Option<u32>,
101 pub differences: Vec<String>,
102 pub ok: bool,
103}
104
105pub fn outdated_packages(refresh: bool, remote: bool, registry_override: Option<&str>, json: bool) {
106 let result = (|| -> Result<OutdatedReport, PackageError> {
107 let workspace = PackageWorkspace::from_current_dir()?;
108 outdated_packages_in(&workspace, refresh, remote, registry_override)
109 })();
110
111 match result {
112 Ok(report) if json => print_json(&report),
113 Ok(report) => print_outdated_report(&report),
114 Err(error) => {
115 eprintln!("error: {error}");
116 process::exit(1);
117 }
118 }
119}
120
121pub(crate) fn outdated_packages_in(
122 workspace: &PackageWorkspace,
123 refresh: bool,
124 remote: bool,
125 registry_override: Option<&str>,
126) -> Result<OutdatedReport, PackageError> {
127 let ctx = workspace.load_manifest_context()?;
128 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
129 format!(
130 "{} is missing; run `harn install`",
131 ctx.lock_path().display()
132 )
133 })?;
134
135 let needs_registry = lock
139 .packages
140 .iter()
141 .any(|entry| entry.registry.is_some() || registry_override.is_some());
142 let registry_index = if needs_registry {
143 try_load_registry_index(workspace, registry_override, refresh).unwrap_or(None)
144 } else {
145 None
146 };
147
148 let mut entries = Vec::new();
149 for entry in &lock.packages {
150 let kind = lock_entry_kind(entry);
151 let alias = entry.name.clone();
152 let mut report = OutdatedEntry {
153 alias: alias.clone(),
154 kind: kind.to_string(),
155 source: entry.source.clone(),
156 current_rev: entry.commit.clone(),
157 current_version: entry.package_version.clone(),
158 latest_rev: None,
159 latest_version: None,
160 status: OutdatedStatus::Unknown,
161 registry_name: entry.registry.as_ref().map(|reg| reg.name.clone()),
162 note: None,
163 };
164
165 match kind {
166 "path" => {
167 report.status = OutdatedStatus::Skipped;
168 report.note = Some(
169 "path dependencies are live-linked; rebuild to pick up changes".to_string(),
170 );
171 }
172 "registry" => {
173 let reg = entry
174 .registry
175 .as_ref()
176 .expect("registry kind requires registry provenance");
177 match registry_index.as_ref() {
178 Some(index) => match latest_registry_version_for(index, ®.name) {
179 Some(latest) => {
180 report.latest_version = Some(latest.clone());
181 report.status = if latest == reg.version {
182 OutdatedStatus::Current
183 } else {
184 OutdatedStatus::Outdated
185 };
186 }
187 None => {
188 report.status = OutdatedStatus::Unknown;
189 report.note = Some(format!("registry has no entry for {}", reg.name));
190 }
191 },
192 None => {
193 report.status = OutdatedStatus::Unknown;
194 report.note = Some("registry index unavailable".to_string());
195 }
196 }
197 }
198 "git" => {
199 if remote {
200 match resolve_remote_branch_head(entry) {
201 Ok(Some(head)) => {
202 report.latest_rev = Some(head.clone());
203 report.status = if Some(head) == entry.commit {
204 OutdatedStatus::Current
205 } else {
206 OutdatedStatus::Outdated
207 };
208 }
209 Ok(None) => {
210 report.status = OutdatedStatus::Skipped;
211 report.note = Some(
212 "git rev pin: pass --remote to probe upstream tags".to_string(),
213 );
214 }
215 Err(error) => {
216 report.status = OutdatedStatus::Unknown;
217 report.note = Some(format!("git probe failed: {error}"));
218 }
219 }
220 } else {
221 report.status = OutdatedStatus::Skipped;
222 report.note = Some(
223 "pass --remote to probe git remotes for branch HEAD drift".to_string(),
224 );
225 }
226 }
227 "archive" => {
228 report.status = OutdatedStatus::Skipped;
229 report.note =
230 Some("archive dependencies are immutable content-hash pins".to_string());
231 }
232 other => {
233 report.status = OutdatedStatus::Unknown;
234 report.note = Some(format!("unsupported lock kind '{other}'"));
235 }
236 }
237 entries.push(report);
238 }
239
240 Ok(OutdatedReport {
241 manifest_path: ctx.manifest_path().display().to_string(),
242 generator_version: lock.generator_version,
243 current_harn: env!("CARGO_PKG_VERSION").to_string(),
244 entries,
245 })
246}
247
248pub fn audit_packages(registry_override: Option<&str>, skip_materialized: bool, json: bool) {
249 let result = (|| -> Result<AuditReport, PackageError> {
250 let workspace = PackageWorkspace::from_current_dir()?;
251 audit_packages_in(&workspace, registry_override, skip_materialized)
252 })();
253
254 match result {
255 Ok(report) => {
256 let ok = report.ok;
257 if json {
258 print_json(&report);
259 } else {
260 print_audit_report(&report);
261 }
262 if !ok {
263 process::exit(1);
264 }
265 }
266 Err(error) => {
267 eprintln!("error: {error}");
268 process::exit(1);
269 }
270 }
271}
272
273pub(crate) fn audit_packages_in(
274 workspace: &PackageWorkspace,
275 registry_override: Option<&str>,
276 skip_materialized: bool,
277) -> Result<AuditReport, PackageError> {
278 let ctx = workspace.load_manifest_context()?;
279 let lock_path = ctx.lock_path();
280 let manifest_path = ctx.manifest_path();
281
282 let mut findings = Vec::new();
283
284 let lock = match LockFile::load(&lock_path)? {
285 Some(lock) => lock,
286 None => {
287 findings.push(AuditFinding {
288 alias: None,
289 severity: AuditSeverity::Error,
290 code: AuditCode::LockfileMissing,
291 message: format!("{} is missing; run `harn install`", lock_path.display()),
292 });
293 return Ok(AuditReport {
294 manifest_path: manifest_path.display().to_string(),
295 lock_path: lock_path.display().to_string(),
296 current_harn: env!("CARGO_PKG_VERSION").to_string(),
297 generator_version: String::new(),
298 protocol_artifact_version: String::new(),
299 ok: false,
300 findings,
301 });
302 }
303 };
304
305 let current_harn = env!("CARGO_PKG_VERSION").to_string();
306 if lock.generator_version != current_harn {
307 findings.push(AuditFinding {
308 alias: None,
309 severity: AuditSeverity::Warning,
310 code: AuditCode::LockfileGeneratorMismatch,
311 message: format!(
312 "harn.lock generator_version {} != current Harn {current_harn}; rerun `harn install` to refresh provenance",
313 lock.generator_version
314 ),
315 });
316 }
317 if lock.protocol_artifact_version != current_harn {
318 findings.push(AuditFinding {
319 alias: None,
320 severity: AuditSeverity::Warning,
321 code: AuditCode::LockfileProtocolMismatch,
322 message: format!(
323 "harn.lock protocol_artifact_version {} != current Harn {current_harn}; downstream protocol bindings may regenerate",
324 lock.protocol_artifact_version
325 ),
326 });
327 }
328
329 if let Err(error) = validate_lock_matches_manifest(workspace, &ctx, &lock) {
330 findings.push(AuditFinding {
331 alias: None,
332 severity: AuditSeverity::Error,
333 code: AuditCode::LockfileStale,
334 message: error.to_string(),
335 });
336 }
337
338 let needs_registry = lock
339 .packages
340 .iter()
341 .any(|entry| entry.registry.is_some() || registry_override.is_some());
342 let registry_index = if needs_registry {
343 try_load_registry_index(workspace, registry_override, false).unwrap_or_else(|error| {
344 findings.push(AuditFinding {
345 alias: None,
346 severity: AuditSeverity::Info,
347 code: AuditCode::RegistryUnavailable,
348 message: format!("registry probe skipped: {error}"),
349 });
350 None
351 })
352 } else {
353 None
354 };
355
356 let manifest_aliases: BTreeMap<&String, &Dependency> =
357 ctx.manifest.dependencies.iter().collect();
358 let snapshot = (!skip_materialized)
359 .then(|| current_package_snapshot(&ctx))
360 .transpose()?;
361
362 for entry in &lock.packages {
363 let alias = entry.name.clone();
364 let kind = lock_entry_kind(entry);
365
366 if entry.manifest_digest.is_none() || entry.package_version.is_none() {
367 findings.push(AuditFinding {
368 alias: Some(alias.clone()),
369 severity: AuditSeverity::Warning,
370 code: AuditCode::EntryMissingProvenance,
371 message: "lock entry has no resolved package version or manifest digest; run `harn install` to backfill".to_string(),
372 });
373 }
374
375 if let Some(range) = entry.harn_compat.as_deref() {
376 if !supports_current_harn(range) {
377 findings.push(AuditFinding {
378 alias: Some(alias.clone()),
379 severity: AuditSeverity::Error,
380 code: AuditCode::HarnCompatViolation,
381 message: format!(
382 "{alias} declares harn = \"{range}\" which does not include the current Harn {current_harn}"
383 ),
384 });
385 }
386 }
387
388 if matches!(kind, "git" | "registry" | "archive") {
389 if let Err(error) = audit_git_entry_integrity(
390 workspace,
391 snapshot.as_ref().map(|snapshot| snapshot.packages_root()),
392 entry,
393 ) {
394 findings.push(AuditFinding {
395 alias: Some(alias.clone()),
396 severity: AuditSeverity::Error,
397 code: AuditCode::ContentHashMismatch,
398 message: error.to_string(),
399 });
400 }
401 if !skip_materialized {
402 if let Some((expected, actual)) = detect_manifest_digest_drift(
403 snapshot
404 .as_ref()
405 .expect("materialized audit has snapshot")
406 .packages_root(),
407 entry,
408 workspace,
409 ) {
410 findings.push(AuditFinding {
411 alias: Some(alias.clone()),
412 severity: AuditSeverity::Error,
413 code: AuditCode::ManifestDigestMismatch,
414 message: format!(
415 "{alias} harn.toml digest drifted: lock recorded {expected}, materialized package now {actual}"
416 ),
417 });
418 }
419 }
420 }
421
422 if let (Some(reg), Some(index)) = (entry.registry.as_ref(), registry_index.as_ref()) {
423 if registry_version_is_yanked(index, ®.name, ®.version) {
424 findings.push(AuditFinding {
425 alias: Some(alias.clone()),
426 severity: AuditSeverity::Error,
427 code: AuditCode::YankedRegistryVersion,
428 message: format!("registry now lists {}@{} as yanked", reg.name, reg.version),
429 });
430 }
431 }
432
433 if let Some(dep) = manifest_aliases.get(&alias) {
434 if dep.local_path().is_some() && manifest_is_publishable(&ctx.manifest) {
435 findings.push(AuditFinding {
436 alias: Some(alias.clone()),
437 severity: AuditSeverity::Warning,
438 code: AuditCode::PathDependencyInPublishable,
439 message: format!(
440 "{alias} is a path dependency; replace with a git or registry pin before publishing"
441 ),
442 });
443 }
444 }
445 }
446
447 let ok = !findings
448 .iter()
449 .any(|finding| matches!(finding.severity, AuditSeverity::Error));
450
451 Ok(AuditReport {
452 manifest_path: manifest_path.display().to_string(),
453 lock_path: lock_path.display().to_string(),
454 current_harn,
455 generator_version: lock.generator_version.clone(),
456 protocol_artifact_version: lock.protocol_artifact_version,
457 findings,
458 ok,
459 })
460}
461
462pub fn artifacts_manifest(output: Option<&Path>) {
463 let body = match crate::commands::dump_protocol_artifacts::manifest_json() {
464 Ok(body) => body,
465 Err(error) => {
466 eprintln!("error: failed to render protocol manifest: {error}");
467 process::exit(1);
468 }
469 };
470 let body = if body.ends_with('\n') {
471 body
472 } else {
473 format!("{body}\n")
474 };
475 if let Some(path) = output {
476 if let Err(error) = harn_vm::atomic_io::atomic_write(path, body.as_bytes()) {
477 eprintln!("error: failed to write {}: {error}", path.display());
478 process::exit(1);
479 }
480 } else {
481 print!("{body}");
482 }
483}
484
485pub fn artifacts_check(manifest: &Path, json: bool) {
486 let report = match check_artifact_manifest(manifest) {
487 Ok(report) => report,
488 Err(error) => {
489 eprintln!("error: {error}");
490 process::exit(1);
491 }
492 };
493 let ok = report.ok;
494 if json {
495 print_json(&report);
496 } else {
497 print_artifact_drift_report(&report);
498 }
499 if !ok {
500 process::exit(1);
501 }
502}
503
504pub(crate) fn check_artifact_manifest(
505 manifest_path: &Path,
506) -> Result<ArtifactDriftReport, PackageError> {
507 let body = fs::read_to_string(manifest_path).map_err(|error| {
508 PackageError::Ops(format!(
509 "failed to read {}: {error}",
510 manifest_path.display()
511 ))
512 })?;
513 let vendored: serde_json::Value = serde_json::from_str(&body)
514 .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
515 let current_text =
516 crate::commands::dump_protocol_artifacts::manifest_json().map_err(|error| {
517 PackageError::Ops(format!("failed to render protocol manifest: {error}"))
518 })?;
519 let current: serde_json::Value = serde_json::from_str(¤t_text).map_err(|error| {
520 PackageError::Ops(format!("failed to parse generated manifest: {error}"))
521 })?;
522
523 let current_artifact_version = current
524 .get("artifactVersion")
525 .and_then(|value| value.as_str())
526 .unwrap_or_default()
527 .to_string();
528 let vendored_artifact_version = vendored
529 .get("artifactVersion")
530 .and_then(|value| value.as_str())
531 .map(str::to_string);
532 let schema_version = current
533 .get("schemaVersion")
534 .and_then(|value| value.as_u64())
535 .unwrap_or(1) as u32;
536 let vendored_schema_version = vendored
537 .get("schemaVersion")
538 .and_then(|value| value.as_u64())
539 .map(|value| value as u32);
540
541 let mut differences = diff_json("", &vendored, ¤t);
542 differences.sort();
543 differences.dedup();
544 let ok = differences.is_empty();
545 Ok(ArtifactDriftReport {
546 current_artifact_version,
547 vendored_artifact_version,
548 schema_version,
549 vendored_schema_version,
550 differences,
551 ok,
552 })
553}
554
555fn diff_json(path: &str, left: &serde_json::Value, right: &serde_json::Value) -> Vec<String> {
556 let mut out = Vec::new();
557 match (left, right) {
558 (serde_json::Value::Object(left_map), serde_json::Value::Object(right_map)) => {
559 let mut keys: Vec<&String> =
560 left_map.keys().chain(right_map.keys()).collect::<Vec<_>>();
561 keys.sort();
562 keys.dedup();
563 for key in keys {
564 let next = if path.is_empty() {
565 key.clone()
566 } else {
567 format!("{path}.{key}")
568 };
569 match (left_map.get(key), right_map.get(key)) {
570 (Some(left_value), Some(right_value)) => {
571 out.extend(diff_json(&next, left_value, right_value));
572 }
573 (Some(_), None) => out.push(format!("{next}: only in vendored manifest")),
574 (None, Some(_)) => out.push(format!("{next}: only in current Harn")),
575 (None, None) => {}
576 }
577 }
578 }
579 (serde_json::Value::Array(left_arr), serde_json::Value::Array(right_arr)) => {
580 if left_arr != right_arr {
581 out.push(format!("{path}: array contents differ"));
582 }
583 }
584 _ => {
585 if left != right {
586 out.push(format!(
587 "{path}: vendored {left} -> current {right}",
588 left = compact_value(left),
589 right = compact_value(right)
590 ));
591 }
592 }
593 }
594 out
595}
596
597fn compact_value(value: &serde_json::Value) -> String {
598 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
599}
600
601fn lock_entry_kind(entry: &LockEntry) -> &'static str {
602 if entry.source.starts_with("path+") {
603 "path"
604 } else if entry.registry.is_some() {
605 "registry"
606 } else if entry.source.starts_with("git+") {
607 "git"
608 } else if entry.source.starts_with("archive+") {
609 "archive"
610 } else {
611 "unknown"
612 }
613}
614
615fn try_load_registry_index(
616 workspace: &PackageWorkspace,
617 registry_override: Option<&str>,
618 _refresh: bool,
619) -> Result<Option<PackageRegistryIndex>, PackageError> {
620 match load_package_registry_in(workspace, registry_override) {
621 Ok((_, index)) => Ok(Some(index)),
622 Err(error) => Err(error),
623 }
624}
625
626fn latest_registry_version_for(index: &PackageRegistryIndex, name: &str) -> Option<String> {
627 index
628 .latest_unyanked_version(name)
629 .map(|version| version.to_string())
630}
631
632fn registry_version_is_yanked(index: &PackageRegistryIndex, name: &str, version: &str) -> bool {
633 index.is_version_yanked(name, version)
634}
635
636fn resolve_remote_branch_head(entry: &LockEntry) -> Result<Option<String>, PackageError> {
637 let Some(rev) = entry.rev_request.as_deref() else {
638 return Ok(None);
639 };
640 if !entry.source.starts_with("git+") {
641 return Ok(None);
642 }
643 let url = entry.source.trim_start_matches("git+");
644 let head = git_ls_remote_ref(url, rev)?;
645 Ok(head)
646}
647
648fn git_ls_remote_ref(url: &str, refname: &str) -> Result<Option<String>, PackageError> {
649 let output = git_output(["ls-remote", url, refname], None)?;
650 if !output.status.success() {
651 return Err(format!(
652 "git ls-remote {url} {refname} failed: {}",
653 String::from_utf8_lossy(&output.stderr).trim()
654 )
655 .into());
656 }
657 let stdout = String::from_utf8_lossy(&output.stdout);
658 let head = stdout
659 .lines()
660 .next()
661 .and_then(|line| line.split_whitespace().next())
662 .map(str::to_string);
663 Ok(head)
664}
665
666fn audit_git_entry_integrity(
667 workspace: &PackageWorkspace,
668 packages_root: Option<&Path>,
669 entry: &LockEntry,
670) -> Result<(), PackageError> {
671 let Some(commit) = entry.commit.as_deref() else {
672 return Err(format!("{} is missing a locked commit", entry.name).into());
673 };
674 let Some(expected_hash) = entry.content_hash.as_deref() else {
675 return Err(format!("{} is missing a content hash", entry.name).into());
676 };
677 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
678 if !cache_dir.exists() {
679 return Err(format!(
680 "{}: git cache entry missing at {}",
681 entry.name,
682 cache_dir.display()
683 )
684 .into());
685 }
686 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
687 if let Some(packages_root) = packages_root {
688 let workspace_pkg = packages_root.join(&entry.name);
689 if workspace_pkg.exists() {
690 verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
691 }
692 }
693 Ok(())
694}
695
696fn detect_manifest_digest_drift(
697 packages_root: &Path,
698 entry: &LockEntry,
699 workspace: &PackageWorkspace,
700) -> Option<(String, String)> {
701 let expected = entry.manifest_digest.as_deref()?;
702 let materialized = packages_root.join(&entry.name);
703 let manifest_path = materialized.join(MANIFEST);
704 let bytes = fs::read(&manifest_path).ok()?;
705 let actual = format!("sha256:{}", sha256_hex(&bytes));
706 if actual == expected {
707 return None;
708 }
709 let _ = workspace; Some((expected.to_string(), actual))
711}
712
713fn manifest_is_publishable(manifest: &Manifest) -> bool {
714 manifest
715 .package
716 .as_ref()
717 .and_then(|pkg| pkg.name.as_deref())
718 .is_some()
719}
720
721fn print_outdated_report(report: &OutdatedReport) {
722 if report.entries.is_empty() {
723 println!("No dependencies recorded in harn.lock.");
724 return;
725 }
726 println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
727 for entry in &report.entries {
728 let current = entry
729 .current_version
730 .as_deref()
731 .or(entry.current_rev.as_deref())
732 .unwrap_or("-");
733 let latest = entry
734 .latest_version
735 .as_deref()
736 .or(entry.latest_rev.as_deref())
737 .unwrap_or("-");
738 let status = match entry.status {
739 OutdatedStatus::Current => "current",
740 OutdatedStatus::Outdated => "outdated",
741 OutdatedStatus::Unknown => "unknown",
742 OutdatedStatus::Skipped => "skipped",
743 };
744 println!(
745 "{}\t{}\t{}\t{}\t{}\t{}",
746 entry.alias,
747 entry.kind,
748 current,
749 latest,
750 status,
751 entry.note.as_deref().unwrap_or("")
752 );
753 }
754}
755
756fn print_audit_report(report: &AuditReport) {
757 println!("manifest: {}", report.manifest_path);
758 println!("lock: {}", report.lock_path);
759 println!(
760 "harn: {} (lock generator {} / protocol {})",
761 report.current_harn, report.generator_version, report.protocol_artifact_version
762 );
763 if report.findings.is_empty() {
764 println!("No issues found.");
765 return;
766 }
767 for finding in &report.findings {
768 let severity = match finding.severity {
769 AuditSeverity::Error => "error",
770 AuditSeverity::Warning => "warn",
771 AuditSeverity::Info => "info",
772 };
773 if let Some(alias) = &finding.alias {
774 println!("[{severity}] {alias}: {}", finding.message);
775 } else {
776 println!("[{severity}] {}", finding.message);
777 }
778 }
779}
780
781fn print_artifact_drift_report(report: &ArtifactDriftReport) {
782 println!(
783 "current artifact version: {}",
784 report.current_artifact_version
785 );
786 if let Some(version) = &report.vendored_artifact_version {
787 println!("vendored artifact version: {version}");
788 } else {
789 println!("vendored artifact version: <missing>");
790 }
791 println!("schema version: {}", report.schema_version);
792 if let Some(version) = report.vendored_schema_version {
793 println!("vendored schema version: {version}");
794 }
795 if report.differences.is_empty() {
796 println!("Vendored manifest matches the current Harn protocol contract.");
797 } else {
798 println!("Differences:");
799 for diff in &report.differences {
800 println!("- {diff}");
801 }
802 }
803}
804
805fn print_json<T: Serialize>(value: &T) {
806 let body = serde_json::to_string_pretty(value)
807 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
808 println!("{body}");
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::package::test_support::*;
815
816 #[test]
817 fn lockfile_records_generator_protocol_and_per_entry_provenance() {
818 let (_repo_tmp, repo, _branch) = create_git_package_repo();
819 let project_tmp = tempfile::tempdir().unwrap();
820 let root = project_tmp.path();
821 let workspace = TestWorkspace::new(root);
822 fs::create_dir_all(root.join(".git")).unwrap();
823 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
824 fs::write(
825 root.join(MANIFEST),
826 format!(
827 r#"
828[package]
829name = "workspace"
830version = "0.1.0"
831
832[dependencies]
833acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
834"#
835 ),
836 )
837 .unwrap();
838
839 install_packages_in(workspace.env(), false, None, false).unwrap();
840 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
841 assert_eq!(lock.version, LOCK_FILE_VERSION);
842 assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
843 assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
844 let entry = lock.find("acme-lib").unwrap();
845 assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
846 assert!(entry
847 .manifest_digest
848 .as_deref()
849 .is_some_and(|digest| digest.starts_with("sha256:")));
850 }
851
852 #[test]
853 fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
854 let (_repo_tmp, repo, _branch) = create_git_package_repo();
855 let project_tmp = tempfile::tempdir().unwrap();
856 let root = project_tmp.path();
857 let workspace = TestWorkspace::new(root);
858 fs::create_dir_all(root.join(".git")).unwrap();
859 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
860 fs::write(
861 root.join(MANIFEST),
862 format!(
863 r#"
864[package]
865name = "workspace"
866version = "0.1.0"
867
868[dependencies]
869acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
870"#
871 ),
872 )
873 .unwrap();
874
875 install_packages_in(workspace.env(), false, None, false).unwrap();
876 let lock_path = root.join(LOCK_FILE);
877 let lock = LockFile::load(&lock_path).unwrap().unwrap();
878 let entry = lock.find("acme-lib").unwrap();
879
880 let v1 = format!(
884 "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
885 entry.source,
886 entry.commit.as_deref().unwrap(),
887 entry.content_hash.as_deref().unwrap(),
888 );
889 fs::write(&lock_path, v1).unwrap();
890
891 install_packages_in(workspace.env(), false, None, false).unwrap();
892 let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
893 assert_eq!(upgraded.version, LOCK_FILE_VERSION);
894 let upgraded_entry = upgraded.find("acme-lib").unwrap();
895 assert!(upgraded_entry.package_version.is_some());
896 assert!(upgraded_entry.manifest_digest.is_some());
897 }
898
899 #[test]
900 fn outdated_marks_path_dependencies_as_skipped() {
901 let dependency_tmp = tempfile::tempdir().unwrap();
902 let dep_root = dependency_tmp.path().join("openapi");
903 fs::create_dir_all(&dep_root).unwrap();
904 fs::write(
905 dep_root.join(MANIFEST),
906 r#"
907[package]
908name = "openapi"
909version = "0.1.0"
910"#,
911 )
912 .unwrap();
913 fs::write(
914 dep_root.join("lib.harn"),
915 "pub fn version() -> string { return \"v1\" }\n",
916 )
917 .unwrap();
918
919 let project_tmp = tempfile::tempdir().unwrap();
920 let root = project_tmp.path();
921 let workspace = TestWorkspace::new(root);
922 fs::create_dir_all(root.join(".git")).unwrap();
923 let dep_path = crate::format::toml_basic_string_literal(&dep_root.display().to_string());
924 fs::write(
925 root.join(MANIFEST),
926 format!(
927 r#"
928[package]
929name = "workspace"
930version = "0.1.0"
931
932[dependencies]
933openapi = {{ path = {dep_path} }}
934"#
935 ),
936 )
937 .unwrap();
938
939 install_packages_in(workspace.env(), false, None, false).unwrap();
940 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
941 let entry = report
942 .entries
943 .iter()
944 .find(|entry| entry.alias == "openapi")
945 .expect("openapi entry");
946 assert_eq!(entry.kind, "path");
947 assert!(matches!(entry.status, OutdatedStatus::Skipped));
948 }
949
950 #[test]
951 fn audit_reports_missing_lock_as_error() {
952 let project_tmp = tempfile::tempdir().unwrap();
953 let root = project_tmp.path();
954 let workspace = TestWorkspace::new(root);
955 fs::create_dir_all(root.join(".git")).unwrap();
956 fs::write(
957 root.join(MANIFEST),
958 r#"
959[package]
960name = "workspace"
961version = "0.1.0"
962"#,
963 )
964 .unwrap();
965
966 let report = audit_packages_in(workspace.env(), None, true).unwrap();
967 assert!(!report.ok);
968 assert!(report
969 .findings
970 .iter()
971 .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
972 }
973
974 #[test]
975 fn audit_flags_content_hash_tampering() {
976 let (_repo_tmp, repo, _branch) = create_git_package_repo();
977 let project_tmp = tempfile::tempdir().unwrap();
978 let root = project_tmp.path();
979 let workspace = TestWorkspace::new(root);
980 fs::create_dir_all(root.join(".git")).unwrap();
981 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
982 fs::write(
983 root.join(MANIFEST),
984 format!(
985 r#"
986[package]
987name = "workspace"
988version = "0.1.0"
989
990[dependencies]
991acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
992"#
993 ),
994 )
995 .unwrap();
996 install_packages_in(workspace.env(), false, None, false).unwrap();
997 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
998 let entry = lock.find("acme-lib").unwrap();
999 let cache_dir = git_cache_dir_in(
1000 workspace.env(),
1001 &entry.source,
1002 entry.commit.as_deref().unwrap(),
1003 )
1004 .unwrap();
1005 fs::write(
1006 cache_dir.join("lib.harn"),
1007 "pub fn value() { return \"pwned\" }\n",
1008 )
1009 .unwrap();
1010
1011 let report = audit_packages_in(workspace.env(), None, false).unwrap();
1012 assert!(!report.ok);
1013 assert!(report
1014 .findings
1015 .iter()
1016 .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1017 }
1018
1019 #[test]
1020 fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 let path = tmp.path().join("manifest.json");
1023 let stale = serde_json::json!({
1024 "schemaVersion": 1,
1025 "artifactVersion": "0.0.0",
1026 "generatedBy": "harn dump-protocol-artifacts",
1027 });
1028 fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1029 let report = check_artifact_manifest(&path).unwrap();
1030 assert!(!report.ok);
1031 assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1032 assert!(!report.differences.is_empty());
1033 }
1034
1035 #[test]
1036 fn artifacts_check_passes_for_current_manifest() {
1037 let tmp = tempfile::tempdir().unwrap();
1038 let path = tmp.path().join("manifest.json");
1039 let current = crate::commands::dump_protocol_artifacts::manifest_json().unwrap();
1040 fs::write(&path, current).unwrap();
1041 let report = check_artifact_manifest(&path).unwrap();
1042 assert!(report.ok, "expected no drift, got {:?}", report.differences);
1043 }
1044
1045 #[test]
1046 fn install_is_deterministic_across_repeated_runs() {
1047 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1048 let project_tmp = tempfile::tempdir().unwrap();
1049 let root = project_tmp.path();
1050 let workspace = TestWorkspace::new(root);
1051 fs::create_dir_all(root.join(".git")).unwrap();
1052 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1053 fs::write(
1054 root.join(MANIFEST),
1055 format!(
1056 r#"
1057[package]
1058name = "workspace"
1059version = "0.1.0"
1060
1061[dependencies]
1062acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1063"#
1064 ),
1065 )
1066 .unwrap();
1067
1068 install_packages_in(workspace.env(), false, None, false).unwrap();
1069 let first = fs::read(root.join(LOCK_FILE)).unwrap();
1070 install_packages_in(workspace.env(), false, None, false).unwrap();
1071 let second = fs::read(root.join(LOCK_FILE)).unwrap();
1072 assert_eq!(
1073 first, second,
1074 "harn.lock must be byte-for-byte stable across repeated installs"
1075 );
1076 }
1077
1078 #[test]
1079 fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1080 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1081 let project_tmp = tempfile::tempdir().unwrap();
1082 let root = project_tmp.path();
1083 let registry_path = root.join("index.toml");
1084 let workspace = TestWorkspace::new(root)
1085 .with_registry_source(registry_path.to_string_lossy().to_string());
1086 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1087 let harn_range = current_harn_range_example();
1088 fs::write(
1089 ®istry_path,
1090 format!(
1091 r#"
1092version = 1
1093
1094[[package]]
1095name = "@burin/acme-lib"
1096description = "Acme package for tests"
1097repository = "{git}"
1098license = "MIT"
1099harn = "{harn_range}"
1100
1101[[package.version]]
1102version = "1.0.0"
1103git = "{git}"
1104rev = "v1.0.0"
1105package = "acme-lib"
1106
1107[[package.version]]
1108version = "1.1.0"
1109git = "{git}"
1110rev = "v1.0.0"
1111package = "acme-lib"
1112"#
1113 ),
1114 )
1115 .unwrap();
1116 fs::create_dir_all(root.join(".git")).unwrap();
1117 fs::write(
1118 root.join(MANIFEST),
1119 r#"
1120[package]
1121name = "workspace"
1122version = "0.1.0"
1123"#,
1124 )
1125 .unwrap();
1126
1127 add_package_to(
1128 workspace.env(),
1129 "@burin/acme-lib@1.0.0",
1130 None,
1131 None,
1132 None,
1133 None,
1134 None,
1135 None,
1136 None,
1137 )
1138 .unwrap();
1139
1140 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1141 let entry = report
1142 .entries
1143 .iter()
1144 .find(|entry| entry.alias == "acme-lib")
1145 .expect("acme-lib entry");
1146 assert_eq!(entry.kind, "registry");
1147 assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1148 assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1149 assert!(matches!(entry.status, OutdatedStatus::Outdated));
1150 }
1151}