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