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 cwd = std::env::current_dir().map_err(|error| {
508 PackageError::Ops(format!("failed to inspect current directory: {error}"))
509 })?;
510 check_artifact_manifest_from(manifest_path, &cwd)
511}
512
513fn check_artifact_manifest_from(
514 manifest_path: &Path,
515 source_anchor: &Path,
516) -> Result<ArtifactDriftReport, PackageError> {
517 let body = fs::read_to_string(manifest_path).map_err(|error| {
518 PackageError::Ops(format!(
519 "failed to read {}: {error}",
520 manifest_path.display()
521 ))
522 })?;
523 let vendored: serde_json::Value = serde_json::from_str(&body)
524 .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
525 let current_text = crate::commands::dump_protocol_artifacts::manifest_json_from(source_anchor)
526 .map_err(|error| {
527 PackageError::Ops(format!("failed to render protocol manifest: {error}"))
528 })?;
529 let current: serde_json::Value = serde_json::from_str(¤t_text).map_err(|error| {
530 PackageError::Ops(format!("failed to parse generated manifest: {error}"))
531 })?;
532
533 let current_artifact_version = current
534 .get("artifactVersion")
535 .and_then(|value| value.as_str())
536 .unwrap_or_default()
537 .to_string();
538 let vendored_artifact_version = vendored
539 .get("artifactVersion")
540 .and_then(|value| value.as_str())
541 .map(str::to_string);
542 let schema_version = current
543 .get("schemaVersion")
544 .and_then(|value| value.as_u64())
545 .unwrap_or(1) as u32;
546 let vendored_schema_version = vendored
547 .get("schemaVersion")
548 .and_then(|value| value.as_u64())
549 .map(|value| value as u32);
550
551 let mut differences = diff_json("", &vendored, ¤t);
552 differences.sort();
553 differences.dedup();
554 let ok = differences.is_empty();
555 Ok(ArtifactDriftReport {
556 current_artifact_version,
557 vendored_artifact_version,
558 schema_version,
559 vendored_schema_version,
560 differences,
561 ok,
562 })
563}
564
565fn diff_json(path: &str, left: &serde_json::Value, right: &serde_json::Value) -> Vec<String> {
566 let mut out = Vec::new();
567 match (left, right) {
568 (serde_json::Value::Object(left_map), serde_json::Value::Object(right_map)) => {
569 let mut keys: Vec<&String> =
570 left_map.keys().chain(right_map.keys()).collect::<Vec<_>>();
571 keys.sort();
572 keys.dedup();
573 for key in keys {
574 let next = if path.is_empty() {
575 key.clone()
576 } else {
577 format!("{path}.{key}")
578 };
579 match (left_map.get(key), right_map.get(key)) {
580 (Some(left_value), Some(right_value)) => {
581 out.extend(diff_json(&next, left_value, right_value));
582 }
583 (Some(_), None) => out.push(format!("{next}: only in vendored manifest")),
584 (None, Some(_)) => out.push(format!("{next}: only in current Harn")),
585 (None, None) => {}
586 }
587 }
588 }
589 (serde_json::Value::Array(left_arr), serde_json::Value::Array(right_arr)) => {
590 if left_arr != right_arr {
591 out.push(format!("{path}: array contents differ"));
592 }
593 }
594 _ => {
595 if left != right {
596 out.push(format!(
597 "{path}: vendored {left} -> current {right}",
598 left = compact_value(left),
599 right = compact_value(right)
600 ));
601 }
602 }
603 }
604 out
605}
606
607fn compact_value(value: &serde_json::Value) -> String {
608 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
609}
610
611fn lock_entry_kind(entry: &LockEntry) -> &'static str {
612 if entry.source.starts_with("path+") {
613 "path"
614 } else if entry.registry.is_some() {
615 "registry"
616 } else if entry.source.starts_with("git+") {
617 "git"
618 } else if entry.source.starts_with("archive+") {
619 "archive"
620 } else {
621 "unknown"
622 }
623}
624
625fn try_load_registry_index(
626 workspace: &PackageWorkspace,
627 registry_override: Option<&str>,
628 _refresh: bool,
629) -> Result<Option<PackageRegistryIndex>, PackageError> {
630 match load_package_registry_in(workspace, registry_override) {
631 Ok((_, index)) => Ok(Some(index)),
632 Err(error) => Err(error),
633 }
634}
635
636fn latest_registry_version_for(index: &PackageRegistryIndex, name: &str) -> Option<String> {
637 index
638 .latest_unyanked_version(name)
639 .map(|version| version.to_string())
640}
641
642fn registry_version_is_yanked(index: &PackageRegistryIndex, name: &str, version: &str) -> bool {
643 index.is_version_yanked(name, version)
644}
645
646fn resolve_remote_branch_head(entry: &LockEntry) -> Result<Option<String>, PackageError> {
647 let Some(rev) = entry.rev_request.as_deref() else {
648 return Ok(None);
649 };
650 if !entry.source.starts_with("git+") {
651 return Ok(None);
652 }
653 let url = entry.source.trim_start_matches("git+");
654 let head = git_ls_remote_ref(url, rev)?;
655 Ok(head)
656}
657
658fn git_ls_remote_ref(url: &str, refname: &str) -> Result<Option<String>, PackageError> {
659 let output = git_output(["ls-remote", url, refname], None)?;
660 if !output.status.success() {
661 return Err(format!(
662 "git ls-remote {url} {refname} failed: {}",
663 String::from_utf8_lossy(&output.stderr).trim()
664 )
665 .into());
666 }
667 let stdout = String::from_utf8_lossy(&output.stdout);
668 let head = stdout
669 .lines()
670 .next()
671 .and_then(|line| line.split_whitespace().next())
672 .map(str::to_string);
673 Ok(head)
674}
675
676fn audit_git_entry_integrity(
677 workspace: &PackageWorkspace,
678 packages_root: Option<&Path>,
679 entry: &LockEntry,
680) -> Result<(), PackageError> {
681 let Some(commit) = entry.commit.as_deref() else {
682 return Err(format!("{} is missing a locked commit", entry.name).into());
683 };
684 let Some(expected_hash) = entry.content_hash.as_deref() else {
685 return Err(format!("{} is missing a content hash", entry.name).into());
686 };
687 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
688 if !cache_dir.exists() {
689 return Err(format!(
690 "{}: git cache entry missing at {}",
691 entry.name,
692 cache_dir.display()
693 )
694 .into());
695 }
696 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
697 if let Some(packages_root) = packages_root {
698 let workspace_pkg = packages_root.join(&entry.name);
699 if workspace_pkg.exists() {
700 verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
701 }
702 }
703 Ok(())
704}
705
706fn detect_manifest_digest_drift(
707 packages_root: &Path,
708 entry: &LockEntry,
709 workspace: &PackageWorkspace,
710) -> Option<(String, String)> {
711 let expected = entry.manifest_digest.as_deref()?;
712 let materialized = packages_root.join(&entry.name);
713 let manifest_path = materialized.join(MANIFEST);
714 let bytes = fs::read(&manifest_path).ok()?;
715 let actual = format!("sha256:{}", sha256_hex(&bytes));
716 if actual == expected {
717 return None;
718 }
719 let _ = workspace; Some((expected.to_string(), actual))
721}
722
723fn manifest_is_publishable(manifest: &Manifest) -> bool {
724 manifest
725 .package
726 .as_ref()
727 .and_then(|pkg| pkg.name.as_deref())
728 .is_some()
729}
730
731fn print_outdated_report(report: &OutdatedReport) {
732 if report.entries.is_empty() {
733 println!("No dependencies recorded in harn.lock.");
734 return;
735 }
736 println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
737 for entry in &report.entries {
738 let current = entry
739 .current_version
740 .as_deref()
741 .or(entry.current_rev.as_deref())
742 .unwrap_or("-");
743 let latest = entry
744 .latest_version
745 .as_deref()
746 .or(entry.latest_rev.as_deref())
747 .unwrap_or("-");
748 let status = match entry.status {
749 OutdatedStatus::Current => "current",
750 OutdatedStatus::Outdated => "outdated",
751 OutdatedStatus::Unknown => "unknown",
752 OutdatedStatus::Skipped => "skipped",
753 };
754 println!(
755 "{}\t{}\t{}\t{}\t{}\t{}",
756 entry.alias,
757 entry.kind,
758 current,
759 latest,
760 status,
761 entry.note.as_deref().unwrap_or("")
762 );
763 }
764}
765
766fn print_audit_report(report: &AuditReport) {
767 println!("manifest: {}", report.manifest_path);
768 println!("lock: {}", report.lock_path);
769 println!(
770 "harn: {} (lock generator {} / protocol {})",
771 report.current_harn, report.generator_version, report.protocol_artifact_version
772 );
773 if report.findings.is_empty() {
774 println!("No issues found.");
775 return;
776 }
777 for finding in &report.findings {
778 let severity = match finding.severity {
779 AuditSeverity::Error => "error",
780 AuditSeverity::Warning => "warn",
781 AuditSeverity::Info => "info",
782 };
783 if let Some(alias) = &finding.alias {
784 println!("[{severity}] {alias}: {}", finding.message);
785 } else {
786 println!("[{severity}] {}", finding.message);
787 }
788 }
789}
790
791fn print_artifact_drift_report(report: &ArtifactDriftReport) {
792 println!(
793 "current artifact version: {}",
794 report.current_artifact_version
795 );
796 if let Some(version) = &report.vendored_artifact_version {
797 println!("vendored artifact version: {version}");
798 } else {
799 println!("vendored artifact version: <missing>");
800 }
801 println!("schema version: {}", report.schema_version);
802 if let Some(version) = report.vendored_schema_version {
803 println!("vendored schema version: {version}");
804 }
805 if report.differences.is_empty() {
806 println!("Vendored manifest matches the current Harn protocol contract.");
807 } else {
808 println!("Differences:");
809 for diff in &report.differences {
810 println!("- {diff}");
811 }
812 }
813}
814
815fn print_json<T: Serialize>(value: &T) {
816 let body = serde_json::to_string_pretty(value)
817 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
818 println!("{body}");
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824 use crate::package::test_support::*;
825
826 #[test]
827 fn lockfile_records_generator_protocol_and_per_entry_provenance() {
828 let (_repo_tmp, repo, _branch) = create_git_package_repo();
829 let project_tmp = tempfile::tempdir().unwrap();
830 let root = project_tmp.path();
831 let workspace = TestWorkspace::new(root);
832 fs::create_dir_all(root.join(".git")).unwrap();
833 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
834 fs::write(
835 root.join(MANIFEST),
836 format!(
837 r#"
838[package]
839name = "workspace"
840version = "0.1.0"
841
842[dependencies]
843acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
844"#
845 ),
846 )
847 .unwrap();
848
849 install_packages_in(workspace.env(), false, None, false).unwrap();
850 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
851 assert_eq!(lock.version, LOCK_FILE_VERSION);
852 assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
853 assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
854 let entry = lock.find("acme-lib").unwrap();
855 assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
856 assert!(entry
857 .manifest_digest
858 .as_deref()
859 .is_some_and(|digest| digest.starts_with("sha256:")));
860 }
861
862 #[test]
863 fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
864 let (_repo_tmp, repo, _branch) = create_git_package_repo();
865 let project_tmp = tempfile::tempdir().unwrap();
866 let root = project_tmp.path();
867 let workspace = TestWorkspace::new(root);
868 fs::create_dir_all(root.join(".git")).unwrap();
869 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
870 fs::write(
871 root.join(MANIFEST),
872 format!(
873 r#"
874[package]
875name = "workspace"
876version = "0.1.0"
877
878[dependencies]
879acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
880"#
881 ),
882 )
883 .unwrap();
884
885 install_packages_in(workspace.env(), false, None, false).unwrap();
886 let lock_path = root.join(LOCK_FILE);
887 let lock = LockFile::load(&lock_path).unwrap().unwrap();
888 let entry = lock.find("acme-lib").unwrap();
889
890 let v1 = format!(
894 "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
895 entry.source,
896 entry.commit.as_deref().unwrap(),
897 entry.content_hash.as_deref().unwrap(),
898 );
899 fs::write(&lock_path, v1).unwrap();
900
901 install_packages_in(workspace.env(), false, None, false).unwrap();
902 let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
903 assert_eq!(upgraded.version, LOCK_FILE_VERSION);
904 let upgraded_entry = upgraded.find("acme-lib").unwrap();
905 assert!(upgraded_entry.package_version.is_some());
906 assert!(upgraded_entry.manifest_digest.is_some());
907 }
908
909 #[test]
910 fn outdated_marks_path_dependencies_as_skipped() {
911 let dependency_tmp = tempfile::tempdir().unwrap();
912 let dep_root = dependency_tmp.path().join("openapi");
913 fs::create_dir_all(&dep_root).unwrap();
914 fs::write(
915 dep_root.join(MANIFEST),
916 r#"
917[package]
918name = "openapi"
919version = "0.1.0"
920"#,
921 )
922 .unwrap();
923 fs::write(
924 dep_root.join("lib.harn"),
925 "pub fn version() -> string { return \"v1\" }\n",
926 )
927 .unwrap();
928
929 let project_tmp = tempfile::tempdir().unwrap();
930 let root = project_tmp.path();
931 let workspace = TestWorkspace::new(root);
932 fs::create_dir_all(root.join(".git")).unwrap();
933 let dep_path = crate::format::toml_basic_string_literal(&dep_root.display().to_string());
934 fs::write(
935 root.join(MANIFEST),
936 format!(
937 r#"
938[package]
939name = "workspace"
940version = "0.1.0"
941
942[dependencies]
943openapi = {{ path = {dep_path} }}
944"#
945 ),
946 )
947 .unwrap();
948
949 install_packages_in(workspace.env(), false, None, false).unwrap();
950 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
951 let entry = report
952 .entries
953 .iter()
954 .find(|entry| entry.alias == "openapi")
955 .expect("openapi entry");
956 assert_eq!(entry.kind, "path");
957 assert!(matches!(entry.status, OutdatedStatus::Skipped));
958 }
959
960 #[test]
961 fn audit_reports_missing_lock_as_error() {
962 let project_tmp = tempfile::tempdir().unwrap();
963 let root = project_tmp.path();
964 let workspace = TestWorkspace::new(root);
965 fs::create_dir_all(root.join(".git")).unwrap();
966 fs::write(
967 root.join(MANIFEST),
968 r#"
969[package]
970name = "workspace"
971version = "0.1.0"
972"#,
973 )
974 .unwrap();
975
976 let report = audit_packages_in(workspace.env(), None, true).unwrap();
977 assert!(!report.ok);
978 assert!(report
979 .findings
980 .iter()
981 .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
982 }
983
984 #[test]
985 fn audit_flags_content_hash_tampering() {
986 let (_repo_tmp, repo, _branch) = create_git_package_repo();
987 let project_tmp = tempfile::tempdir().unwrap();
988 let root = project_tmp.path();
989 let workspace = TestWorkspace::new(root);
990 fs::create_dir_all(root.join(".git")).unwrap();
991 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
992 fs::write(
993 root.join(MANIFEST),
994 format!(
995 r#"
996[package]
997name = "workspace"
998version = "0.1.0"
999
1000[dependencies]
1001acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1002"#
1003 ),
1004 )
1005 .unwrap();
1006 install_packages_in(workspace.env(), false, None, false).unwrap();
1007 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1008 let entry = lock.find("acme-lib").unwrap();
1009 let cache_dir = git_cache_dir_in(
1010 workspace.env(),
1011 &entry.source,
1012 entry.commit.as_deref().unwrap(),
1013 )
1014 .unwrap();
1015 fs::write(
1016 cache_dir.join("lib.harn"),
1017 "pub fn value() { return \"pwned\" }\n",
1018 )
1019 .unwrap();
1020
1021 let report = audit_packages_in(workspace.env(), None, false).unwrap();
1022 assert!(!report.ok);
1023 assert!(report
1024 .findings
1025 .iter()
1026 .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1027 }
1028
1029 #[test]
1030 fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1031 let tmp = tempfile::tempdir().unwrap();
1032 let path = tmp.path().join("manifest.json");
1033 let stale = serde_json::json!({
1034 "schemaVersion": 1,
1035 "artifactVersion": "0.0.0",
1036 "generatedBy": "harn dump-protocol-artifacts",
1037 });
1038 fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1039 let report =
1040 check_artifact_manifest_from(&path, Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
1041 assert!(!report.ok);
1042 assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1043 assert!(!report.differences.is_empty());
1044 }
1045
1046 #[test]
1047 fn artifacts_check_passes_for_current_manifest() {
1048 let tmp = tempfile::tempdir().unwrap();
1049 let path = tmp.path().join("manifest.json");
1050 let source_anchor = Path::new(env!("CARGO_MANIFEST_DIR"));
1051 let current =
1052 crate::commands::dump_protocol_artifacts::manifest_json_from(source_anchor).unwrap();
1053 fs::write(&path, current).unwrap();
1054 let report = check_artifact_manifest_from(&path, source_anchor).unwrap();
1055 assert!(report.ok, "expected no drift, got {:?}", report.differences);
1056 }
1057
1058 #[test]
1059 fn install_is_deterministic_across_repeated_runs() {
1060 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1061 let project_tmp = tempfile::tempdir().unwrap();
1062 let root = project_tmp.path();
1063 let workspace = TestWorkspace::new(root);
1064 fs::create_dir_all(root.join(".git")).unwrap();
1065 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1066 fs::write(
1067 root.join(MANIFEST),
1068 format!(
1069 r#"
1070[package]
1071name = "workspace"
1072version = "0.1.0"
1073
1074[dependencies]
1075acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1076"#
1077 ),
1078 )
1079 .unwrap();
1080
1081 install_packages_in(workspace.env(), false, None, false).unwrap();
1082 let first = fs::read(root.join(LOCK_FILE)).unwrap();
1083 install_packages_in(workspace.env(), false, None, false).unwrap();
1084 let second = fs::read(root.join(LOCK_FILE)).unwrap();
1085 assert_eq!(
1086 first, second,
1087 "harn.lock must be byte-for-byte stable across repeated installs"
1088 );
1089 }
1090
1091 #[test]
1092 fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1093 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1094 let project_tmp = tempfile::tempdir().unwrap();
1095 let root = project_tmp.path();
1096 let registry_path = root.join("index.toml");
1097 let workspace = TestWorkspace::new(root)
1098 .with_registry_source(registry_path.to_string_lossy().to_string());
1099 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1100 let harn_range = current_harn_range_example();
1101 fs::write(
1102 ®istry_path,
1103 format!(
1104 r#"
1105version = 1
1106
1107[[package]]
1108name = "@burin/acme-lib"
1109description = "Acme package for tests"
1110repository = "{git}"
1111license = "MIT"
1112harn = "{harn_range}"
1113
1114[[package.version]]
1115version = "1.0.0"
1116git = "{git}"
1117rev = "v1.0.0"
1118package = "acme-lib"
1119
1120[[package.version]]
1121version = "1.1.0"
1122git = "{git}"
1123rev = "v1.0.0"
1124package = "acme-lib"
1125"#
1126 ),
1127 )
1128 .unwrap();
1129 fs::create_dir_all(root.join(".git")).unwrap();
1130 fs::write(
1131 root.join(MANIFEST),
1132 r#"
1133[package]
1134name = "workspace"
1135version = "0.1.0"
1136"#,
1137 )
1138 .unwrap();
1139
1140 add_package_to(
1141 workspace.env(),
1142 "@burin/acme-lib@1.0.0",
1143 None,
1144 None,
1145 None,
1146 None,
1147 None,
1148 None,
1149 None,
1150 )
1151 .unwrap();
1152
1153 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1154 let entry = report
1155 .entries
1156 .iter()
1157 .find(|entry| entry.alias == "acme-lib")
1158 .expect("acme-lib entry");
1159 assert_eq!(entry.kind, "registry");
1160 assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1161 assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1162 assert!(matches!(entry.status, OutdatedStatus::Outdated));
1163 }
1164}