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(workspace, &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 = git_output(["ls-remote", url, refname], None)?;
633 if !output.status.success() {
634 return Err(format!(
635 "git ls-remote {url} {refname} failed: {}",
636 String::from_utf8_lossy(&output.stderr).trim()
637 )
638 .into());
639 }
640 let stdout = String::from_utf8_lossy(&output.stdout);
641 let head = stdout
642 .lines()
643 .next()
644 .and_then(|line| line.split_whitespace().next())
645 .map(str::to_string);
646 Ok(head)
647}
648
649fn audit_git_entry_integrity(
650 workspace: &PackageWorkspace,
651 entry: &LockEntry,
652 skip_materialized: bool,
653) -> Result<(), PackageError> {
654 let Some(commit) = entry.commit.as_deref() else {
655 return Err(format!("{} is missing a locked commit", entry.name).into());
656 };
657 let Some(expected_hash) = entry.content_hash.as_deref() else {
658 return Err(format!("{} is missing a content hash", entry.name).into());
659 };
660 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
661 if !cache_dir.exists() {
662 return Err(format!(
663 "{}: git cache entry missing at {}",
664 entry.name,
665 cache_dir.display()
666 )
667 .into());
668 }
669 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
670 if !skip_materialized {
671 let workspace_pkg = workspace.manifest_dir().join(PKG_DIR).join(&entry.name);
672 if workspace_pkg.exists() {
673 verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
674 }
675 }
676 Ok(())
677}
678
679fn detect_manifest_digest_drift(
680 ctx: &ManifestContext,
681 entry: &LockEntry,
682 workspace: &PackageWorkspace,
683) -> Option<(String, String)> {
684 let expected = entry.manifest_digest.as_deref()?;
685 let materialized = ctx.packages_dir().join(&entry.name);
686 let manifest_path = materialized.join(MANIFEST);
687 let bytes = fs::read(&manifest_path).ok()?;
688 let actual = format!("sha256:{}", sha256_hex(&bytes));
689 if actual == expected {
690 return None;
691 }
692 let _ = workspace; Some((expected.to_string(), actual))
694}
695
696fn manifest_is_publishable(manifest: &Manifest) -> bool {
697 manifest
698 .package
699 .as_ref()
700 .and_then(|pkg| pkg.name.as_deref())
701 .is_some()
702}
703
704fn print_outdated_report(report: &OutdatedReport) {
705 if report.entries.is_empty() {
706 println!("No dependencies recorded in harn.lock.");
707 return;
708 }
709 println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
710 for entry in &report.entries {
711 let current = entry
712 .current_version
713 .as_deref()
714 .or(entry.current_rev.as_deref())
715 .unwrap_or("-");
716 let latest = entry
717 .latest_version
718 .as_deref()
719 .or(entry.latest_rev.as_deref())
720 .unwrap_or("-");
721 let status = match entry.status {
722 OutdatedStatus::Current => "current",
723 OutdatedStatus::Outdated => "outdated",
724 OutdatedStatus::Unknown => "unknown",
725 OutdatedStatus::Skipped => "skipped",
726 };
727 println!(
728 "{}\t{}\t{}\t{}\t{}\t{}",
729 entry.alias,
730 entry.kind,
731 current,
732 latest,
733 status,
734 entry.note.as_deref().unwrap_or("")
735 );
736 }
737}
738
739fn print_audit_report(report: &AuditReport) {
740 println!("manifest: {}", report.manifest_path);
741 println!("lock: {}", report.lock_path);
742 println!(
743 "harn: {} (lock generator {} / protocol {})",
744 report.current_harn, report.generator_version, report.protocol_artifact_version
745 );
746 if report.findings.is_empty() {
747 println!("No issues found.");
748 return;
749 }
750 for finding in &report.findings {
751 let severity = match finding.severity {
752 AuditSeverity::Error => "error",
753 AuditSeverity::Warning => "warn",
754 AuditSeverity::Info => "info",
755 };
756 if let Some(alias) = &finding.alias {
757 println!("[{severity}] {alias}: {}", finding.message);
758 } else {
759 println!("[{severity}] {}", finding.message);
760 }
761 }
762}
763
764fn print_artifact_drift_report(report: &ArtifactDriftReport) {
765 println!(
766 "current artifact version: {}",
767 report.current_artifact_version
768 );
769 if let Some(version) = &report.vendored_artifact_version {
770 println!("vendored artifact version: {version}");
771 } else {
772 println!("vendored artifact version: <missing>");
773 }
774 println!("schema version: {}", report.schema_version);
775 if let Some(version) = report.vendored_schema_version {
776 println!("vendored schema version: {version}");
777 }
778 if report.differences.is_empty() {
779 println!("Vendored manifest matches the current Harn protocol contract.");
780 } else {
781 println!("Differences:");
782 for diff in &report.differences {
783 println!("- {diff}");
784 }
785 }
786}
787
788fn print_json<T: Serialize>(value: &T) {
789 let body = serde_json::to_string_pretty(value)
790 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
791 println!("{body}");
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use crate::package::test_support::*;
798
799 #[test]
800 fn lockfile_records_generator_protocol_and_per_entry_provenance() {
801 let (_repo_tmp, repo, _branch) = create_git_package_repo();
802 let project_tmp = tempfile::tempdir().unwrap();
803 let root = project_tmp.path();
804 let workspace = TestWorkspace::new(root);
805 fs::create_dir_all(root.join(".git")).unwrap();
806 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
807 fs::write(
808 root.join(MANIFEST),
809 format!(
810 r#"
811[package]
812name = "workspace"
813version = "0.1.0"
814
815[dependencies]
816acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
817"#
818 ),
819 )
820 .unwrap();
821
822 install_packages_in(workspace.env(), false, None, false).unwrap();
823 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
824 assert_eq!(lock.version, LOCK_FILE_VERSION);
825 assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
826 assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
827 let entry = lock.find("acme-lib").unwrap();
828 assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
829 assert!(entry
830 .manifest_digest
831 .as_deref()
832 .is_some_and(|digest| digest.starts_with("sha256:")));
833 }
834
835 #[test]
836 fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
837 let (_repo_tmp, repo, _branch) = create_git_package_repo();
838 let project_tmp = tempfile::tempdir().unwrap();
839 let root = project_tmp.path();
840 let workspace = TestWorkspace::new(root);
841 fs::create_dir_all(root.join(".git")).unwrap();
842 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
843 fs::write(
844 root.join(MANIFEST),
845 format!(
846 r#"
847[package]
848name = "workspace"
849version = "0.1.0"
850
851[dependencies]
852acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
853"#
854 ),
855 )
856 .unwrap();
857
858 install_packages_in(workspace.env(), false, None, false).unwrap();
859 let lock_path = root.join(LOCK_FILE);
860 let lock = LockFile::load(&lock_path).unwrap().unwrap();
861 let entry = lock.find("acme-lib").unwrap();
862
863 let v1 = format!(
867 "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
868 entry.source,
869 entry.commit.as_deref().unwrap(),
870 entry.content_hash.as_deref().unwrap(),
871 );
872 fs::write(&lock_path, v1).unwrap();
873
874 install_packages_in(workspace.env(), false, None, false).unwrap();
875 let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
876 assert_eq!(upgraded.version, LOCK_FILE_VERSION);
877 let upgraded_entry = upgraded.find("acme-lib").unwrap();
878 assert!(upgraded_entry.package_version.is_some());
879 assert!(upgraded_entry.manifest_digest.is_some());
880 }
881
882 #[test]
883 fn outdated_marks_path_dependencies_as_skipped() {
884 let dependency_tmp = tempfile::tempdir().unwrap();
885 let dep_root = dependency_tmp.path().join("openapi");
886 fs::create_dir_all(&dep_root).unwrap();
887 fs::write(
888 dep_root.join(MANIFEST),
889 r#"
890[package]
891name = "openapi"
892version = "0.1.0"
893"#,
894 )
895 .unwrap();
896 fs::write(
897 dep_root.join("lib.harn"),
898 "pub fn version() -> string { return \"v1\" }\n",
899 )
900 .unwrap();
901
902 let project_tmp = tempfile::tempdir().unwrap();
903 let root = project_tmp.path();
904 let workspace = TestWorkspace::new(root);
905 fs::create_dir_all(root.join(".git")).unwrap();
906 let dep_path = dep_root.display().to_string();
907 fs::write(
908 root.join(MANIFEST),
909 format!(
910 r#"
911[package]
912name = "workspace"
913version = "0.1.0"
914
915[dependencies]
916openapi = {{ path = "{dep_path}" }}
917"#
918 ),
919 )
920 .unwrap();
921
922 install_packages_in(workspace.env(), false, None, false).unwrap();
923 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
924 let entry = report
925 .entries
926 .iter()
927 .find(|entry| entry.alias == "openapi")
928 .expect("openapi entry");
929 assert_eq!(entry.kind, "path");
930 assert!(matches!(entry.status, OutdatedStatus::Skipped));
931 }
932
933 #[test]
934 fn audit_reports_missing_lock_as_error() {
935 let project_tmp = tempfile::tempdir().unwrap();
936 let root = project_tmp.path();
937 let workspace = TestWorkspace::new(root);
938 fs::create_dir_all(root.join(".git")).unwrap();
939 fs::write(
940 root.join(MANIFEST),
941 r#"
942[package]
943name = "workspace"
944version = "0.1.0"
945"#,
946 )
947 .unwrap();
948
949 let report = audit_packages_in(workspace.env(), None, true).unwrap();
950 assert!(!report.ok);
951 assert!(report
952 .findings
953 .iter()
954 .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
955 }
956
957 #[test]
958 fn audit_flags_content_hash_tampering() {
959 let (_repo_tmp, repo, _branch) = create_git_package_repo();
960 let project_tmp = tempfile::tempdir().unwrap();
961 let root = project_tmp.path();
962 let workspace = TestWorkspace::new(root);
963 fs::create_dir_all(root.join(".git")).unwrap();
964 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
965 fs::write(
966 root.join(MANIFEST),
967 format!(
968 r#"
969[package]
970name = "workspace"
971version = "0.1.0"
972
973[dependencies]
974acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
975"#
976 ),
977 )
978 .unwrap();
979 install_packages_in(workspace.env(), false, None, false).unwrap();
980 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
981 let entry = lock.find("acme-lib").unwrap();
982 let cache_dir = git_cache_dir_in(
983 workspace.env(),
984 &entry.source,
985 entry.commit.as_deref().unwrap(),
986 )
987 .unwrap();
988 fs::write(
989 cache_dir.join("lib.harn"),
990 "pub fn value() { return \"pwned\" }\n",
991 )
992 .unwrap();
993
994 let report = audit_packages_in(workspace.env(), None, false).unwrap();
995 assert!(!report.ok);
996 assert!(report
997 .findings
998 .iter()
999 .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1000 }
1001
1002 #[test]
1003 fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1004 let tmp = tempfile::tempdir().unwrap();
1005 let path = tmp.path().join("manifest.json");
1006 let stale = serde_json::json!({
1007 "schemaVersion": 1,
1008 "artifactVersion": "0.0.0",
1009 "generatedBy": "harn dump-protocol-artifacts",
1010 });
1011 fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1012 let report = check_artifact_manifest(&path).unwrap();
1013 assert!(!report.ok);
1014 assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1015 assert!(!report.differences.is_empty());
1016 }
1017
1018 #[test]
1019 fn artifacts_check_passes_for_current_manifest() {
1020 let tmp = tempfile::tempdir().unwrap();
1021 let path = tmp.path().join("manifest.json");
1022 let current = crate::commands::dump_protocol_artifacts::manifest_json().unwrap();
1023 fs::write(&path, current).unwrap();
1024 let report = check_artifact_manifest(&path).unwrap();
1025 assert!(report.ok, "expected no drift, got {:?}", report.differences);
1026 }
1027
1028 #[test]
1029 fn install_is_deterministic_across_repeated_runs() {
1030 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1031 let project_tmp = tempfile::tempdir().unwrap();
1032 let root = project_tmp.path();
1033 let workspace = TestWorkspace::new(root);
1034 fs::create_dir_all(root.join(".git")).unwrap();
1035 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1036 fs::write(
1037 root.join(MANIFEST),
1038 format!(
1039 r#"
1040[package]
1041name = "workspace"
1042version = "0.1.0"
1043
1044[dependencies]
1045acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1046"#
1047 ),
1048 )
1049 .unwrap();
1050
1051 install_packages_in(workspace.env(), false, None, false).unwrap();
1052 let first = fs::read(root.join(LOCK_FILE)).unwrap();
1053 install_packages_in(workspace.env(), false, None, false).unwrap();
1054 let second = fs::read(root.join(LOCK_FILE)).unwrap();
1055 assert_eq!(
1056 first, second,
1057 "harn.lock must be byte-for-byte stable across repeated installs"
1058 );
1059 }
1060
1061 #[test]
1062 fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1063 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1064 let project_tmp = tempfile::tempdir().unwrap();
1065 let root = project_tmp.path();
1066 let registry_path = root.join("index.toml");
1067 let workspace =
1068 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1069 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1070 let harn_range = current_harn_range_example();
1071 fs::write(
1072 ®istry_path,
1073 format!(
1074 r#"
1075version = 1
1076
1077[[package]]
1078name = "@burin/acme-lib"
1079description = "Acme package for tests"
1080repository = "{git}"
1081license = "MIT"
1082harn = "{harn_range}"
1083
1084[[package.version]]
1085version = "1.0.0"
1086git = "{git}"
1087rev = "v1.0.0"
1088package = "acme-lib"
1089
1090[[package.version]]
1091version = "1.1.0"
1092git = "{git}"
1093rev = "v1.0.0"
1094package = "acme-lib"
1095"#
1096 ),
1097 )
1098 .unwrap();
1099 fs::create_dir_all(root.join(".git")).unwrap();
1100 fs::write(
1101 root.join(MANIFEST),
1102 r#"
1103[package]
1104name = "workspace"
1105version = "0.1.0"
1106"#,
1107 )
1108 .unwrap();
1109
1110 add_package_to(
1111 workspace.env(),
1112 "@burin/acme-lib@1.0.0",
1113 None,
1114 None,
1115 None,
1116 None,
1117 None,
1118 None,
1119 None,
1120 )
1121 .unwrap();
1122
1123 let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1124 let entry = report
1125 .entries
1126 .iter()
1127 .find(|entry| entry.alias == "acme-lib")
1128 .expect("acme-lib entry");
1129 assert_eq!(entry.kind, "registry");
1130 assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1131 assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1132 assert!(matches!(entry.status, OutdatedStatus::Outdated));
1133 }
1134}