1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub(crate) struct PackageCacheMetadata {
6 version: u32,
7 source: String,
8 commit: String,
9 content_hash: String,
10 cached_at_unix_ms: u128,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub(crate) struct PackageRegistryIndex {
15 version: u32,
16 #[serde(default, rename = "package")]
17 packages: Vec<RegistryPackage>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub(crate) struct RegistryPackage {
22 name: String,
23 #[serde(default)]
24 description: Option<String>,
25 repository: String,
26 #[serde(default)]
27 license: Option<String>,
28 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
29 harn: Option<String>,
30 #[serde(default)]
31 exports: Vec<String>,
32 #[serde(default, alias = "connector-contract")]
33 connector_contract: Option<String>,
34 #[serde(default)]
35 docs_url: Option<String>,
36 #[serde(default)]
37 checksum: Option<String>,
38 #[serde(default)]
39 provenance: Option<String>,
40 #[serde(default, rename = "version")]
41 versions: Vec<RegistryPackageVersion>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub(crate) struct RegistryPackageVersion {
46 version: String,
47 git: String,
48 #[serde(default)]
49 rev: Option<String>,
50 #[serde(default)]
51 branch: Option<String>,
52 #[serde(default)]
53 package: Option<String>,
54 #[serde(default)]
55 checksum: Option<String>,
56 #[serde(default)]
57 provenance: Option<String>,
58 #[serde(default)]
59 yanked: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub(crate) struct RegistryPackageInfo {
64 package: RegistryPackage,
65 selected_version: Option<RegistryPackageVersion>,
66}
67
68pub(crate) fn manifest_has_git_dependencies(manifest: &Manifest) -> bool {
69 manifest
70 .dependencies
71 .values()
72 .any(|dependency| dependency.git_url().is_some())
73}
74
75pub(crate) fn ensure_git_available() -> Result<(), PackageError> {
76 process::Command::new("git")
77 .arg("--version")
78 .env_remove("GIT_DIR")
79 .env_remove("GIT_WORK_TREE")
80 .env_remove("GIT_INDEX_FILE")
81 .output()
82 .map(|_| ())
83 .map_err(|_| {
84 PackageError::Registry(
85 "git is required for git dependencies but was not found in PATH".to_string(),
86 )
87 })
88}
89
90pub(crate) fn cache_root() -> Result<PathBuf, PackageError> {
91 if let Ok(value) = std::env::var(HARN_CACHE_DIR_ENV) {
92 if !value.trim().is_empty() {
93 return Ok(PathBuf::from(value));
94 }
95 }
96
97 let home = std::env::var_os("HOME")
98 .map(PathBuf::from)
99 .ok_or_else(|| "HOME is not set and HARN_CACHE_DIR was not provided".to_string())?;
100 if cfg!(target_os = "macos") {
101 return Ok(home.join("Library/Caches/harn"));
102 }
103 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
104 return Ok(PathBuf::from(xdg).join("harn"));
105 }
106 Ok(home.join(".cache/harn"))
107}
108
109pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
110 hex_bytes(Sha256::digest(bytes.as_ref()))
111}
112
113pub(crate) fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
114 const HEX: &[u8; 16] = b"0123456789abcdef";
115 let bytes = bytes.as_ref();
116 let mut out = String::with_capacity(bytes.len() * 2);
117 for &byte in bytes {
118 out.push(HEX[(byte >> 4) as usize] as char);
119 out.push(HEX[(byte & 0x0f) as usize] as char);
120 }
121 out
122}
123
124pub(crate) fn git_cache_dir(source: &str, commit: &str) -> Result<PathBuf, PackageError> {
125 Ok(cache_root()?
126 .join("git")
127 .join(sha256_hex(source))
128 .join(commit))
129}
130
131pub(crate) fn git_cache_lock_path(source: &str, commit: &str) -> Result<PathBuf, PackageError> {
132 Ok(cache_root()?
133 .join("locks")
134 .join(format!("{}-{commit}.lock", sha256_hex(source))))
135}
136
137pub(crate) fn acquire_git_cache_lock(source: &str, commit: &str) -> Result<File, PackageError> {
138 let path = git_cache_lock_path(source, commit)?;
139 if let Some(parent) = path.parent() {
140 fs::create_dir_all(parent)
141 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
142 }
143 let file = File::create(&path)
144 .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
145 file.lock_exclusive()
146 .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
147 Ok(file)
148}
149
150pub(crate) fn read_cached_content_hash(dir: &Path) -> Result<Option<String>, PackageError> {
151 let path = dir.join(CONTENT_HASH_FILE);
152 match fs::read_to_string(&path) {
153 Ok(value) => Ok(Some(value.trim().to_string())),
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
155 Err(error) => Err(format!("failed to read {}: {error}", path.display()).into()),
156 }
157}
158
159pub(crate) fn write_cached_content_hash(dir: &Path, hash: &str) -> Result<(), PackageError> {
160 let path = dir.join(CONTENT_HASH_FILE);
161 harn_vm::atomic_io::atomic_write(&path, format!("{hash}\n").as_bytes()).map_err(|error| {
162 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
163 })
164}
165
166pub(crate) fn read_cache_metadata(
167 dir: &Path,
168) -> Result<Option<PackageCacheMetadata>, PackageError> {
169 let path = dir.join(CACHE_METADATA_FILE);
170 let content = match fs::read_to_string(&path) {
171 Ok(content) => content,
172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
173 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
174 };
175 let metadata = toml::from_str::<PackageCacheMetadata>(&content)
176 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
177 if metadata.version != CACHE_METADATA_VERSION {
178 return Err(format!(
179 "unsupported {} version {} (expected {})",
180 path.display(),
181 metadata.version,
182 CACHE_METADATA_VERSION
183 )
184 .into());
185 }
186 Ok(Some(metadata))
187}
188
189pub(crate) fn write_cache_metadata(
190 dir: &Path,
191 source: &str,
192 commit: &str,
193 content_hash: &str,
194) -> Result<(), PackageError> {
195 let cached_at_unix_ms = SystemTime::now()
196 .duration_since(UNIX_EPOCH)
197 .map_err(|error| format!("system clock error: {error}"))?
198 .as_millis();
199 let metadata = PackageCacheMetadata {
200 version: CACHE_METADATA_VERSION,
201 source: source.to_string(),
202 commit: commit.to_string(),
203 content_hash: content_hash.to_string(),
204 cached_at_unix_ms,
205 };
206 let body = toml::to_string_pretty(&metadata)
207 .map_err(|error| format!("failed to encode cache metadata: {error}"))?;
208 let path = dir.join(CACHE_METADATA_FILE);
209 harn_vm::atomic_io::atomic_write(&path, body.as_bytes()).map_err(|error| {
210 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
211 })
212}
213
214pub(crate) fn normalized_relative_path(path: &Path) -> String {
215 path.components()
216 .map(|component| component.as_os_str().to_string_lossy())
217 .collect::<Vec<_>>()
218 .join("/")
219}
220
221pub(crate) fn collect_hashable_files(
222 root: &Path,
223 cursor: &Path,
224 out: &mut Vec<PathBuf>,
225) -> Result<(), PackageError> {
226 for entry in fs::read_dir(cursor)
227 .map_err(|error| format!("failed to read {}: {error}", cursor.display()))?
228 {
229 let entry =
230 entry.map_err(|error| format!("failed to read {} entry: {error}", cursor.display()))?;
231 let path = entry.path();
232 let file_type = entry
233 .file_type()
234 .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
235 let name = entry.file_name();
236 if name == OsStr::new(".git")
237 || name == OsStr::new(".gitignore")
238 || name == OsStr::new(CONTENT_HASH_FILE)
239 || name == OsStr::new(CACHE_METADATA_FILE)
240 {
241 continue;
242 }
243 if file_type.is_dir() {
244 collect_hashable_files(root, &path, out)?;
245 } else if file_type.is_file() {
246 let relative = path
247 .strip_prefix(root)
248 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
249 out.push(relative.to_path_buf());
250 }
251 }
252 Ok(())
253}
254
255pub(crate) fn compute_content_hash(dir: &Path) -> Result<String, PackageError> {
256 let mut files = Vec::new();
257 collect_hashable_files(dir, dir, &mut files)?;
258 files.sort();
259 let mut hasher = Sha256::new();
260 for relative in files {
261 let normalized = normalized_relative_path(&relative);
262 let contents = fs::read(dir.join(&relative)).map_err(|error| {
263 format!("failed to read {}: {error}", dir.join(&relative).display())
264 })?;
265 hasher.update(normalized.as_bytes());
266 hasher.update([0]);
267 hasher.update(sha256_hex(contents).as_bytes());
268 }
269 Ok(format!("sha256:{}", hex_bytes(hasher.finalize())))
270}
271
272pub(crate) fn verify_content_hash_or_compute(
273 dir: &Path,
274 expected: &str,
275) -> Result<(), PackageError> {
276 let actual = compute_content_hash(dir)?;
277 if actual != expected {
278 return Err(format!(
279 "content hash mismatch for {}: expected {}, got {}",
280 dir.display(),
281 expected,
282 actual
283 )
284 .into());
285 }
286 if read_cached_content_hash(dir)?.as_deref() != Some(expected) {
287 write_cached_content_hash(dir, expected)?;
288 }
289 Ok(())
290}
291
292pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {
293 fs::create_dir_all(dst)
294 .map_err(|error| format!("failed to create {}: {error}", dst.display()))?;
295 for entry in
296 fs::read_dir(src).map_err(|error| format!("failed to read {}: {error}", src.display()))?
297 {
298 let entry =
299 entry.map_err(|error| format!("failed to read {} entry: {error}", src.display()))?;
300 let ty = entry
301 .file_type()
302 .map_err(|error| format!("failed to stat {}: {error}", entry.path().display()))?;
303 let name = entry.file_name();
304 if name == OsStr::new(".git")
305 || name == OsStr::new(CONTENT_HASH_FILE)
306 || name == OsStr::new(CACHE_METADATA_FILE)
307 {
308 continue;
309 }
310 let dest_path = dst.join(entry.file_name());
311 if ty.is_dir() {
312 copy_dir_recursive(&entry.path(), &dest_path)?;
313 } else if ty.is_file() {
314 if let Some(parent) = dest_path.parent() {
315 fs::create_dir_all(parent)
316 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
317 }
318 fs::copy(entry.path(), &dest_path).map_err(|error| {
319 format!(
320 "failed to copy {} to {}: {error}",
321 entry.path().display(),
322 dest_path.display()
323 )
324 })?;
325 }
326 }
327 Ok(())
328}
329
330pub(crate) fn remove_materialized_package(
331 packages_dir: &Path,
332 alias: &str,
333) -> Result<(), PackageError> {
334 remove_materialized_path(&packages_dir.join(alias))?;
335 remove_materialized_path(&packages_dir.join(format!("{alias}.harn")))?;
336 Ok(())
337}
338
339fn remove_materialized_path(path: &Path) -> Result<(), PackageError> {
340 match fs::symlink_metadata(path) {
341 Ok(metadata) if is_link_like(&metadata) => remove_link_like_path(path)
342 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
343 Ok(metadata) if metadata.is_file() => fs::remove_file(path)
344 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
345 Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)
346 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
347 Ok(_) => Ok(()),
348 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
349 Err(error) => Err(format!("failed to stat {}: {error}", path.display()).into()),
350 }
351}
352
353fn is_link_like(metadata: &fs::Metadata) -> bool {
354 metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
355}
356
357#[cfg(windows)]
358fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
359 use std::os::windows::fs::MetadataExt;
360
361 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
362 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
363}
364
365#[cfg(not(windows))]
366fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
367 false
368}
369
370fn remove_link_like_path(path: &Path) -> std::io::Result<()> {
371 match fs::remove_file(path) {
372 Ok(()) => Ok(()),
373 Err(file_error) => match fs::remove_dir(path) {
374 Ok(()) => Ok(()),
375 Err(_) => Err(file_error),
376 },
377 }
378}
379
380#[cfg(unix)]
381pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
382 std::os::unix::fs::symlink(source, dest).map_err(|error| {
383 PackageError::Registry(format!(
384 "failed to symlink {} to {}: {error}",
385 source.display(),
386 dest.display()
387 ))
388 })
389}
390
391#[cfg(windows)]
392pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
393 if source.is_dir() {
394 std::os::windows::fs::symlink_dir(source, dest)
395 } else {
396 std::os::windows::fs::symlink_file(source, dest)
397 }
398 .map_err(|error| {
399 PackageError::Registry(format!(
400 "failed to symlink {} to {}: {error}",
401 source.display(),
402 dest.display()
403 ))
404 })
405}
406
407#[cfg(not(any(unix, windows)))]
408pub(crate) fn symlink_path_dependency(_source: &Path, _dest: &Path) -> Result<(), PackageError> {
409 Err("symlinks are not supported on this platform"
410 .to_string()
411 .into())
412}
413
414pub(crate) fn materialize_path_dependency(
415 source: &Path,
416 dest_root: &Path,
417 alias: &str,
418) -> Result<(), PackageError> {
419 remove_materialized_package(dest_root, alias)?;
420 if source.is_dir() {
421 let dest = dest_root.join(alias);
422 match symlink_path_dependency(source, &dest) {
423 Ok(()) => Ok(()),
424 Err(_) => copy_dir_recursive(source, &dest),
425 }
426 } else {
427 let dest = dest_root.join(format!("{alias}.harn"));
428 if let Some(parent) = dest.parent() {
429 fs::create_dir_all(parent)
430 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
431 }
432 match symlink_path_dependency(source, &dest) {
433 Ok(()) => Ok(()),
434 Err(_) => {
435 fs::copy(source, &dest).map_err(|error| {
436 format!(
437 "failed to copy {} to {}: {error}",
438 source.display(),
439 dest.display()
440 )
441 })?;
442 Ok(())
443 }
444 }
445 }
446}
447
448pub(crate) fn materialized_hash_matches(dir: &Path, expected: &str) -> bool {
449 verify_content_hash_or_compute(dir, expected).is_ok()
450}
451
452pub(crate) fn resolve_path_dependency_source(
453 manifest_dir: &Path,
454 raw: &str,
455) -> Result<PathBuf, PackageError> {
456 let source = {
457 let candidate = PathBuf::from(raw);
458 if candidate.is_absolute() {
459 candidate
460 } else {
461 manifest_dir.join(candidate)
462 }
463 };
464 if source.exists() {
465 return source.canonicalize().map_err(|error| {
466 PackageError::Registry(format!(
467 "failed to canonicalize {}: {error}",
468 source.display()
469 ))
470 });
471 }
472 if source.extension().is_none() {
473 let with_ext = source.with_extension("harn");
474 if with_ext.exists() {
475 return with_ext.canonicalize().map_err(|error| {
476 PackageError::Registry(format!(
477 "failed to canonicalize {}: {error}",
478 with_ext.display()
479 ))
480 });
481 }
482 }
483 Err(format!("package source not found: {}", source.display()).into())
484}
485
486pub(crate) fn path_source_uri(path: &Path) -> Result<String, PackageError> {
487 let url = Url::from_file_path(path)
488 .map_err(|_| format!("failed to convert {} to file:// URL", path.display()))?;
489 Ok(format!("path+{}", url))
490}
491
492pub(crate) fn path_from_source_uri(source: &str) -> Result<PathBuf, PackageError> {
493 let raw = source
494 .strip_prefix("path+")
495 .ok_or_else(|| format!("invalid path source: {source}"))?;
496 if let Ok(url) = Url::parse(raw) {
497 return url
498 .to_file_path()
499 .map_err(|_| PackageError::Registry(format!("invalid file:// path source: {source}")));
500 }
501 Ok(PathBuf::from(raw))
502}
503
504pub(crate) fn registry_file_url_or_path(raw: &str) -> Result<Option<PathBuf>, PackageError> {
505 if let Ok(url) = Url::parse(raw) {
506 if url.scheme() == "file" {
507 return url.to_file_path().map(Some).map_err(|_| {
508 PackageError::Registry(format!("invalid file:// registry URL: {raw}"))
509 });
510 }
511 return Ok(None);
512 }
513 Ok(Some(PathBuf::from(raw)))
514}
515
516pub(crate) fn read_registry_source(source: &str) -> Result<String, PackageError> {
517 if let Some(path) = registry_file_url_or_path(source)? {
518 return fs::read_to_string(&path).map_err(|error| {
519 PackageError::Registry(format!(
520 "failed to read package registry {}: {error}",
521 path.display()
522 ))
523 });
524 }
525
526 let url = Url::parse(source)
527 .map_err(|error| format!("invalid package registry URL {source:?}: {error}"))?;
528 match url.scheme() {
529 "http" | "https" => {}
530 other => return Err(format!("unsupported package registry URL scheme: {other}").into()),
531 }
532 let response = reqwest::blocking::Client::builder()
533 .timeout(Duration::from_secs(20))
534 .build()
535 .map_err(|error| format!("failed to build package registry client: {error}"))?
536 .get(url)
537 .send()
538 .map_err(|error| format!("failed to fetch package registry {source}: {error}"))?;
539 let status = response.status();
540 if !status.is_success() {
541 return Err(format!("GET {source} returned HTTP {status}").into());
542 }
543 response.text().map_err(|error| {
544 PackageError::Registry(format!("failed to read package registry response: {error}"))
545 })
546}
547
548pub(crate) fn resolve_configured_registry_source(
549 explicit: Option<&str>,
550) -> Result<String, PackageError> {
551 if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
552 return Ok(explicit.to_string());
553 }
554 if let Ok(value) = std::env::var(HARN_PACKAGE_REGISTRY_ENV) {
555 let value = value.trim();
556 if !value.is_empty() {
557 return Ok(value.to_string());
558 }
559 }
560
561 let cwd = std::env::current_dir().map_err(|error| format!("failed to read cwd: {error}"))?;
562 if let Some((manifest, manifest_dir)) = find_nearest_manifest(&cwd) {
563 if let Some(raw) = manifest
564 .registry
565 .url
566 .as_deref()
567 .map(str::trim)
568 .filter(|value| !value.is_empty())
569 {
570 if Url::parse(raw).is_ok() || PathBuf::from(raw).is_absolute() {
571 return Ok(raw.to_string());
572 }
573 return Ok(manifest_dir.join(raw).display().to_string());
574 }
575 }
576
577 Ok(DEFAULT_PACKAGE_REGISTRY_URL.to_string())
578}
579
580pub(crate) fn is_valid_registry_segment(segment: &str) -> bool {
581 let mut chars = segment.chars();
582 let Some(first) = chars.next() else {
583 return false;
584 };
585 first.is_ascii_alphanumeric()
586 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
587}
588
589pub(crate) fn is_valid_registry_package_name(name: &str) -> bool {
590 let trimmed = name.trim();
591 if trimmed != name || trimmed.is_empty() || trimmed.contains("://") || trimmed.ends_with('/') {
592 return false;
593 }
594 if let Some(scoped) = trimmed.strip_prefix('@') {
595 let Some((scope, package)) = scoped.split_once('/') else {
596 return false;
597 };
598 return !package.contains('/')
599 && is_valid_registry_segment(scope)
600 && is_valid_registry_segment(package);
601 }
602 !trimmed.contains('/') && is_valid_registry_segment(trimmed)
603}
604
605pub(crate) fn parse_registry_package_spec(spec: &str) -> Option<(&str, Option<&str>)> {
606 let trimmed = spec.trim();
607 if !trimmed.starts_with('@') {
608 if let Some((name, version)) = trimmed.rsplit_once('@') {
609 if is_valid_registry_package_name(name) && !version.trim().is_empty() {
610 return Some((name, Some(version)));
611 }
612 }
613 if is_valid_registry_package_name(trimmed) {
614 return Some((trimmed, None));
615 }
616 return None;
617 }
618
619 if let Some((name, version)) = trimmed.rsplit_once('@') {
620 if !name.is_empty()
621 && name != trimmed
622 && is_valid_registry_package_name(name)
623 && !version.trim().is_empty()
624 {
625 return Some((name, Some(version)));
626 }
627 }
628 if is_valid_registry_package_name(trimmed) {
629 return Some((trimmed, None));
630 }
631 None
632}
633
634pub(crate) fn parse_package_registry_index(
635 source: &str,
636 content: &str,
637) -> Result<PackageRegistryIndex, PackageError> {
638 let mut index = toml::from_str::<PackageRegistryIndex>(content)
639 .map_err(|error| format!("failed to parse package registry {source}: {error}"))?;
640 if index.version != REGISTRY_INDEX_VERSION {
641 return Err(format!(
642 "unsupported package registry {source} version {} (expected {})",
643 index.version, REGISTRY_INDEX_VERSION
644 )
645 .into());
646 }
647 validate_package_registry_index(source, &mut index)?;
648 Ok(index)
649}
650
651pub(crate) fn validate_package_registry_index(
652 source: &str,
653 index: &mut PackageRegistryIndex,
654) -> Result<(), PackageError> {
655 let mut names = HashSet::new();
656 for package in &mut index.packages {
657 if !is_valid_registry_package_name(&package.name) {
658 return Err(format!(
659 "package registry {source} has invalid package name '{}'",
660 package.name
661 )
662 .into());
663 }
664 if !names.insert(package.name.clone()) {
665 return Err(format!(
666 "package registry {source} declares '{}' more than once",
667 package.name
668 )
669 .into());
670 }
671 normalize_git_url(&package.repository).map_err(|error| {
672 format!(
673 "package registry {source} has invalid repository for '{}': {error}",
674 package.name
675 )
676 })?;
677 let mut versions = HashSet::new();
678 for version in &package.versions {
679 if version.version.trim().is_empty() {
680 return Err(format!(
681 "package registry {source} has empty version for '{}'",
682 package.name
683 )
684 .into());
685 }
686 if !versions.insert(version.version.clone()) {
687 return Err(format!(
688 "package registry {source} declares '{}@{}' more than once",
689 package.name, version.version
690 )
691 .into());
692 }
693 if version.rev.is_none() && version.branch.is_none() {
694 return Err(format!(
695 "package registry {source} entry '{}@{}' must specify rev or branch",
696 package.name, version.version
697 )
698 .into());
699 }
700 normalize_git_url(&version.git).map_err(|error| {
701 format!(
702 "package registry {source} has invalid git source for '{}@{}': {error}",
703 package.name, version.version
704 )
705 })?;
706 }
707 }
708 index
709 .packages
710 .sort_by(|left, right| left.name.cmp(&right.name));
711 Ok(())
712}
713
714pub(crate) fn load_package_registry(
715 explicit: Option<&str>,
716) -> Result<(String, PackageRegistryIndex), PackageError> {
717 let source = resolve_configured_registry_source(explicit)?;
718 let content = read_registry_source(&source)?;
719 let index = parse_package_registry_index(&source, &content)?;
720 Ok((source, index))
721}
722
723pub(crate) fn registry_package_matches(package: &RegistryPackage, query: &str) -> bool {
724 if query.trim().is_empty() {
725 return true;
726 }
727 let query = query.to_ascii_lowercase();
728 package.name.to_ascii_lowercase().contains(&query)
729 || package
730 .description
731 .as_deref()
732 .is_some_and(|value| value.to_ascii_lowercase().contains(&query))
733 || package.repository.to_ascii_lowercase().contains(&query)
734 || package
735 .exports
736 .iter()
737 .any(|export| export.to_ascii_lowercase().contains(&query))
738}
739
740pub(crate) fn latest_registry_version(
741 package: &RegistryPackage,
742) -> Option<&RegistryPackageVersion> {
743 package
744 .versions
745 .iter()
746 .rev()
747 .find(|version| !version.yanked)
748}
749
750pub(crate) fn find_registry_package_version(
751 index: &PackageRegistryIndex,
752 name: &str,
753 version: Option<&str>,
754) -> Result<RegistryPackageInfo, PackageError> {
755 let package = index
756 .packages
757 .iter()
758 .find(|package| package.name == name)
759 .ok_or_else(|| format!("package registry does not contain {name}"))?;
760 let selected_version = match version {
761 Some(version) => Some(
762 package
763 .versions
764 .iter()
765 .find(|entry| entry.version == version)
766 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?
767 .clone(),
768 ),
769 None => latest_registry_version(package).cloned(),
770 };
771 Ok(RegistryPackageInfo {
772 package: package.clone(),
773 selected_version,
774 })
775}
776
777pub(crate) fn search_package_registry_impl(
778 query: Option<&str>,
779 registry: Option<&str>,
780) -> Result<Vec<RegistryPackage>, PackageError> {
781 let (_, index) = load_package_registry(registry)?;
782 Ok(index
783 .packages
784 .into_iter()
785 .filter(|package| registry_package_matches(package, query.unwrap_or("")))
786 .collect())
787}
788
789pub(crate) fn package_registry_info_impl(
790 spec: &str,
791 registry: Option<&str>,
792) -> Result<RegistryPackageInfo, PackageError> {
793 let Some((name, version)) = parse_registry_package_spec(spec) else {
794 return Err(format!(
795 "invalid registry package name '{spec}'; use names like @burin/notion-sdk or acme-lib"
796 )
797 .into());
798 };
799 let (_, index) = load_package_registry(registry)?;
800 find_registry_package_version(&index, name, version)
801}
802
803pub(crate) fn registry_dependency_from_spec(
804 spec: &str,
805 alias: Option<&str>,
806 registry: Option<&str>,
807) -> Result<(String, Dependency), PackageError> {
808 let Some((name, Some(version))) = parse_registry_package_spec(spec) else {
809 return Err(format!(
810 "registry dependency '{spec}' must include a version, for example {spec}@1.2.3"
811 )
812 .into());
813 };
814 let info = package_registry_info_impl(&format!("{name}@{version}"), registry)?;
815 let selected = info
816 .selected_version
817 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?;
818 if selected.yanked {
819 return Err(format!("{name}@{version} is yanked in the package registry").into());
820 }
821 let git = normalize_git_url(&selected.git)?;
822 let package_name = selected
823 .package
824 .clone()
825 .map(Ok)
826 .unwrap_or_else(|| derive_repo_name_from_source(&git))?;
827 let alias = alias.unwrap_or(package_name.as_str()).to_string();
828 Ok((
829 alias.clone(),
830 Dependency::Table(DepTable {
831 git: Some(git),
832 tag: None,
833 rev: selected.rev,
834 branch: selected.branch,
835 path: None,
836 package: (alias != package_name).then_some(package_name),
837 }),
838 ))
839}
840
841pub(crate) fn is_probable_shorthand_git_url(raw: &str) -> bool {
842 !raw.contains("://")
843 && !raw.starts_with("git@")
844 && raw.contains('/')
845 && raw
846 .split('/')
847 .next()
848 .is_some_and(|segment| segment.contains('.'))
849}
850
851pub(crate) fn normalize_git_url(raw: &str) -> Result<String, PackageError> {
852 let trimmed = raw.trim();
853 if trimmed.is_empty() {
854 return Err("git URL cannot be empty".to_string().into());
855 }
856
857 let candidate_path = PathBuf::from(trimmed);
858 if candidate_path.exists() {
859 let canonical = candidate_path
860 .canonicalize()
861 .map_err(|error| format!("failed to canonicalize {}: {error}", trimmed))?;
862 let url = Url::from_file_path(canonical)
863 .map_err(|_| format!("failed to convert {} to file:// URL", trimmed))?;
864 return Ok(url.to_string().trim_end_matches('/').to_string());
865 }
866
867 if let Some(rest) = trimmed.strip_prefix("git@") {
868 if let Some((host, path)) = rest.split_once(':') {
869 return Ok(format!(
870 "ssh://git@{}/{}",
871 host,
872 path.trim_start_matches('/').trim_end_matches('/')
873 ));
874 }
875 }
876
877 let with_scheme = if is_probable_shorthand_git_url(trimmed) {
878 format!("https://{trimmed}")
879 } else {
880 trimmed.to_string()
881 };
882 let parsed =
883 Url::parse(&with_scheme).map_err(|error| format!("invalid git URL {trimmed}: {error}"))?;
884 let mut normalized = parsed.to_string();
885 while normalized.ends_with('/') {
886 normalized.pop();
887 }
888 if parsed.scheme() != "file" && normalized.ends_with(".git") {
889 normalized.truncate(normalized.len() - 4);
890 }
891 Ok(normalized)
892}
893
894pub(crate) fn derive_repo_name_from_source(source: &str) -> Result<String, PackageError> {
895 let url = Url::parse(source).map_err(|error| format!("invalid git URL {source}: {error}"))?;
896 let segment = url
897 .path_segments()
898 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
899 .ok_or_else(|| format!("failed to derive package name from {source}"))?;
900 Ok(segment.trim_end_matches(".git").to_string())
901}
902
903pub(crate) fn parse_positional_git_spec(spec: &str) -> (&str, Option<&str>) {
904 if let Some((source, candidate_ref)) = spec.rsplit_once('@') {
905 if !candidate_ref.is_empty()
906 && !candidate_ref.contains('/')
907 && !candidate_ref.contains(':')
908 && !source.ends_with("://")
909 {
910 return (source, Some(candidate_ref));
911 }
912 }
913 (spec, None)
914}
915
916pub(crate) fn existing_local_path_spec(spec: &str) -> Option<PathBuf> {
917 if spec.trim().is_empty() || spec.contains("://") || spec.starts_with("git@") {
918 return None;
919 }
920 let candidate = PathBuf::from(spec);
921 if candidate.exists() {
922 return Some(candidate);
923 }
924 if candidate.extension().is_none() {
925 let with_ext = candidate.with_extension("harn");
926 if with_ext.exists() {
927 return Some(with_ext);
928 }
929 }
930 if is_probable_shorthand_git_url(spec) {
931 return None;
932 }
933 None
934}
935
936pub(crate) fn package_manifest_name(path: &Path) -> Option<String> {
937 let manifest_path = if path.is_dir() {
938 path.join(MANIFEST)
939 } else {
940 path.parent()?.join(MANIFEST)
941 };
942 let manifest = read_manifest_from_path(&manifest_path).ok()?;
943 manifest
944 .package
945 .and_then(|pkg| pkg.name)
946 .map(|name| name.trim().to_string())
947 .filter(|name| !name.is_empty())
948}
949
950pub(crate) fn derive_package_alias_from_path(path: &Path) -> Result<String, PackageError> {
951 if let Some(name) = package_manifest_name(path) {
952 return Ok(name);
953 }
954 let fallback = if path.is_dir() {
955 path.file_name()
956 } else {
957 path.file_stem()
958 };
959 fallback
960 .and_then(|name| name.to_str())
961 .map(str::trim)
962 .filter(|name| !name.is_empty())
963 .map(str::to_string)
964 .ok_or_else(|| {
965 PackageError::Registry(format!(
966 "failed to derive package alias from {}",
967 path.display()
968 ))
969 })
970}
971
972pub(crate) fn is_full_git_sha(value: &str) -> bool {
973 value.len() == 40 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
974}
975
976pub(crate) fn git_output<I, S>(
977 args: I,
978 cwd: Option<&Path>,
979) -> Result<std::process::Output, PackageError>
980where
981 I: IntoIterator<Item = S>,
982 S: AsRef<OsStr>,
983{
984 let mut command = process::Command::new("git");
985 command.args(args);
986 if let Some(dir) = cwd {
987 command.current_dir(dir);
988 }
989 command
990 .env_remove("GIT_DIR")
991 .env_remove("GIT_WORK_TREE")
992 .env_remove("GIT_INDEX_FILE")
993 .output()
994 .map_err(|error| PackageError::Registry(format!("failed to run git: {error}")))
995}
996
997pub(crate) fn resolve_git_commit(
998 url: &str,
999 rev: Option<&str>,
1000 branch: Option<&str>,
1001) -> Result<String, PackageError> {
1002 let requested = branch.or(rev).unwrap_or("HEAD");
1003 if branch.is_none() && is_full_git_sha(requested) {
1004 return Ok(requested.to_string());
1005 }
1006
1007 let refs = if let Some(branch) = branch {
1008 vec![format!("refs/heads/{branch}")]
1009 } else if requested == "HEAD" {
1010 vec!["HEAD".to_string()]
1011 } else {
1012 vec![
1013 requested.to_string(),
1014 format!("refs/tags/{requested}^{{}}"),
1015 format!("refs/tags/{requested}"),
1016 format!("refs/heads/{requested}"),
1017 ]
1018 };
1019
1020 let output = git_output(
1021 std::iter::once("ls-remote".to_string())
1022 .chain(std::iter::once(url.to_string()))
1023 .chain(refs.clone()),
1024 None,
1025 )?;
1026 if !output.status.success() {
1027 return Err(format!(
1028 "failed to resolve git ref from {url}: {}",
1029 String::from_utf8_lossy(&output.stderr).trim()
1030 )
1031 .into());
1032 }
1033 let stdout = String::from_utf8_lossy(&output.stdout);
1034 let commit = stdout
1035 .lines()
1036 .filter_map(|line| line.split_whitespace().next())
1037 .find(|value| is_full_git_sha(value))
1038 .ok_or_else(|| format!("could not resolve {requested} from {url}"))?;
1039 Ok(commit.to_string())
1040}
1041
1042pub(crate) fn clone_git_commit_to(
1043 url: &str,
1044 commit: &str,
1045 dest: &Path,
1046) -> Result<(), PackageError> {
1047 if dest.exists() {
1048 fs::remove_dir_all(dest)
1049 .map_err(|error| format!("failed to reset {}: {error}", dest.display()))?;
1050 }
1051 fs::create_dir_all(dest)
1052 .map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
1053
1054 let init = git_output(["init", "--quiet"], Some(dest))?;
1055 if !init.status.success() {
1056 return Err(format!(
1057 "failed to initialize git repo in {}: {}",
1058 dest.display(),
1059 String::from_utf8_lossy(&init.stderr).trim()
1060 )
1061 .into());
1062 }
1063
1064 let remote = git_output(["remote", "add", "origin", url], Some(dest))?;
1065 if !remote.status.success() {
1066 return Err(format!(
1067 "failed to add git remote {url}: {}",
1068 String::from_utf8_lossy(&remote.stderr).trim()
1069 )
1070 .into());
1071 }
1072
1073 let fetch = git_output(["fetch", "--depth", "1", "origin", commit], Some(dest))?;
1074 if !fetch.status.success() {
1075 let fallback_dir = dest.with_extension("full-clone");
1076 if fallback_dir.exists() {
1077 fs::remove_dir_all(&fallback_dir)
1078 .map_err(|error| format!("failed to remove {}: {error}", fallback_dir.display()))?;
1079 }
1080 let clone = git_output(
1081 ["clone", url, fallback_dir.to_string_lossy().as_ref()],
1082 None,
1083 )?;
1084 if !clone.status.success() {
1085 return Err(format!(
1086 "failed to fetch {commit} from {url}: {}",
1087 String::from_utf8_lossy(&fetch.stderr).trim()
1088 )
1089 .into());
1090 }
1091 let checkout = git_output(["checkout", commit], Some(&fallback_dir))?;
1092 if !checkout.status.success() {
1093 return Err(format!(
1094 "failed to checkout {commit} in {}: {}",
1095 fallback_dir.display(),
1096 String::from_utf8_lossy(&checkout.stderr).trim()
1097 )
1098 .into());
1099 }
1100 fs::remove_dir_all(dest)
1101 .map_err(|error| format!("failed to remove {}: {error}", dest.display()))?;
1102 fs::rename(&fallback_dir, dest).map_err(|error| {
1103 format!(
1104 "failed to move {} to {}: {error}",
1105 fallback_dir.display(),
1106 dest.display()
1107 )
1108 })?;
1109 } else {
1110 let checkout = git_output(["checkout", "--detach", "FETCH_HEAD"], Some(dest))?;
1111 if !checkout.status.success() {
1112 return Err(format!(
1113 "failed to checkout FETCH_HEAD in {}: {}",
1114 dest.display(),
1115 String::from_utf8_lossy(&checkout.stderr).trim()
1116 )
1117 .into());
1118 }
1119 }
1120
1121 let git_dir = dest.join(".git");
1122 if git_dir.exists() {
1123 fs::remove_dir_all(&git_dir)
1124 .map_err(|error| format!("failed to remove {}: {error}", git_dir.display()))?;
1125 }
1126 Ok(())
1127}
1128
1129pub(crate) fn unique_temp_dir(base: &Path, label: &str) -> Result<PathBuf, PackageError> {
1130 for _ in 0..16 {
1131 let suffix = uuid::Uuid::now_v7();
1132 let candidate = base.join(format!("{label}-{suffix}"));
1133 if !candidate.exists() {
1134 return Ok(candidate);
1135 }
1136 }
1137 Err(format!(
1138 "failed to allocate a unique temporary directory under {}",
1139 base.display()
1140 )
1141 .into())
1142}
1143
1144pub(crate) fn ensure_git_cache_populated(
1145 url: &str,
1146 source: &str,
1147 commit: &str,
1148 expected_hash: Option<&str>,
1149 refetch: bool,
1150 offline: bool,
1151) -> Result<String, PackageError> {
1152 let cache_dir = git_cache_dir(source, commit)?;
1153 let _lock = acquire_git_cache_lock(source, commit)?;
1154 if refetch && cache_dir.exists() {
1155 fs::remove_dir_all(&cache_dir)
1156 .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1157 }
1158 if cache_dir.exists() {
1159 if let Some(expected) = expected_hash {
1160 verify_content_hash_or_compute(&cache_dir, expected)?;
1161 write_cache_metadata(&cache_dir, source, commit, expected)?;
1162 return Ok(expected.to_string());
1163 }
1164 let hash = compute_content_hash(&cache_dir)?;
1165 write_cached_content_hash(&cache_dir, &hash)?;
1166 write_cache_metadata(&cache_dir, source, commit, &hash)?;
1167 return Ok(hash);
1168 }
1169
1170 if offline {
1171 return Err(format!(
1172 "package cache entry for {source} at {commit} is missing; cannot fetch in offline mode"
1173 )
1174 .into());
1175 }
1176
1177 let parent = cache_dir
1178 .parent()
1179 .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1180 fs::create_dir_all(parent)
1181 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1182 let temp_dir = unique_temp_dir(parent, "tmp")?;
1183 let populated = (|| -> Result<String, PackageError> {
1184 clone_git_commit_to(url, commit, &temp_dir)?;
1185 let hash = compute_content_hash(&temp_dir)?;
1186 if let Some(expected) = expected_hash {
1187 if hash != expected {
1188 return Err(format!(
1189 "content hash mismatch for {} at {}: expected {}, got {}",
1190 source, commit, expected, hash
1191 )
1192 .into());
1193 }
1194 }
1195 write_cached_content_hash(&temp_dir, &hash)?;
1196 write_cache_metadata(&temp_dir, source, commit, &hash)?;
1197 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1198 format!(
1199 "failed to move {} to {}: {error}",
1200 temp_dir.display(),
1201 cache_dir.display()
1202 )
1203 })?;
1204 Ok(hash)
1205 })();
1206 let hash = match populated {
1207 Ok(hash) => hash,
1208 Err(error) => {
1209 let _ = fs::remove_dir_all(&temp_dir);
1210 return Err(error);
1211 }
1212 };
1213 Ok(hash)
1214}
1215
1216#[derive(Debug, Clone)]
1217pub(crate) struct PackageCacheEntry {
1218 path: PathBuf,
1219 source_hash: String,
1220 commit: String,
1221 metadata: Option<PackageCacheMetadata>,
1222}
1223
1224pub(crate) fn git_cache_root() -> Result<PathBuf, PackageError> {
1225 Ok(cache_root()?.join("git"))
1226}
1227
1228pub(crate) fn discover_git_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1229 let root = git_cache_root()?;
1230 let mut entries = Vec::new();
1231 let source_dirs = match fs::read_dir(&root) {
1232 Ok(source_dirs) => source_dirs,
1233 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1234 Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1235 };
1236 for source_dir in source_dirs {
1237 let source_dir = source_dir
1238 .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1239 let source_type = source_dir
1240 .file_type()
1241 .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1242 if !source_type.is_dir() {
1243 continue;
1244 }
1245 let source_hash = source_dir.file_name().to_string_lossy().to_string();
1246 let commit_dirs = fs::read_dir(source_dir.path())
1247 .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1248 for commit_dir in commit_dirs {
1249 let commit_dir = commit_dir.map_err(|error| {
1250 format!(
1251 "failed to read {} entry: {error}",
1252 source_dir.path().display()
1253 )
1254 })?;
1255 let commit_type = commit_dir.file_type().map_err(|error| {
1256 format!("failed to stat {}: {error}", commit_dir.path().display())
1257 })?;
1258 if !commit_type.is_dir() {
1259 continue;
1260 }
1261 let commit = commit_dir.file_name().to_string_lossy().to_string();
1262 if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1263 continue;
1264 }
1265 let metadata = read_cache_metadata(&commit_dir.path())?;
1266 entries.push(PackageCacheEntry {
1267 path: commit_dir.path(),
1268 source_hash: source_hash.clone(),
1269 commit,
1270 metadata,
1271 });
1272 }
1273 }
1274 entries.sort_by(|left, right| {
1275 left.source_hash
1276 .cmp(&right.source_hash)
1277 .then_with(|| left.commit.cmp(&right.commit))
1278 });
1279 Ok(entries)
1280}
1281
1282pub(crate) fn locked_git_cache_paths(lock: &LockFile) -> Result<HashSet<PathBuf>, PackageError> {
1283 let mut keep = HashSet::new();
1284 for entry in &lock.packages {
1285 validate_package_alias(&entry.name)?;
1286 if !entry.source.starts_with("git+") {
1287 continue;
1288 }
1289 let commit = entry
1290 .commit
1291 .as_deref()
1292 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1293 keep.insert(git_cache_dir(&entry.source, commit)?);
1294 }
1295 Ok(keep)
1296}
1297
1298pub(crate) fn verify_lock_entry_cache(entry: &LockEntry) -> Result<bool, PackageError> {
1299 validate_package_alias(&entry.name)?;
1300 if !entry.source.starts_with("git+") {
1301 if entry.source.starts_with("path+") {
1302 let path = path_from_source_uri(&entry.source)?;
1303 if !path.exists() {
1304 return Err(format!(
1305 "path dependency {} source is missing: {}",
1306 entry.name,
1307 path.display()
1308 )
1309 .into());
1310 }
1311 }
1312 return Ok(false);
1313 }
1314 let commit = entry
1315 .commit
1316 .as_deref()
1317 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1318 let expected_hash = entry
1319 .content_hash
1320 .as_deref()
1321 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1322 let cache_dir = git_cache_dir(&entry.source, commit)?;
1323 if !cache_dir.is_dir() {
1324 return Err(format!(
1325 "package cache entry for {} is missing: {}",
1326 entry.name,
1327 cache_dir.display()
1328 )
1329 .into());
1330 }
1331 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1332 match read_cache_metadata(&cache_dir)? {
1333 Some(metadata)
1334 if metadata.source == entry.source
1335 && metadata.commit == commit
1336 && metadata.content_hash == expected_hash => {}
1337 Some(metadata) => {
1338 return Err(format!(
1339 "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
1340 entry.name,
1341 entry.source,
1342 commit,
1343 expected_hash,
1344 metadata.source,
1345 metadata.commit,
1346 metadata.content_hash
1347 )
1348 .into());
1349 }
1350 None => write_cache_metadata(&cache_dir, &entry.source, commit, expected_hash)?,
1351 }
1352 Ok(true)
1353}
1354
1355pub(crate) fn verify_materialized_lock_entry(
1356 ctx: &ManifestContext,
1357 entry: &LockEntry,
1358) -> Result<bool, PackageError> {
1359 validate_package_alias(&entry.name)?;
1360 let packages_dir = ctx.packages_dir();
1361 if entry.source.starts_with("path+") {
1362 let dir = packages_dir.join(&entry.name);
1363 let file = packages_dir.join(format!("{}.harn", entry.name));
1364 if !dir.exists() && !file.exists() {
1365 return Err(format!(
1366 "materialized path dependency {} is missing under {}",
1367 entry.name,
1368 packages_dir.display()
1369 )
1370 .into());
1371 }
1372 return Ok(true);
1373 }
1374 if !entry.source.starts_with("git+") {
1375 return Ok(false);
1376 }
1377 let expected_hash = entry
1378 .content_hash
1379 .as_deref()
1380 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1381 let dest_dir = packages_dir.join(&entry.name);
1382 if !dest_dir.is_dir() {
1383 return Err(format!(
1384 "materialized package {} is missing: {}",
1385 entry.name,
1386 dest_dir.display()
1387 )
1388 .into());
1389 }
1390 verify_content_hash_or_compute(&dest_dir, expected_hash)?;
1391 Ok(true)
1392}
1393
1394pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
1395 let ctx = load_current_manifest_context()?;
1396 let lock = LockFile::load(&ctx.lock_path())?
1397 .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
1398 validate_lock_matches_manifest(&ctx, &lock)?;
1399 let mut verified = 0usize;
1400 for entry in &lock.packages {
1401 if verify_lock_entry_cache(entry)? {
1402 verified += 1;
1403 }
1404 if materialized && verify_materialized_lock_entry(&ctx, entry)? {
1405 verified += 1;
1406 }
1407 }
1408 Ok(verified)
1409}
1410
1411pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
1412 let entries = discover_git_cache_entries()?;
1413 if entries.is_empty() {
1414 return Ok(0);
1415 }
1416 if all {
1417 let root = cache_root()?;
1418 for child in ["git", "locks"] {
1419 let path = root.join(child);
1420 if path.exists() {
1421 fs::remove_dir_all(&path)
1422 .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
1423 }
1424 }
1425 return Ok(entries.len());
1426 }
1427
1428 let ctx = load_current_manifest_context()?;
1429 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
1430 format!(
1431 "{} is missing; pass --all to clean every cache entry",
1432 LOCK_FILE
1433 )
1434 })?;
1435 validate_lock_matches_manifest(&ctx, &lock)?;
1436 let keep = locked_git_cache_paths(&lock)?;
1437 let mut removed = 0usize;
1438 for entry in entries {
1439 if keep.contains(&entry.path) {
1440 continue;
1441 }
1442 fs::remove_dir_all(&entry.path)
1443 .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
1444 removed += 1;
1445 if let Some(parent) = entry.path.parent() {
1446 let is_empty = fs::read_dir(parent)
1447 .map(|mut children| children.next().is_none())
1448 .unwrap_or(false);
1449 if is_empty {
1450 fs::remove_dir(parent)
1451 .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
1452 }
1453 }
1454 }
1455 Ok(removed)
1456}
1457
1458pub fn list_package_cache() {
1459 let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
1460 Ok((cache_root()?, discover_git_cache_entries()?))
1461 })();
1462
1463 match result {
1464 Ok((root, entries)) => {
1465 println!("Cache root: {}", root.display());
1466 if entries.is_empty() {
1467 println!("No cached git packages.");
1468 return;
1469 }
1470 println!("commit\tcontent_hash\tsource\tpath");
1471 for entry in entries {
1472 let (source, content_hash) = entry
1473 .metadata
1474 .as_ref()
1475 .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
1476 .unwrap_or(("(unknown)", "(unknown)"));
1477 println!(
1478 "{}\t{}\t{}\t{}",
1479 entry.commit,
1480 content_hash,
1481 source,
1482 entry.path.display()
1483 );
1484 }
1485 }
1486 Err(error) => {
1487 eprintln!("error: {error}");
1488 process::exit(1);
1489 }
1490 }
1491}
1492
1493pub fn clean_package_cache(all: bool) {
1494 match clean_package_cache_impl(all) {
1495 Ok(removed) => println!("Removed {removed} cached package entries."),
1496 Err(error) => {
1497 eprintln!("error: {error}");
1498 process::exit(1);
1499 }
1500 }
1501}
1502
1503pub fn verify_package_cache(materialized: bool) {
1504 match verify_package_cache_impl(materialized) {
1505 Ok(verified) => println!("Verified {verified} package cache entries."),
1506 Err(error) => {
1507 eprintln!("error: {error}");
1508 process::exit(1);
1509 }
1510 }
1511}
1512
1513pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
1514 match search_package_registry_impl(query, registry) {
1515 Ok(packages) if json => {
1516 println!(
1517 "{}",
1518 serde_json::to_string_pretty(&packages)
1519 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1520 );
1521 }
1522 Ok(packages) => {
1523 if packages.is_empty() {
1524 println!("No packages found.");
1525 return;
1526 }
1527 println!("name\tlatest\tharn\tcontract\tdescription");
1528 for package in packages {
1529 let latest = latest_registry_version(&package)
1530 .map(|version| version.version.as_str())
1531 .unwrap_or("-");
1532 println!(
1533 "{}\t{}\t{}\t{}\t{}",
1534 package.name,
1535 latest,
1536 package.harn.as_deref().unwrap_or("-"),
1537 package.connector_contract.as_deref().unwrap_or("-"),
1538 package.description.as_deref().unwrap_or("")
1539 );
1540 }
1541 }
1542 Err(error) => {
1543 eprintln!("error: {error}");
1544 process::exit(1);
1545 }
1546 }
1547}
1548
1549pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
1550 match package_registry_info_impl(spec, registry) {
1551 Ok(info) if json => {
1552 println!(
1553 "{}",
1554 serde_json::to_string_pretty(&info)
1555 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1556 );
1557 }
1558 Ok(info) => {
1559 let package = info.package;
1560 println!("{}", package.name);
1561 if let Some(description) = package.description.as_deref() {
1562 println!("description: {description}");
1563 }
1564 println!("repository: {}", package.repository);
1565 if let Some(license) = package.license.as_deref() {
1566 println!("license: {license}");
1567 }
1568 if let Some(harn) = package.harn.as_deref() {
1569 println!("harn: {harn}");
1570 }
1571 if let Some(contract) = package.connector_contract.as_deref() {
1572 println!("connector_contract: {contract}");
1573 }
1574 if let Some(docs) = package.docs_url.as_deref() {
1575 println!("docs: {docs}");
1576 }
1577 if let Some(checksum) = package.checksum.as_deref() {
1578 println!("checksum: {checksum}");
1579 }
1580 if let Some(provenance) = package.provenance.as_deref() {
1581 println!("provenance: {provenance}");
1582 }
1583 if !package.exports.is_empty() {
1584 println!("exports: {}", package.exports.join(", "));
1585 }
1586 if let Some(version) = info.selected_version {
1587 println!("selected: {}", version.version);
1588 println!("git: {}", version.git);
1589 if let Some(rev) = version.rev.as_deref() {
1590 println!("rev: {rev}");
1591 }
1592 if let Some(branch) = version.branch.as_deref() {
1593 println!("branch: {branch}");
1594 }
1595 if let Some(package_name) = version.package.as_deref() {
1596 println!("package: {package_name}");
1597 }
1598 }
1599 if !package.versions.is_empty() {
1600 let versions = package
1601 .versions
1602 .iter()
1603 .map(|version| {
1604 if version.yanked {
1605 format!("{} (yanked)", version.version)
1606 } else {
1607 version.version.clone()
1608 }
1609 })
1610 .collect::<Vec<_>>()
1611 .join(", ");
1612 println!("versions: {versions}");
1613 }
1614 }
1615 Err(error) => {
1616 eprintln!("error: {error}");
1617 process::exit(1);
1618 }
1619 }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624 use super::*;
1625 use crate::package::test_support::*;
1626
1627 #[test]
1628 fn compute_content_hash_ignores_git_and_hash_marker() {
1629 let tmp = tempfile::tempdir().unwrap();
1630 let root = tmp.path();
1631 fs::create_dir_all(root.join(".git")).unwrap();
1632 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
1633 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
1634 fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
1635 fs::write(
1636 root.join("lib.harn"),
1637 "pub fn value() -> number { return 1 }\n",
1638 )
1639 .unwrap();
1640 let first = compute_content_hash(root).unwrap();
1641 fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
1642 fs::write(root.join(".gitignore"), "changed\n").unwrap();
1643 fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
1644 let second = compute_content_hash(root).unwrap();
1645 assert_eq!(first, second);
1646 }
1647
1648 #[cfg(unix)]
1649 #[test]
1650 fn remove_materialized_package_unlinks_directory_symlink_without_touching_source() {
1651 let tmp = tempfile::tempdir().unwrap();
1652 let source = tmp.path().join("source");
1653 let packages = tmp.path().join(".harn/packages");
1654 fs::create_dir_all(&source).unwrap();
1655 fs::create_dir_all(&packages).unwrap();
1656 fs::write(
1657 source.join("lib.harn"),
1658 "pub fn value() -> number { return 1 }\n",
1659 )
1660 .unwrap();
1661
1662 let materialized = packages.join("acme");
1663 std::os::unix::fs::symlink(&source, &materialized).unwrap();
1664
1665 remove_materialized_package(&packages, "acme").unwrap();
1666
1667 assert!(!materialized.exists());
1668 assert!(source.join("lib.harn").is_file());
1669 }
1670
1671 #[test]
1672 fn package_cache_verify_detects_tampering_even_with_stale_marker() {
1673 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1674 let project_tmp = tempfile::tempdir().unwrap();
1675 let root = project_tmp.path();
1676 let cache_dir = root.join(".cache");
1677 fs::create_dir_all(root.join(".git")).unwrap();
1678 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1679 fs::write(
1680 root.join(MANIFEST),
1681 format!(
1682 r#"
1683 [package]
1684 name = "workspace"
1685 version = "0.1.0"
1686
1687 [dependencies]
1688 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1689 "#
1690 ),
1691 )
1692 .unwrap();
1693
1694 with_test_env(root, &cache_dir, || {
1695 install_packages_impl(false, None, false).unwrap();
1696 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1697 let entry = lock.find("acme-lib").unwrap();
1698 let cache_dir = git_cache_dir(&entry.source, entry.commit.as_deref().unwrap()).unwrap();
1699 fs::write(
1700 cache_dir.join("lib.harn"),
1701 "pub fn value() { return \"pwned\" }\n",
1702 )
1703 .unwrap();
1704
1705 let error = verify_package_cache_impl(false).unwrap_err();
1706 assert!(error.to_string().contains("content hash mismatch"));
1707 });
1708 }
1709
1710 #[test]
1711 fn package_cache_clean_all_removes_cached_git_entries() {
1712 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1713 let project_tmp = tempfile::tempdir().unwrap();
1714 let root = project_tmp.path();
1715 let cache_dir = root.join(".cache");
1716 fs::create_dir_all(root.join(".git")).unwrap();
1717 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1718 fs::write(
1719 root.join(MANIFEST),
1720 format!(
1721 r#"
1722 [package]
1723 name = "workspace"
1724 version = "0.1.0"
1725
1726 [dependencies]
1727 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1728 "#
1729 ),
1730 )
1731 .unwrap();
1732
1733 with_test_env(root, &cache_dir, || {
1734 install_packages_impl(false, None, false).unwrap();
1735 assert_eq!(discover_git_cache_entries().unwrap().len(), 1);
1736
1737 let removed = clean_package_cache_impl(true).unwrap();
1738 assert_eq!(removed, 1);
1739 assert!(discover_git_cache_entries().unwrap().is_empty());
1740 });
1741 }
1742
1743 #[test]
1744 fn registry_index_search_and_info_use_local_file_without_network() {
1745 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1746 let project_tmp = tempfile::tempdir().unwrap();
1747 let root = project_tmp.path();
1748 let cache_dir = root.join(".cache");
1749 let registry_path = root.join("index.toml");
1750 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1751 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
1752 fs::create_dir_all(root.join(".git")).unwrap();
1753 fs::write(
1754 root.join(MANIFEST),
1755 r#"
1756 [package]
1757 name = "workspace"
1758 version = "0.1.0"
1759 "#,
1760 )
1761 .unwrap();
1762
1763 with_test_env(root, &cache_dir, || {
1764 let matches = search_package_registry_impl(Some("acme"), Some("index.toml")).unwrap();
1765 assert_eq!(matches.len(), 1);
1766 assert_eq!(matches[0].name, "@burin/acme-lib");
1767 assert_eq!(
1768 matches[0].harn.as_deref(),
1769 Some(crate::package::current_harn_range_example().as_str())
1770 );
1771 assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
1772 assert_eq!(matches[0].exports, vec!["lib"]);
1773
1774 let info =
1775 package_registry_info_impl("@burin/acme-lib@1.0.0", Some("index.toml")).unwrap();
1776 assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
1777 assert_eq!(
1778 info.selected_version
1779 .as_ref()
1780 .map(|version| version.git.as_str()),
1781 Some(git.as_str())
1782 );
1783 });
1784 }
1785
1786 #[test]
1787 fn add_registry_dependency_writes_existing_git_dependency_shape() {
1788 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1789 let project_tmp = tempfile::tempdir().unwrap();
1790 let root = project_tmp.path();
1791 let cache_dir = root.join(".cache");
1792 let registry_path = root.join("index.toml");
1793 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1794 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
1795 fs::create_dir_all(root.join(".git")).unwrap();
1796 fs::write(
1797 root.join(MANIFEST),
1798 r#"
1799 [package]
1800 name = "workspace"
1801 version = "0.1.0"
1802 "#,
1803 )
1804 .unwrap();
1805
1806 with_test_env(root, &cache_dir, || {
1807 std::env::set_var(HARN_PACKAGE_REGISTRY_ENV, "index.toml");
1808 add_package("@burin/acme-lib@1.0.0", None, None, None, None, None, None);
1809
1810 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1811 assert!(
1812 manifest.contains(&format!(
1813 "acme-lib = {{ git = \"{git}\", rev = \"v1.0.0\" }}"
1814 )),
1815 "registry install should write the same dependency line as a direct git add: {manifest}"
1816 );
1817 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1818 let entry = lock.find("acme-lib").unwrap();
1819 assert_eq!(entry.source, format!("git+{git}"));
1820 assert!(root
1821 .join(PKG_DIR)
1822 .join("acme-lib")
1823 .join("lib.harn")
1824 .is_file());
1825 });
1826 }
1827
1828 #[test]
1829 fn registry_index_rejects_invalid_names_and_duplicate_versions() {
1830 let content = r#"
1831 version = 1
1832
1833 [[package]]
1834 name = "@bad/"
1835 repository = "https://github.com/acme/acme-lib"
1836
1837 [[package.version]]
1838 version = "1.0.0"
1839 git = "https://github.com/acme/acme-lib"
1840 rev = "v1.0.0"
1841 "#;
1842 let error = parse_package_registry_index("fixture", content).unwrap_err();
1843 assert!(error.to_string().contains("invalid package name"));
1844
1845 let content = r#"
1846 version = 1
1847
1848 [[package]]
1849 name = "@burin/acme-lib"
1850 repository = "https://github.com/acme/acme-lib"
1851
1852 [[package.version]]
1853 version = "1.0.0"
1854 git = "https://github.com/acme/acme-lib"
1855 rev = "v1.0.0"
1856
1857 [[package.version]]
1858 version = "1.0.0"
1859 git = "https://github.com/acme/acme-lib"
1860 rev = "v1.0.0"
1861 "#;
1862 let error = parse_package_registry_index("fixture", content).unwrap_err();
1863 assert!(error.to_string().contains("more than once"));
1864 }
1865}