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