1use super::errors::PackageError;
2use super::*;
3use semver::{Version, VersionReq};
4
5const PRESERVED_GIT_ENV: &[&str] = &[
6 "PATH",
7 "TMPDIR",
8 "TEMP",
9 "TMP",
10 "SystemRoot",
11 "WINDIR",
12 "COMSPEC",
13 "PATHEXT",
14];
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub(crate) struct PackageCacheMetadata {
18 version: u32,
19 source: String,
20 commit: String,
21 content_hash: String,
22 cached_at_unix_ms: u128,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub(crate) struct PackageRegistryIndex {
27 version: u32,
28 #[serde(default, rename = "package")]
29 packages: Vec<RegistryPackage>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub(crate) struct RegistryPackage {
34 name: String,
35 #[serde(default)]
36 description: Option<String>,
37 repository: String,
38 #[serde(default)]
39 license: Option<String>,
40 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
41 harn: Option<String>,
42 #[serde(default)]
43 exports: Vec<String>,
44 #[serde(default, alias = "connector-contract")]
45 connector_contract: Option<String>,
46 #[serde(default)]
47 docs_url: Option<String>,
48 #[serde(default)]
49 checksum: Option<String>,
50 #[serde(default)]
51 provenance: Option<String>,
52 #[serde(default, rename = "version")]
53 versions: Vec<RegistryPackageVersion>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub(crate) struct RegistryPackageVersion {
58 version: String,
59 git: String,
60 #[serde(default)]
61 tag: Option<String>,
62 #[serde(default)]
63 rev: Option<String>,
64 #[serde(default)]
65 sha: Option<String>,
66 #[serde(default)]
67 branch: Option<String>,
68 #[serde(default)]
69 package: Option<String>,
70 #[serde(default)]
71 checksum: Option<String>,
72 #[serde(default)]
73 provenance: Option<String>,
74 #[serde(default)]
75 yanked: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub(crate) struct RegistryPackageInfo {
80 package: RegistryPackage,
81 selected_version: Option<RegistryPackageVersion>,
82}
83
84pub(crate) fn manifest_has_git_dependencies(manifest: &Manifest) -> bool {
85 manifest.dependencies.values().any(Dependency::requires_git)
86}
87
88pub(crate) fn ensure_git_available() -> Result<(), PackageError> {
89 process::Command::new("git")
90 .arg("--version")
91 .env_remove("GIT_DIR")
92 .env_remove("GIT_WORK_TREE")
93 .env_remove("GIT_INDEX_FILE")
94 .output()
95 .map(|_| ())
96 .map_err(|_| {
97 PackageError::Registry(
98 "git is required for git dependencies but was not found in PATH".to_string(),
99 )
100 })
101}
102
103pub(crate) fn cache_root() -> Result<PathBuf, PackageError> {
104 PackageWorkspace::from_current_dir()?.cache_root()
105}
106
107pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
108 hex_bytes(Sha256::digest(bytes.as_ref()))
109}
110
111pub(crate) fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
112 const HEX: &[u8; 16] = b"0123456789abcdef";
113 let bytes = bytes.as_ref();
114 let mut out = String::with_capacity(bytes.len() * 2);
115 for &byte in bytes {
116 out.push(HEX[(byte >> 4) as usize] as char);
117 out.push(HEX[(byte & 0x0f) as usize] as char);
118 }
119 out
120}
121
122pub(crate) fn git_cache_dir_in(
123 workspace: &PackageWorkspace,
124 source: &str,
125 commit: &str,
126) -> Result<PathBuf, PackageError> {
127 Ok(workspace
128 .cache_root()?
129 .join("git")
130 .join(sha256_hex(source))
131 .join(commit))
132}
133
134pub(crate) fn git_cache_lock_path_in(
135 workspace: &PackageWorkspace,
136 source: &str,
137 commit: &str,
138) -> Result<PathBuf, PackageError> {
139 Ok(workspace
140 .cache_root()?
141 .join("locks")
142 .join(format!("{}-{commit}.lock", sha256_hex(source))))
143}
144
145pub(crate) fn acquire_git_cache_lock_in(
146 workspace: &PackageWorkspace,
147 source: &str,
148 commit: &str,
149) -> Result<File, PackageError> {
150 let path = git_cache_lock_path_in(workspace, source, commit)?;
151 if let Some(parent) = path.parent() {
152 fs::create_dir_all(parent)
153 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
154 }
155 let file = File::create(&path)
156 .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
157 file.lock_exclusive()
158 .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
159 Ok(file)
160}
161
162pub(crate) fn read_cached_content_hash(dir: &Path) -> Result<Option<String>, PackageError> {
163 let path = dir.join(CONTENT_HASH_FILE);
164 match fs::read_to_string(&path) {
165 Ok(value) => Ok(Some(value.trim().to_string())),
166 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
167 Err(error) => Err(format!("failed to read {}: {error}", path.display()).into()),
168 }
169}
170
171pub(crate) fn write_cached_content_hash(dir: &Path, hash: &str) -> Result<(), PackageError> {
172 let path = dir.join(CONTENT_HASH_FILE);
173 harn_vm::atomic_io::atomic_write(&path, format!("{hash}\n").as_bytes()).map_err(|error| {
174 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
175 })
176}
177
178pub(crate) fn read_cache_metadata(
179 dir: &Path,
180) -> Result<Option<PackageCacheMetadata>, PackageError> {
181 let path = dir.join(CACHE_METADATA_FILE);
182 let content = match fs::read_to_string(&path) {
183 Ok(content) => content,
184 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
185 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
186 };
187 let metadata = toml::from_str::<PackageCacheMetadata>(&content)
188 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
189 if metadata.version != CACHE_METADATA_VERSION {
190 return Err(format!(
191 "unsupported {} version {} (expected {})",
192 path.display(),
193 metadata.version,
194 CACHE_METADATA_VERSION
195 )
196 .into());
197 }
198 Ok(Some(metadata))
199}
200
201pub(crate) fn write_cache_metadata(
202 dir: &Path,
203 source: &str,
204 commit: &str,
205 content_hash: &str,
206) -> Result<(), PackageError> {
207 let cached_at_unix_ms = SystemTime::now()
208 .duration_since(UNIX_EPOCH)
209 .map_err(|error| format!("system clock error: {error}"))?
210 .as_millis();
211 let metadata = PackageCacheMetadata {
212 version: CACHE_METADATA_VERSION,
213 source: source.to_string(),
214 commit: commit.to_string(),
215 content_hash: content_hash.to_string(),
216 cached_at_unix_ms,
217 };
218 let body = toml::to_string_pretty(&metadata)
219 .map_err(|error| format!("failed to encode cache metadata: {error}"))?;
220 let path = dir.join(CACHE_METADATA_FILE);
221 harn_vm::atomic_io::atomic_write(&path, body.as_bytes()).map_err(|error| {
222 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
223 })
224}
225
226pub(crate) fn normalized_relative_path(path: &Path) -> String {
227 path.components()
228 .map(|component| component.as_os_str().to_string_lossy())
229 .collect::<Vec<_>>()
230 .join("/")
231}
232
233pub(crate) fn collect_hashable_files(
234 root: &Path,
235 cursor: &Path,
236 out: &mut Vec<PathBuf>,
237) -> Result<(), PackageError> {
238 for entry in fs::read_dir(cursor)
239 .map_err(|error| format!("failed to read {}: {error}", cursor.display()))?
240 {
241 let entry =
242 entry.map_err(|error| format!("failed to read {} entry: {error}", cursor.display()))?;
243 let path = entry.path();
244 let file_type = entry
245 .file_type()
246 .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
247 let name = entry.file_name();
248 if name == OsStr::new(".git")
249 || name == OsStr::new(".gitignore")
250 || name == OsStr::new(CONTENT_HASH_FILE)
251 || name == OsStr::new(CACHE_METADATA_FILE)
252 {
253 continue;
254 }
255 if file_type.is_dir() {
256 collect_hashable_files(root, &path, out)?;
257 } else if file_type.is_file() {
258 let relative = path
259 .strip_prefix(root)
260 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
261 out.push(relative.to_path_buf());
262 }
263 }
264 Ok(())
265}
266
267pub(crate) fn compute_content_hash(dir: &Path) -> Result<String, PackageError> {
268 let mut files = Vec::new();
269 collect_hashable_files(dir, dir, &mut files)?;
270 files.sort();
271 let mut hasher = Sha256::new();
272 for relative in files {
273 let normalized = normalized_relative_path(&relative);
274 let contents = fs::read(dir.join(&relative)).map_err(|error| {
275 format!("failed to read {}: {error}", dir.join(&relative).display())
276 })?;
277 hasher.update(normalized.as_bytes());
278 hasher.update([0]);
279 hasher.update(sha256_hex(contents).as_bytes());
280 }
281 Ok(format!("sha256:{}", hex_bytes(hasher.finalize())))
282}
283
284pub(crate) fn verify_content_hash_or_compute(
285 dir: &Path,
286 expected: &str,
287) -> Result<(), PackageError> {
288 let actual = compute_content_hash(dir)?;
289 if actual != expected {
290 return Err(format!(
291 "content hash mismatch for {}: expected {}, got {}",
292 dir.display(),
293 expected,
294 actual
295 )
296 .into());
297 }
298 if read_cached_content_hash(dir)?.as_deref() != Some(expected) {
299 write_cached_content_hash(dir, expected)?;
300 }
301 Ok(())
302}
303
304pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {
305 fs::create_dir_all(dst)
306 .map_err(|error| format!("failed to create {}: {error}", dst.display()))?;
307 for entry in
308 fs::read_dir(src).map_err(|error| format!("failed to read {}: {error}", src.display()))?
309 {
310 let entry =
311 entry.map_err(|error| format!("failed to read {} entry: {error}", src.display()))?;
312 let ty = entry
313 .file_type()
314 .map_err(|error| format!("failed to stat {}: {error}", entry.path().display()))?;
315 let name = entry.file_name();
316 if name == OsStr::new(".git")
317 || name == OsStr::new(CONTENT_HASH_FILE)
318 || name == OsStr::new(CACHE_METADATA_FILE)
319 {
320 continue;
321 }
322 let dest_path = dst.join(entry.file_name());
323 if ty.is_dir() {
324 copy_dir_recursive(&entry.path(), &dest_path)?;
325 } else if ty.is_file() {
326 if let Some(parent) = dest_path.parent() {
327 fs::create_dir_all(parent)
328 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
329 }
330 fs::copy(entry.path(), &dest_path).map_err(|error| {
331 format!(
332 "failed to copy {} to {}: {error}",
333 entry.path().display(),
334 dest_path.display()
335 )
336 })?;
337 }
338 }
339 Ok(())
340}
341
342pub(crate) fn remove_materialized_package(
343 packages_dir: &Path,
344 alias: &str,
345) -> Result<(), PackageError> {
346 remove_materialized_path(&packages_dir.join(alias))?;
347 remove_materialized_path(&packages_dir.join(format!("{alias}.harn")))?;
348 Ok(())
349}
350
351fn remove_materialized_path(path: &Path) -> Result<(), PackageError> {
352 match fs::symlink_metadata(path) {
353 Ok(metadata) if is_link_like(&metadata) => remove_link_like_path(path)
354 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
355 Ok(metadata) if metadata.is_file() => fs::remove_file(path)
356 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
357 Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)
358 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
359 Ok(_) => Ok(()),
360 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
361 Err(error) => Err(format!("failed to stat {}: {error}", path.display()).into()),
362 }
363}
364
365fn is_link_like(metadata: &fs::Metadata) -> bool {
366 metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
367}
368
369#[cfg(windows)]
370fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
371 use std::os::windows::fs::MetadataExt;
372
373 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
374 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
375}
376
377#[cfg(not(windows))]
378fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
379 false
380}
381
382fn remove_link_like_path(path: &Path) -> std::io::Result<()> {
383 match fs::remove_file(path) {
384 Ok(()) => Ok(()),
385 Err(file_error) => match fs::remove_dir(path) {
386 Ok(()) => Ok(()),
387 Err(_) => Err(file_error),
388 },
389 }
390}
391
392#[cfg(unix)]
393pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
394 std::os::unix::fs::symlink(source, dest).map_err(|error| {
395 PackageError::Registry(format!(
396 "failed to symlink {} to {}: {error}",
397 source.display(),
398 dest.display()
399 ))
400 })
401}
402
403#[cfg(windows)]
404pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
405 if source.is_dir() {
406 std::os::windows::fs::symlink_dir(source, dest)
407 } else {
408 std::os::windows::fs::symlink_file(source, dest)
409 }
410 .map_err(|error| {
411 PackageError::Registry(format!(
412 "failed to symlink {} to {}: {error}",
413 source.display(),
414 dest.display()
415 ))
416 })
417}
418
419#[cfg(not(any(unix, windows)))]
420pub(crate) fn symlink_path_dependency(_source: &Path, _dest: &Path) -> Result<(), PackageError> {
421 Err("symlinks are not supported on this platform"
422 .to_string()
423 .into())
424}
425
426pub(crate) fn materialize_path_dependency(
427 source: &Path,
428 dest_root: &Path,
429 alias: &str,
430) -> Result<(), PackageError> {
431 remove_materialized_package(dest_root, alias)?;
432 if source.is_dir() {
433 let dest = dest_root.join(alias);
434 match symlink_path_dependency(source, &dest) {
435 Ok(()) => Ok(()),
436 Err(_) => copy_dir_recursive(source, &dest),
437 }
438 } else {
439 let dest = dest_root.join(format!("{alias}.harn"));
440 if let Some(parent) = dest.parent() {
441 fs::create_dir_all(parent)
442 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
443 }
444 match symlink_path_dependency(source, &dest) {
445 Ok(()) => Ok(()),
446 Err(_) => {
447 fs::copy(source, &dest).map_err(|error| {
448 format!(
449 "failed to copy {} to {}: {error}",
450 source.display(),
451 dest.display()
452 )
453 })?;
454 Ok(())
455 }
456 }
457 }
458}
459
460pub(crate) fn materialized_hash_matches(dir: &Path, expected: &str) -> bool {
461 verify_content_hash_or_compute(dir, expected).is_ok()
462}
463
464pub(crate) fn resolve_path_dependency_source(
465 manifest_dir: &Path,
466 raw: &str,
467) -> Result<PathBuf, PackageError> {
468 let source = {
469 let candidate = PathBuf::from(raw);
470 if candidate.is_absolute() {
471 candidate
472 } else {
473 manifest_dir.join(candidate)
474 }
475 };
476 if source.exists() {
477 return source.canonicalize().map_err(|error| {
478 PackageError::Registry(format!(
479 "failed to canonicalize {}: {error}",
480 source.display()
481 ))
482 });
483 }
484 if source.extension().is_none() {
485 let with_ext = source.with_extension("harn");
486 if with_ext.exists() {
487 return with_ext.canonicalize().map_err(|error| {
488 PackageError::Registry(format!(
489 "failed to canonicalize {}: {error}",
490 with_ext.display()
491 ))
492 });
493 }
494 }
495 Err(format!("package source not found: {}", source.display()).into())
496}
497
498pub(crate) fn path_source_uri(path: &Path) -> Result<String, PackageError> {
499 let url = Url::from_file_path(path)
500 .map_err(|_| format!("failed to convert {} to file:// URL", path.display()))?;
501 Ok(format!("path+{url}"))
502}
503
504pub(crate) fn path_from_source_uri(source: &str) -> Result<PathBuf, PackageError> {
505 let raw = source
506 .strip_prefix("path+")
507 .ok_or_else(|| format!("invalid path source: {source}"))?;
508 if let Ok(url) = Url::parse(raw) {
509 return url
510 .to_file_path()
511 .map_err(|_| PackageError::Registry(format!("invalid file:// path source: {source}")));
512 }
513 Ok(PathBuf::from(raw))
514}
515
516pub(crate) fn registry_file_url_or_path(raw: &str) -> Result<Option<PathBuf>, PackageError> {
517 if let Ok(url) = Url::parse(raw) {
518 if url.scheme() == "file" {
519 return url.to_file_path().map(Some).map_err(|_| {
520 PackageError::Registry(format!("invalid file:// registry URL: {raw}"))
521 });
522 }
523 return Ok(None);
524 }
525 Ok(Some(PathBuf::from(raw)))
526}
527
528pub(crate) fn read_registry_source(source: &str) -> Result<String, PackageError> {
529 if let Some(path) = registry_file_url_or_path(source)? {
530 return fs::read_to_string(&path).map_err(|error| {
531 PackageError::Registry(format!(
532 "failed to read package registry {}: {error}",
533 path.display()
534 ))
535 });
536 }
537
538 let url = Url::parse(source)
539 .map_err(|error| format!("invalid package registry URL {source:?}: {error}"))?;
540 match url.scheme() {
541 "http" | "https" => {}
542 other => return Err(format!("unsupported package registry URL scheme: {other}").into()),
543 }
544 let source_owned = source.to_string();
550 std::thread::scope(|scope| {
551 scope
552 .spawn(move || fetch_registry_blocking(url, &source_owned))
553 .join()
554 .map_err(|_| PackageError::Registry("registry fetch thread panicked".to_string()))?
555 })
556}
557
558fn fetch_registry_blocking(url: Url, source: &str) -> Result<String, PackageError> {
559 let response = reqwest::blocking::Client::builder()
560 .timeout(Duration::from_secs(20))
561 .build()
562 .map_err(|error| format!("failed to build package registry client: {error}"))?
563 .get(url)
564 .send()
565 .map_err(|error| format!("failed to fetch package registry {source}: {error}"))?;
566 let status = response.status();
567 if !status.is_success() {
568 return Err(format!("GET {source} returned HTTP {status}").into());
569 }
570 response.text().map_err(|error| {
571 PackageError::Registry(format!("failed to read package registry response: {error}"))
572 })
573}
574
575pub(crate) fn is_valid_registry_segment(segment: &str) -> bool {
576 let mut chars = segment.chars();
577 let Some(first) = chars.next() else {
578 return false;
579 };
580 first.is_ascii_alphanumeric()
581 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
582}
583
584pub(crate) fn is_valid_registry_package_name(name: &str) -> bool {
585 let trimmed = name.trim();
586 if trimmed != name || trimmed.is_empty() || trimmed.contains("://") || trimmed.ends_with('/') {
587 return false;
588 }
589 if let Some(scoped) = trimmed.strip_prefix('@') {
590 let Some((scope, package)) = scoped.split_once('/') else {
591 return false;
592 };
593 return !package.contains('/')
594 && is_valid_registry_segment(scope)
595 && is_valid_registry_segment(package);
596 }
597 !trimmed.contains('/') && is_valid_registry_segment(trimmed)
598}
599
600pub(crate) fn parse_registry_package_spec(spec: &str) -> Option<(&str, Option<&str>)> {
601 let trimmed = spec.trim();
602 if !trimmed.starts_with('@') {
603 if let Some((name, version)) = trimmed.rsplit_once('@') {
604 if is_valid_registry_package_name(name) && !version.trim().is_empty() {
605 return Some((name, Some(version)));
606 }
607 }
608 if is_valid_registry_package_name(trimmed) {
609 return Some((trimmed, None));
610 }
611 return None;
612 }
613
614 if let Some((name, version)) = trimmed.rsplit_once('@') {
615 if !name.is_empty()
616 && name != trimmed
617 && is_valid_registry_package_name(name)
618 && !version.trim().is_empty()
619 {
620 return Some((name, Some(version)));
621 }
622 }
623 if is_valid_registry_package_name(trimmed) {
624 return Some((trimmed, None));
625 }
626 None
627}
628
629pub(crate) fn parse_package_registry_index(
630 source: &str,
631 content: &str,
632) -> Result<PackageRegistryIndex, PackageError> {
633 let mut index = toml::from_str::<PackageRegistryIndex>(content)
634 .map_err(|error| format!("failed to parse package registry {source}: {error}"))?;
635 if index.version != REGISTRY_INDEX_VERSION {
636 return Err(format!(
637 "unsupported package registry {source} version {} (expected {})",
638 index.version, REGISTRY_INDEX_VERSION
639 )
640 .into());
641 }
642 validate_package_registry_index(source, &mut index)?;
643 Ok(index)
644}
645
646pub(crate) fn validate_package_registry_index(
647 source: &str,
648 index: &mut PackageRegistryIndex,
649) -> Result<(), PackageError> {
650 let mut names = HashSet::new();
651 for package in &mut index.packages {
652 if !is_valid_registry_package_name(&package.name) {
653 return Err(format!(
654 "package registry {source} has invalid package name '{}'",
655 package.name
656 )
657 .into());
658 }
659 if !names.insert(package.name.clone()) {
660 return Err(format!(
661 "package registry {source} declares '{}' more than once",
662 package.name
663 )
664 .into());
665 }
666 normalize_git_url(&package.repository).map_err(|error| {
667 format!(
668 "package registry {source} has invalid repository for '{}': {error}",
669 package.name
670 )
671 })?;
672 let mut versions = HashSet::new();
673 for version in &package.versions {
674 if version.version.trim().is_empty() {
675 return Err(format!(
676 "package registry {source} has empty version for '{}'",
677 package.name
678 )
679 .into());
680 }
681 if !versions.insert(version.version.clone()) {
682 return Err(format!(
683 "package registry {source} declares '{}@{}' more than once",
684 package.name, version.version
685 )
686 .into());
687 }
688 if selected_git_ref_count(version) != 1 {
689 return Err(format!(
690 "package registry {source} entry '{}@{}' must specify tag, rev, or branch; rev may accompany tag as a resolved commit pin",
691 package.name, version.version
692 )
693 .into());
694 }
695 parse_registry_semver(&version.version).map_err(|error| {
696 format!(
697 "package registry {source} has invalid semver for '{}@{}': {error}",
698 package.name, version.version
699 )
700 })?;
701 normalize_git_url(&version.git).map_err(|error| {
702 format!(
703 "package registry {source} has invalid git source for '{}@{}': {error}",
704 package.name, version.version
705 )
706 })?;
707 }
708 }
709 index
710 .packages
711 .sort_by(|left, right| left.name.cmp(&right.name));
712 Ok(())
713}
714
715fn selected_git_ref_count(version: &RegistryPackageVersion) -> usize {
716 usize::from(version.tag.is_some())
717 + usize::from(version.tag.is_none() && version.rev.is_some())
718 + usize::from(version.branch.is_some())
719}
720
721pub(crate) fn load_package_registry_in(
722 workspace: &PackageWorkspace,
723 explicit: Option<&str>,
724) -> Result<(String, PackageRegistryIndex), PackageError> {
725 let source = workspace.resolve_registry_source(explicit)?;
726 let content = read_registry_source(&source)?;
727 let index = parse_package_registry_index(&source, &content)?;
728 Ok((source, index))
729}
730
731pub(crate) fn registry_package_matches(package: &RegistryPackage, query: &str) -> bool {
732 if query.trim().is_empty() {
733 return true;
734 }
735 let query = query.to_ascii_lowercase();
736 package.name.to_ascii_lowercase().contains(&query)
737 || package
738 .description
739 .as_deref()
740 .is_some_and(|value| value.to_ascii_lowercase().contains(&query))
741 || package.repository.to_ascii_lowercase().contains(&query)
742 || package
743 .exports
744 .iter()
745 .any(|export| export.to_ascii_lowercase().contains(&query))
746}
747
748pub(crate) fn latest_registry_version(
749 package: &RegistryPackage,
750) -> Option<&RegistryPackageVersion> {
751 package
752 .versions
753 .iter()
754 .filter(|version| !version.yanked)
755 .filter_map(|version| {
756 parse_registry_semver(&version.version)
757 .ok()
758 .map(|semver| (semver, version))
759 })
760 .max_by(|(left, _), (right, _)| left.cmp(right))
761 .map(|(_, version)| version)
762}
763
764impl PackageRegistryIndex {
765 pub(crate) fn latest_unyanked_version(&self, name: &str) -> Option<&str> {
766 self.packages
767 .iter()
768 .find(|package| package.name == name)
769 .and_then(latest_registry_version)
770 .map(|version| version.version.as_str())
771 }
772
773 pub(crate) fn is_version_yanked(&self, name: &str, version: &str) -> bool {
774 self.packages
775 .iter()
776 .find(|package| package.name == name)
777 .into_iter()
778 .flat_map(|package| package.versions.iter())
779 .any(|entry| entry.version == version && entry.yanked)
780 }
781}
782
783pub(crate) fn parse_registry_semver(raw: &str) -> Result<Version, PackageError> {
784 Version::parse(raw.trim().trim_start_matches('v'))
785 .map_err(|error| PackageError::Registry(error.to_string()))
786}
787
788pub(crate) fn parse_registry_version_req(raw: &str) -> Result<VersionReq, PackageError> {
789 VersionReq::parse(&normalize_registry_version_req(raw)).map_err(|error| {
790 PackageError::Registry(format!("invalid version requirement {raw:?}: {error}"))
791 })
792}
793
794fn normalize_registry_version_req(raw: &str) -> String {
795 raw.split(',')
796 .map(|part| normalize_version_req_part(part.trim()))
797 .collect::<Vec<_>>()
798 .join(",")
799}
800
801fn normalize_version_req_part(part: &str) -> String {
802 for op in ["<=", ">=", "!=", "=", "<", ">", "^", "~"] {
803 if let Some(rest) = part.strip_prefix(op) {
804 return format!("{op}{}", normalize_partial_version(rest.trim()));
805 }
806 }
807 normalize_partial_version(part)
808}
809
810fn normalize_partial_version(raw: &str) -> String {
811 let trimmed = raw.trim().trim_start_matches('v');
812 if trimmed == "*" || trimmed.eq_ignore_ascii_case("x") {
813 return trimmed.to_string();
814 }
815 let (core, suffix) = trimmed
816 .find(['-', '+'])
817 .map(|index| (&trimmed[..index], &trimmed[index..]))
818 .unwrap_or((trimmed, ""));
819 let mut parts = core.split('.').collect::<Vec<_>>();
820 if (1..=2).contains(&parts.len())
821 && parts
822 .iter()
823 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
824 {
825 while parts.len() < 3 {
826 parts.push("0");
827 }
828 return format!("{}{}", parts.join("."), suffix);
829 }
830 trimmed.to_string()
831}
832
833fn lookup_registry_package<'a>(
838 index: &'a PackageRegistryIndex,
839 name: &str,
840) -> Result<&'a RegistryPackage, PackageError> {
841 if let Some(package) = index.packages.iter().find(|package| package.name == name) {
842 return Ok(package);
843 }
844 let matches: Vec<&RegistryPackage> = index
845 .packages
846 .iter()
847 .filter(|package| {
848 package
849 .versions
850 .iter()
851 .any(|entry| entry.package.as_deref() == Some(name))
852 })
853 .collect();
854 match matches.as_slice() {
855 [package] => Ok(package),
856 [] => Err(format!("package registry does not contain {name}").into()),
857 many => Err(format!(
858 "package alias {name} is ambiguous in the registry — found {} packages; use the scoped name (e.g. {})",
859 many.len(),
860 many[0].name,
861 )
862 .into()),
863 }
864}
865
866pub(crate) fn find_registry_package_version(
867 index: &PackageRegistryIndex,
868 name: &str,
869 version: Option<&str>,
870) -> Result<RegistryPackageInfo, PackageError> {
871 let package = lookup_registry_package(index, name)?;
872 let selected_version = match version {
873 Some(version) => Some(
874 package
875 .versions
876 .iter()
877 .find(|entry| entry.version == version)
878 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?
879 .clone(),
880 ),
881 None => latest_registry_version(package).cloned(),
882 };
883 Ok(RegistryPackageInfo {
884 package: package.clone(),
885 selected_version,
886 })
887}
888
889pub(crate) fn find_registry_package_version_matching(
890 index: &PackageRegistryIndex,
891 name: &str,
892 requirement: &str,
893) -> Result<RegistryPackageInfo, PackageError> {
894 let package = lookup_registry_package(index, name)?;
895 let req = parse_registry_version_req(requirement)?;
896 let selected_version = package
897 .versions
898 .iter()
899 .filter(|entry| !entry.yanked)
900 .filter_map(|entry| {
901 parse_registry_semver(&entry.version)
902 .ok()
903 .filter(|version| req.matches(version))
904 .map(|version| (version, entry.clone()))
905 })
906 .max_by(|(left, _), (right, _)| left.cmp(right))
907 .map(|(_, entry)| entry)
908 .ok_or_else(|| {
909 format!("package registry does not contain {name} matching {requirement}")
910 })?;
911 Ok(RegistryPackageInfo {
912 package: package.clone(),
913 selected_version: Some(selected_version),
914 })
915}
916
917pub(crate) fn search_package_registry_impl(
918 query: Option<&str>,
919 registry: Option<&str>,
920) -> Result<Vec<RegistryPackage>, PackageError> {
921 search_package_registry_in(&PackageWorkspace::from_current_dir()?, query, registry)
922}
923
924pub(crate) fn search_package_registry_in(
925 workspace: &PackageWorkspace,
926 query: Option<&str>,
927 registry: Option<&str>,
928) -> Result<Vec<RegistryPackage>, PackageError> {
929 let (_, index) = load_package_registry_in(workspace, registry)?;
930 Ok(index
931 .packages
932 .into_iter()
933 .filter(|package| registry_package_matches(package, query.unwrap_or("")))
934 .collect())
935}
936
937pub(crate) fn package_registry_info_impl(
938 spec: &str,
939 registry: Option<&str>,
940) -> Result<RegistryPackageInfo, PackageError> {
941 package_registry_info_in(&PackageWorkspace::from_current_dir()?, spec, registry)
942}
943
944pub(crate) fn package_registry_info_in(
945 workspace: &PackageWorkspace,
946 spec: &str,
947 registry: Option<&str>,
948) -> Result<RegistryPackageInfo, PackageError> {
949 let Some((name, version)) = parse_registry_package_spec(spec) else {
950 return Err(format!(
951 "invalid registry package name '{spec}'; use names like @burin/notion-sdk or acme-lib"
952 )
953 .into());
954 };
955 let (_, index) = load_package_registry_in(workspace, registry)?;
956 find_registry_package_version(&index, name, version)
957}
958
959pub(crate) fn registry_dependency_from_spec_in(
960 workspace: &PackageWorkspace,
961 spec: &str,
962 alias: Option<&str>,
963 registry: Option<&str>,
964) -> Result<(String, Dependency), PackageError> {
965 let Some((name, Some(version))) = parse_registry_package_spec(spec) else {
966 return Err(format!(
967 "registry dependency '{spec}' must include a version, for example {spec}@1.2.3"
968 )
969 .into());
970 };
971 let registry_source = workspace.resolve_registry_source(registry)?;
972 let (_, index) = load_package_registry_in(workspace, registry)?;
973 let info = if is_exact_semver(version) {
977 find_registry_package_version(&index, name, Some(version))?
978 } else {
979 find_registry_package_version_matching(&index, name, version)?
980 };
981 let selected = info
982 .selected_version
983 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?;
984 if selected.yanked {
985 return Err(format!("{name}@{version} is yanked in the package registry").into());
986 }
987 let git = normalize_git_url(&selected.git)?;
988 let package_name = selected
989 .package
990 .clone()
991 .map(Ok)
992 .unwrap_or_else(|| derive_repo_name_from_source(&git))?;
993 let alias = alias.unwrap_or(package_name.as_str()).to_string();
994 let tag = selected.tag;
995 let rev = if tag.is_some() { None } else { selected.rev };
996 let resolved_version = selected.version.clone();
997 Ok((
998 alias.clone(),
999 Dependency::Table(Box::new(DepTable {
1000 git: Some(git),
1001 tag,
1002 rev,
1003 branch: selected.branch,
1004 package: (alias != package_name).then_some(package_name),
1005 registry: Some(registry_source),
1006 registry_name: Some(info.package.name),
1010 registry_version: Some(resolved_version),
1011 ..DepTable::default()
1012 })),
1013 ))
1014}
1015
1016fn is_exact_semver(spec: &str) -> bool {
1017 parse_registry_semver(spec).is_ok()
1018}
1019
1020pub(crate) fn registry_dependency_from_manifest_constraint_in(
1021 workspace: &PackageWorkspace,
1022 alias: &str,
1023 table: &DepTable,
1024) -> Result<Dependency, PackageError> {
1025 let requirement = table
1026 .version
1027 .as_deref()
1028 .ok_or_else(|| format!("dependency {alias} is missing `version`"))?;
1029 let registry_source = workspace.resolve_registry_source(table.registry.as_deref())?;
1030 let registry_name = table.registry_name.as_deref().unwrap_or(alias);
1031 let (_, index) = load_package_registry_in(workspace, Some(®istry_source))?;
1032 let info = find_registry_package_version_matching(&index, registry_name, requirement)?;
1033 let selected = info.selected_version.ok_or_else(|| {
1034 format!("package registry does not contain {registry_name} matching {requirement}")
1035 })?;
1036 let git = normalize_git_url(&selected.git)?;
1037 let tag = selected.tag;
1038 let rev = if tag.is_some() { None } else { selected.rev };
1039 Ok(Dependency::Table(Box::new(DepTable {
1040 git: Some(git),
1041 tag,
1042 rev,
1043 branch: selected.branch,
1044 package: selected.package.or_else(|| table.package.clone()),
1045 registry: Some(registry_source),
1046 registry_name: Some(registry_name.to_string()),
1047 registry_version: Some(selected.version),
1048 ..DepTable::default()
1049 })))
1050}
1051
1052pub(crate) fn is_probable_shorthand_git_url(raw: &str) -> bool {
1053 !raw.contains("://")
1054 && !raw.starts_with("git@")
1055 && raw.contains('/')
1056 && raw
1057 .split('/')
1058 .next()
1059 .is_some_and(|segment| segment.contains('.'))
1060}
1061
1062pub(crate) fn normalize_git_url(raw: &str) -> Result<String, PackageError> {
1063 let trimmed = raw.trim();
1064 if trimmed.is_empty() {
1065 return Err("git URL cannot be empty".to_string().into());
1066 }
1067
1068 let candidate_path = PathBuf::from(trimmed);
1069 if candidate_path.exists() {
1070 let canonical = candidate_path
1071 .canonicalize()
1072 .map_err(|error| format!("failed to canonicalize {trimmed}: {error}"))?;
1073 let url = Url::from_file_path(canonical)
1074 .map_err(|_| format!("failed to convert {trimmed} to file:// URL"))?;
1075 return Ok(url.to_string().trim_end_matches('/').to_string());
1076 }
1077
1078 if let Some(rest) = trimmed.strip_prefix("git@") {
1079 if let Some((host, path)) = rest.split_once(':') {
1080 return Ok(format!(
1081 "ssh://git@{}/{}",
1082 host,
1083 path.trim_start_matches('/').trim_end_matches('/')
1084 ));
1085 }
1086 }
1087
1088 let with_scheme = if is_probable_shorthand_git_url(trimmed) {
1089 format!("https://{trimmed}")
1090 } else {
1091 trimmed.to_string()
1092 };
1093 let parsed =
1094 Url::parse(&with_scheme).map_err(|error| format!("invalid git URL {trimmed}: {error}"))?;
1095 let mut normalized = parsed.to_string();
1096 while normalized.ends_with('/') {
1097 normalized.pop();
1098 }
1099 if parsed.scheme() != "file" && normalized.ends_with(".git") {
1100 normalized.truncate(normalized.len() - 4);
1101 }
1102 Ok(normalized)
1103}
1104
1105pub(crate) fn derive_repo_name_from_source(source: &str) -> Result<String, PackageError> {
1106 let url = Url::parse(source).map_err(|error| format!("invalid git URL {source}: {error}"))?;
1107 let segment = url
1108 .path_segments()
1109 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
1110 .ok_or_else(|| format!("failed to derive package name from {source}"))?;
1111 Ok(segment.trim_end_matches(".git").to_string())
1112}
1113
1114pub(crate) fn parse_positional_git_spec(spec: &str) -> (&str, Option<&str>) {
1115 if let Some((source, candidate_ref)) = spec.rsplit_once('@') {
1116 if !candidate_ref.is_empty()
1117 && !candidate_ref.contains('/')
1118 && !candidate_ref.contains(':')
1119 && !source.ends_with("://")
1120 {
1121 return (source, Some(candidate_ref));
1122 }
1123 }
1124 (spec, None)
1125}
1126
1127pub(crate) fn existing_local_path_spec(spec: &str) -> Option<PathBuf> {
1128 if spec.trim().is_empty() || spec.contains("://") || spec.starts_with("git@") {
1129 return None;
1130 }
1131 let candidate = PathBuf::from(spec);
1132 if candidate.exists() {
1133 return Some(candidate);
1134 }
1135 if candidate.extension().is_none() {
1136 let with_ext = candidate.with_extension("harn");
1137 if with_ext.exists() {
1138 return Some(with_ext);
1139 }
1140 }
1141 if is_probable_shorthand_git_url(spec) {
1142 return None;
1143 }
1144 None
1145}
1146
1147pub(crate) fn package_manifest_name(path: &Path) -> Option<String> {
1148 let manifest_path = if path.is_dir() {
1149 path.join(MANIFEST)
1150 } else {
1151 path.parent()?.join(MANIFEST)
1152 };
1153 let manifest = read_manifest_from_path(&manifest_path).ok()?;
1154 manifest
1155 .package
1156 .and_then(|pkg| pkg.name)
1157 .map(|name| name.trim().to_string())
1158 .filter(|name| !name.is_empty())
1159}
1160
1161pub(crate) fn derive_package_alias_from_path(path: &Path) -> Result<String, PackageError> {
1162 if let Some(name) = package_manifest_name(path) {
1163 return Ok(name);
1164 }
1165 let fallback = if path.is_dir() {
1166 path.file_name()
1167 } else {
1168 path.file_stem()
1169 };
1170 fallback
1171 .and_then(|name| name.to_str())
1172 .map(str::trim)
1173 .filter(|name| !name.is_empty())
1174 .map(str::to_string)
1175 .ok_or_else(|| {
1176 PackageError::Registry(format!(
1177 "failed to derive package alias from {}",
1178 path.display()
1179 ))
1180 })
1181}
1182
1183pub(crate) fn is_full_git_sha(value: &str) -> bool {
1184 value.len() == 40 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
1185}
1186
1187struct HardenedGitEnv {
1188 _temp_dir: tempfile::TempDir,
1189 home: PathBuf,
1190 config_home: PathBuf,
1191 global_config: PathBuf,
1192 system_config: PathBuf,
1193}
1194
1195impl HardenedGitEnv {
1196 fn new() -> Result<Self, PackageError> {
1197 let temp_dir = tempfile::Builder::new()
1198 .prefix("harn-git-env-")
1199 .tempdir()
1200 .map_err(|error| {
1201 PackageError::Registry(format!("failed to create isolated git env: {error}"))
1202 })?;
1203 let home = temp_dir.path().join("home");
1204 let config_home = temp_dir.path().join("xdg-config");
1205 fs::create_dir_all(&home)
1206 .map_err(|error| format!("failed to create {}: {error}", home.display()))?;
1207 fs::create_dir_all(&config_home)
1208 .map_err(|error| format!("failed to create {}: {error}", config_home.display()))?;
1209 let global_config = home.join(".gitconfig");
1210 let system_config = temp_dir.path().join("gitconfig-system");
1211 Ok(Self {
1212 _temp_dir: temp_dir,
1213 home,
1214 config_home,
1215 global_config,
1216 system_config,
1217 })
1218 }
1219
1220 fn apply_to(&self, command: &mut process::Command, cwd: Option<&Path>) {
1221 if let Some(dir) = cwd {
1222 command.current_dir(dir);
1223 }
1224 command.env_clear();
1227 for name in PRESERVED_GIT_ENV {
1228 if let Some(value) = std::env::var_os(name) {
1229 command.env(name, value);
1230 }
1231 }
1232 command
1233 .env("HOME", &self.home)
1234 .env("XDG_CONFIG_HOME", &self.config_home)
1235 .env("GIT_CONFIG_GLOBAL", &self.global_config)
1236 .env("GIT_CONFIG_SYSTEM", &self.system_config)
1237 .env("GIT_CONFIG_NOSYSTEM", "1")
1238 .env("GIT_TERMINAL_PROMPT", "0");
1239 }
1240}
1241
1242pub(crate) fn git_output<I, S>(
1243 args: I,
1244 cwd: Option<&Path>,
1245) -> Result<std::process::Output, PackageError>
1246where
1247 I: IntoIterator<Item = S>,
1248 S: AsRef<OsStr>,
1249{
1250 let git_env = HardenedGitEnv::new()?;
1251 let mut command = process::Command::new("git");
1252 git_env.apply_to(&mut command, cwd);
1253 command.args(args);
1254 command
1255 .output()
1256 .map_err(|error| PackageError::Registry(format!("failed to run git: {error}")))
1257}
1258
1259pub(crate) fn resolve_git_commit(
1260 url: &str,
1261 rev: Option<&str>,
1262 tag: Option<&str>,
1263 branch: Option<&str>,
1264) -> Result<String, PackageError> {
1265 let requested = branch.or(rev).or(tag).unwrap_or("HEAD");
1266 if branch.is_none() && tag.is_none() && is_full_git_sha(requested) {
1267 return Ok(requested.to_string());
1268 }
1269
1270 let refs = if let Some(branch) = branch {
1271 vec![format!("refs/heads/{branch}")]
1272 } else if let Some(tag) = tag {
1273 vec![format!("refs/tags/{tag}^{{}}"), format!("refs/tags/{tag}")]
1274 } else if requested == "HEAD" {
1275 vec!["HEAD".to_string()]
1276 } else {
1277 vec![
1278 requested.to_string(),
1279 format!("refs/tags/{requested}^{{}}"),
1280 format!("refs/tags/{requested}"),
1281 format!("refs/heads/{requested}"),
1282 ]
1283 };
1284
1285 let output = git_output(
1286 std::iter::once("ls-remote".to_string())
1287 .chain(std::iter::once(url.to_string()))
1288 .chain(refs),
1289 None,
1290 )?;
1291 if !output.status.success() {
1292 return Err(format!(
1293 "failed to resolve git ref from {url}: {}",
1294 String::from_utf8_lossy(&output.stderr).trim()
1295 )
1296 .into());
1297 }
1298 let stdout = String::from_utf8_lossy(&output.stdout);
1299 pick_ls_remote_commit(&stdout)
1300 .map(str::to_string)
1301 .ok_or_else(|| format!("could not resolve {requested} from {url}").into())
1302}
1303
1304fn pick_ls_remote_commit(stdout: &str) -> Option<&str> {
1312 let parsed: Vec<(&str, &str)> = stdout
1313 .lines()
1314 .filter_map(|line| {
1315 let mut parts = line.split_whitespace();
1316 let sha = parts.next()?;
1317 let refname = parts.next().unwrap_or("");
1318 is_full_git_sha(sha).then_some((sha, refname))
1319 })
1320 .collect();
1321 parsed
1322 .iter()
1323 .find_map(|(sha, refname)| refname.ends_with("^{}").then_some(*sha))
1324 .or_else(|| parsed.first().map(|(sha, _)| *sha))
1325}
1326
1327pub(crate) fn clone_git_commit_to(
1328 url: &str,
1329 commit: &str,
1330 dest: &Path,
1331) -> Result<(), PackageError> {
1332 if dest.exists() {
1333 fs::remove_dir_all(dest)
1334 .map_err(|error| format!("failed to reset {}: {error}", dest.display()))?;
1335 }
1336 fs::create_dir_all(dest)
1337 .map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
1338
1339 let init = git_output(["init", "--quiet"], Some(dest))?;
1340 if !init.status.success() {
1341 return Err(format!(
1342 "failed to initialize git repo in {}: {}",
1343 dest.display(),
1344 String::from_utf8_lossy(&init.stderr).trim()
1345 )
1346 .into());
1347 }
1348
1349 let remote = git_output(["remote", "add", "origin", url], Some(dest))?;
1350 if !remote.status.success() {
1351 return Err(format!(
1352 "failed to add git remote {url}: {}",
1353 String::from_utf8_lossy(&remote.stderr).trim()
1354 )
1355 .into());
1356 }
1357
1358 let fetch = git_output(["fetch", "--depth", "1", "origin", commit], Some(dest))?;
1359 if !fetch.status.success() {
1360 let fallback_dir = dest.with_extension("full-clone");
1361 if fallback_dir.exists() {
1362 fs::remove_dir_all(&fallback_dir)
1363 .map_err(|error| format!("failed to remove {}: {error}", fallback_dir.display()))?;
1364 }
1365 let clone = git_output(
1366 ["clone", url, fallback_dir.to_string_lossy().as_ref()],
1367 None,
1368 )?;
1369 if !clone.status.success() {
1370 return Err(format!(
1371 "failed to fetch {commit} from {url}: {}",
1372 String::from_utf8_lossy(&fetch.stderr).trim()
1373 )
1374 .into());
1375 }
1376 let checkout = git_output(["checkout", commit], Some(&fallback_dir))?;
1377 if !checkout.status.success() {
1378 return Err(format!(
1379 "failed to checkout {commit} in {}: {}",
1380 fallback_dir.display(),
1381 String::from_utf8_lossy(&checkout.stderr).trim()
1382 )
1383 .into());
1384 }
1385 fs::remove_dir_all(dest)
1386 .map_err(|error| format!("failed to remove {}: {error}", dest.display()))?;
1387 fs::rename(&fallback_dir, dest).map_err(|error| {
1388 format!(
1389 "failed to move {} to {}: {error}",
1390 fallback_dir.display(),
1391 dest.display()
1392 )
1393 })?;
1394 } else {
1395 let checkout = git_output(["checkout", "--detach", "FETCH_HEAD"], Some(dest))?;
1396 if !checkout.status.success() {
1397 return Err(format!(
1398 "failed to checkout FETCH_HEAD in {}: {}",
1399 dest.display(),
1400 String::from_utf8_lossy(&checkout.stderr).trim()
1401 )
1402 .into());
1403 }
1404 }
1405
1406 let git_dir = dest.join(".git");
1407 if git_dir.exists() {
1408 fs::remove_dir_all(&git_dir)
1409 .map_err(|error| format!("failed to remove {}: {error}", git_dir.display()))?;
1410 }
1411 Ok(())
1412}
1413
1414pub(crate) fn unique_temp_dir(base: &Path, label: &str) -> Result<PathBuf, PackageError> {
1415 for _ in 0..16 {
1416 let suffix = uuid::Uuid::now_v7();
1417 let candidate = base.join(format!("{label}-{suffix}"));
1418 if !candidate.exists() {
1419 return Ok(candidate);
1420 }
1421 }
1422 Err(format!(
1423 "failed to allocate a unique temporary directory under {}",
1424 base.display()
1425 )
1426 .into())
1427}
1428
1429pub(crate) fn ensure_git_cache_populated_in(
1430 workspace: &PackageWorkspace,
1431 url: &str,
1432 source: &str,
1433 commit: &str,
1434 expected_hash: Option<&str>,
1435 refetch: bool,
1436 offline: bool,
1437) -> Result<String, PackageError> {
1438 let cache_dir = git_cache_dir_in(workspace, source, commit)?;
1439 let _lock = acquire_git_cache_lock_in(workspace, source, commit)?;
1440 if refetch && cache_dir.exists() {
1441 fs::remove_dir_all(&cache_dir)
1442 .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1443 }
1444 if cache_dir.exists() {
1445 if let Some(expected) = expected_hash {
1446 verify_content_hash_or_compute(&cache_dir, expected)?;
1447 write_cache_metadata(&cache_dir, source, commit, expected)?;
1448 return Ok(expected.to_string());
1449 }
1450 let hash = compute_content_hash(&cache_dir)?;
1451 write_cached_content_hash(&cache_dir, &hash)?;
1452 write_cache_metadata(&cache_dir, source, commit, &hash)?;
1453 return Ok(hash);
1454 }
1455
1456 if offline {
1457 return Err(format!(
1458 "package cache entry for {source} at {commit} is missing; cannot fetch in offline mode"
1459 )
1460 .into());
1461 }
1462
1463 let parent = cache_dir
1464 .parent()
1465 .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1466 fs::create_dir_all(parent)
1467 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1468 let temp_dir = unique_temp_dir(parent, "tmp")?;
1469 let populated = (|| -> Result<String, PackageError> {
1470 clone_git_commit_to(url, commit, &temp_dir)?;
1471 let hash = compute_content_hash(&temp_dir)?;
1472 if let Some(expected) = expected_hash {
1473 if hash != expected {
1474 return Err(format!(
1475 "content hash mismatch for {source} at {commit}: expected {expected}, got {hash}"
1476 )
1477 .into());
1478 }
1479 }
1480 write_cached_content_hash(&temp_dir, &hash)?;
1481 write_cache_metadata(&temp_dir, source, commit, &hash)?;
1482 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1483 format!(
1484 "failed to move {} to {}: {error}",
1485 temp_dir.display(),
1486 cache_dir.display()
1487 )
1488 })?;
1489 Ok(hash)
1490 })();
1491 let hash = match populated {
1492 Ok(hash) => hash,
1493 Err(error) => {
1494 let _ = fs::remove_dir_all(&temp_dir);
1495 return Err(error);
1496 }
1497 };
1498 Ok(hash)
1499}
1500
1501#[derive(Debug, Clone)]
1502pub(crate) struct PackageCacheEntry {
1503 path: PathBuf,
1504 source_hash: String,
1505 commit: String,
1506 metadata: Option<PackageCacheMetadata>,
1507}
1508
1509pub(crate) fn git_cache_root_in(workspace: &PackageWorkspace) -> Result<PathBuf, PackageError> {
1510 Ok(workspace.cache_root()?.join("git"))
1511}
1512
1513pub(crate) fn discover_git_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1514 discover_git_cache_entries_in(&PackageWorkspace::from_current_dir()?)
1515}
1516
1517pub(crate) fn discover_git_cache_entries_in(
1518 workspace: &PackageWorkspace,
1519) -> Result<Vec<PackageCacheEntry>, PackageError> {
1520 let root = git_cache_root_in(workspace)?;
1521 let mut entries = Vec::new();
1522 let source_dirs = match fs::read_dir(&root) {
1523 Ok(source_dirs) => source_dirs,
1524 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1525 Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1526 };
1527 for source_dir in source_dirs {
1528 let source_dir = source_dir
1529 .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1530 let source_type = source_dir
1531 .file_type()
1532 .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1533 if !source_type.is_dir() {
1534 continue;
1535 }
1536 let source_hash = source_dir.file_name().to_string_lossy().to_string();
1537 let commit_dirs = fs::read_dir(source_dir.path())
1538 .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1539 for commit_dir in commit_dirs {
1540 let commit_dir = commit_dir.map_err(|error| {
1541 format!(
1542 "failed to read {} entry: {error}",
1543 source_dir.path().display()
1544 )
1545 })?;
1546 let commit_type = commit_dir.file_type().map_err(|error| {
1547 format!("failed to stat {}: {error}", commit_dir.path().display())
1548 })?;
1549 if !commit_type.is_dir() {
1550 continue;
1551 }
1552 let commit = commit_dir.file_name().to_string_lossy().to_string();
1553 if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1554 continue;
1555 }
1556 let metadata = read_cache_metadata(&commit_dir.path())?;
1557 entries.push(PackageCacheEntry {
1558 path: commit_dir.path(),
1559 source_hash: source_hash.clone(),
1560 commit,
1561 metadata,
1562 });
1563 }
1564 }
1565 entries.sort_by(|left, right| {
1566 left.source_hash
1567 .cmp(&right.source_hash)
1568 .then_with(|| left.commit.cmp(&right.commit))
1569 });
1570 Ok(entries)
1571}
1572
1573pub(crate) fn locked_git_cache_paths_in(
1574 workspace: &PackageWorkspace,
1575 lock: &LockFile,
1576) -> Result<HashSet<PathBuf>, PackageError> {
1577 let mut keep = HashSet::new();
1578 for entry in &lock.packages {
1579 validate_package_alias(&entry.name)?;
1580 if !entry.source.starts_with("git+") {
1581 continue;
1582 }
1583 let commit = entry
1584 .commit
1585 .as_deref()
1586 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1587 keep.insert(git_cache_dir_in(workspace, &entry.source, commit)?);
1588 }
1589 Ok(keep)
1590}
1591
1592pub(crate) fn verify_lock_entry_cache_in(
1593 workspace: &PackageWorkspace,
1594 entry: &LockEntry,
1595) -> Result<bool, PackageError> {
1596 validate_package_alias(&entry.name)?;
1597 if !entry.source.starts_with("git+") {
1598 if entry.source.starts_with("path+") {
1599 let path = path_from_source_uri(&entry.source)?;
1600 if !path.exists() {
1601 return Err(format!(
1602 "path dependency {} source is missing: {}",
1603 entry.name,
1604 path.display()
1605 )
1606 .into());
1607 }
1608 }
1609 return Ok(false);
1610 }
1611 let commit = entry
1612 .commit
1613 .as_deref()
1614 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1615 let expected_hash = entry
1616 .content_hash
1617 .as_deref()
1618 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1619 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
1620 if !cache_dir.is_dir() {
1621 return Err(format!(
1622 "package cache entry for {} is missing: {}",
1623 entry.name,
1624 cache_dir.display()
1625 )
1626 .into());
1627 }
1628 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1629 match read_cache_metadata(&cache_dir)? {
1630 Some(metadata)
1631 if metadata.source == entry.source
1632 && metadata.commit == commit
1633 && metadata.content_hash == expected_hash => {}
1634 Some(metadata) => {
1635 return Err(format!(
1636 "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
1637 entry.name,
1638 entry.source,
1639 commit,
1640 expected_hash,
1641 metadata.source,
1642 metadata.commit,
1643 metadata.content_hash
1644 )
1645 .into());
1646 }
1647 None => write_cache_metadata(&cache_dir, &entry.source, commit, expected_hash)?,
1648 }
1649 Ok(true)
1650}
1651
1652pub(crate) fn verify_materialized_lock_entry(
1653 ctx: &ManifestContext,
1654 entry: &LockEntry,
1655) -> Result<bool, PackageError> {
1656 validate_package_alias(&entry.name)?;
1657 let packages_dir = ctx.packages_dir();
1658 if entry.source.starts_with("path+") {
1659 let dir = packages_dir.join(&entry.name);
1660 let file = packages_dir.join(format!("{}.harn", entry.name));
1661 if !dir.exists() && !file.exists() {
1662 return Err(format!(
1663 "materialized path dependency {} is missing under {}",
1664 entry.name,
1665 packages_dir.display()
1666 )
1667 .into());
1668 }
1669 return Ok(true);
1670 }
1671 if !entry.source.starts_with("git+") {
1672 return Ok(false);
1673 }
1674 let expected_hash = entry
1675 .content_hash
1676 .as_deref()
1677 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1678 let dest_dir = packages_dir.join(&entry.name);
1679 if !dest_dir.is_dir() {
1680 return Err(format!(
1681 "materialized package {} is missing: {}",
1682 entry.name,
1683 dest_dir.display()
1684 )
1685 .into());
1686 }
1687 verify_content_hash_or_compute(&dest_dir, expected_hash)?;
1688 Ok(true)
1689}
1690
1691pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
1692 verify_package_cache_in(&PackageWorkspace::from_current_dir()?, materialized)
1693}
1694
1695pub(crate) fn verify_package_cache_in(
1696 workspace: &PackageWorkspace,
1697 materialized: bool,
1698) -> Result<usize, PackageError> {
1699 let ctx = workspace.load_manifest_context()?;
1700 let lock = LockFile::load(&ctx.lock_path())?
1701 .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
1702 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1703 let mut verified = 0usize;
1704 for entry in &lock.packages {
1705 if verify_lock_entry_cache_in(workspace, entry)? {
1706 verified += 1;
1707 }
1708 if materialized && verify_materialized_lock_entry(&ctx, entry)? {
1709 verified += 1;
1710 }
1711 }
1712 Ok(verified)
1713}
1714
1715pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
1716 clean_package_cache_in(&PackageWorkspace::from_current_dir()?, all)
1717}
1718
1719pub(crate) fn clean_package_cache_in(
1720 workspace: &PackageWorkspace,
1721 all: bool,
1722) -> Result<usize, PackageError> {
1723 let entries = discover_git_cache_entries_in(workspace)?;
1724 if entries.is_empty() {
1725 return Ok(0);
1726 }
1727 if all {
1728 let root = workspace.cache_root()?;
1729 for child in ["git", "locks"] {
1730 let path = root.join(child);
1731 if path.exists() {
1732 fs::remove_dir_all(&path)
1733 .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
1734 }
1735 }
1736 return Ok(entries.len());
1737 }
1738
1739 let ctx = workspace.load_manifest_context()?;
1740 let lock = LockFile::load(&ctx.lock_path())?
1741 .ok_or_else(|| format!("{LOCK_FILE} is missing; pass --all to clean every cache entry"))?;
1742 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1743 let keep = locked_git_cache_paths_in(workspace, &lock)?;
1744 let mut removed = 0usize;
1745 for entry in entries {
1746 if keep.contains(&entry.path) {
1747 continue;
1748 }
1749 fs::remove_dir_all(&entry.path)
1750 .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
1751 removed += 1;
1752 if let Some(parent) = entry.path.parent() {
1753 let is_empty = fs::read_dir(parent)
1754 .map(|mut children| children.next().is_none())
1755 .unwrap_or(false);
1756 if is_empty {
1757 fs::remove_dir(parent)
1758 .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
1759 }
1760 }
1761 }
1762 Ok(removed)
1763}
1764
1765pub fn list_package_cache() {
1766 let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
1767 Ok((cache_root()?, discover_git_cache_entries()?))
1768 })();
1769
1770 match result {
1771 Ok((root, entries)) => {
1772 println!("Cache root: {}", root.display());
1773 if entries.is_empty() {
1774 println!("No cached git packages.");
1775 return;
1776 }
1777 println!("commit\tcontent_hash\tsource\tpath");
1778 for entry in entries {
1779 let (source, content_hash) = entry
1780 .metadata
1781 .as_ref()
1782 .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
1783 .unwrap_or(("(unknown)", "(unknown)"));
1784 println!(
1785 "{}\t{}\t{}\t{}",
1786 entry.commit,
1787 content_hash,
1788 source,
1789 entry.path.display()
1790 );
1791 }
1792 }
1793 Err(error) => {
1794 eprintln!("error: {error}");
1795 process::exit(1);
1796 }
1797 }
1798}
1799
1800pub fn clean_package_cache(all: bool) {
1801 match clean_package_cache_impl(all) {
1802 Ok(removed) => println!("Removed {removed} cached package entries."),
1803 Err(error) => {
1804 eprintln!("error: {error}");
1805 process::exit(1);
1806 }
1807 }
1808}
1809
1810pub fn verify_package_cache(materialized: bool) {
1811 match verify_package_cache_impl(materialized) {
1812 Ok(verified) => println!("Verified {verified} package cache entries."),
1813 Err(error) => {
1814 eprintln!("error: {error}");
1815 process::exit(1);
1816 }
1817 }
1818}
1819
1820pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
1821 match search_package_registry_impl(query, registry) {
1822 Ok(packages) if json => {
1823 println!(
1824 "{}",
1825 serde_json::to_string_pretty(&packages)
1826 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1827 );
1828 }
1829 Ok(packages) => {
1830 if packages.is_empty() {
1831 println!("No packages found.");
1832 return;
1833 }
1834 println!("name\tlatest\tharn\tcontract\tdescription");
1835 for package in packages {
1836 let latest = latest_registry_version(&package)
1837 .map(|version| version.version.as_str())
1838 .unwrap_or("-");
1839 println!(
1840 "{}\t{}\t{}\t{}\t{}",
1841 package.name,
1842 latest,
1843 package.harn.as_deref().unwrap_or("-"),
1844 package.connector_contract.as_deref().unwrap_or("-"),
1845 package.description.as_deref().unwrap_or("")
1846 );
1847 }
1848 }
1849 Err(error) => {
1850 eprintln!("error: {error}");
1851 process::exit(1);
1852 }
1853 }
1854}
1855
1856pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
1857 match package_registry_info_impl(spec, registry) {
1858 Ok(info) if json => {
1859 println!(
1860 "{}",
1861 serde_json::to_string_pretty(&info)
1862 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1863 );
1864 }
1865 Ok(info) => {
1866 let package = info.package;
1867 println!("{}", package.name);
1868 if let Some(description) = package.description.as_deref() {
1869 println!("description: {description}");
1870 }
1871 println!("repository: {}", package.repository);
1872 if let Some(license) = package.license.as_deref() {
1873 println!("license: {license}");
1874 }
1875 if let Some(harn) = package.harn.as_deref() {
1876 println!("harn: {harn}");
1877 }
1878 if let Some(contract) = package.connector_contract.as_deref() {
1879 println!("connector_contract: {contract}");
1880 }
1881 if let Some(docs) = package.docs_url.as_deref() {
1882 println!("docs: {docs}");
1883 }
1884 if let Some(checksum) = package.checksum.as_deref() {
1885 println!("checksum: {checksum}");
1886 }
1887 if let Some(provenance) = package.provenance.as_deref() {
1888 println!("provenance: {provenance}");
1889 }
1890 if !package.exports.is_empty() {
1891 println!("exports: {}", package.exports.join(", "));
1892 }
1893 if let Some(version) = info.selected_version {
1894 println!("selected: {}", version.version);
1895 println!("git: {}", version.git);
1896 if let Some(rev) = version.rev.as_deref() {
1897 println!("rev: {rev}");
1898 }
1899 if let Some(branch) = version.branch.as_deref() {
1900 println!("branch: {branch}");
1901 }
1902 if let Some(package_name) = version.package.as_deref() {
1903 println!("package: {package_name}");
1904 }
1905 }
1906 if !package.versions.is_empty() {
1907 let versions = package
1908 .versions
1909 .iter()
1910 .map(|version| {
1911 if version.yanked {
1912 format!("{} (yanked)", version.version)
1913 } else {
1914 version.version.clone()
1915 }
1916 })
1917 .collect::<Vec<_>>()
1918 .join(", ");
1919 println!("versions: {versions}");
1920 }
1921 }
1922 Err(error) => {
1923 eprintln!("error: {error}");
1924 process::exit(1);
1925 }
1926 }
1927}
1928
1929#[cfg(test)]
1930mod tests {
1931 use super::*;
1932 use crate::package::test_support::*;
1933
1934 #[test]
1935 fn pick_ls_remote_commit_prefers_peeled_tag_over_tag_object() {
1936 let output = "\
1940963b6e8acfdf030a9b922bc5a73e010758ff47da\trefs/tags/v0.1.0\n\
1941bad580c5fbe8ede612b2748ad98606642ce2fc02\trefs/tags/v0.1.0^{}\n";
1942 assert_eq!(
1943 pick_ls_remote_commit(output),
1944 Some("bad580c5fbe8ede612b2748ad98606642ce2fc02"),
1945 );
1946 }
1947
1948 #[test]
1949 fn pick_ls_remote_commit_falls_back_to_first_match_for_lightweight_tags() {
1950 let output = "\
1951abc123abc123abc123abc123abc123abc1234567\trefs/tags/v0.0.1\n";
1952 assert_eq!(
1953 pick_ls_remote_commit(output),
1954 Some("abc123abc123abc123abc123abc123abc1234567"),
1955 );
1956 }
1957
1958 #[test]
1959 fn pick_ls_remote_commit_returns_none_on_empty_output() {
1960 assert_eq!(pick_ls_remote_commit(""), None);
1961 }
1962
1963 #[cfg(unix)]
1964 #[test]
1965 fn hardened_git_env_scrubs_ambient_git_credentials_and_config() {
1966 let git_env = HardenedGitEnv::new().unwrap();
1967 let mut command = process::Command::new("/usr/bin/env");
1968 command
1969 .env("HOME", "/sensitive/home")
1970 .env("XDG_CONFIG_HOME", "/sensitive/config")
1971 .env("GIT_ASKPASS", "/sensitive/askpass")
1972 .env("GIT_SSH_COMMAND", "ssh -i /sensitive/key")
1973 .env("SSH_AUTH_SOCK", "/sensitive/agent.sock")
1974 .env("GIT_CONFIG_COUNT", "1")
1975 .env(
1976 "GIT_CONFIG_KEY_0",
1977 "http.https://attacker.example/.extraheader",
1978 )
1979 .env("GIT_CONFIG_VALUE_0", "Authorization: bearer secret");
1980 git_env.apply_to(&mut command, None);
1981
1982 let output = command.output().unwrap();
1983 assert!(
1984 output.status.success(),
1985 "env probe failed: {}",
1986 String::from_utf8_lossy(&output.stderr)
1987 );
1988 let stdout = String::from_utf8(output.stdout).unwrap();
1989 let vars: std::collections::BTreeMap<_, _> = stdout
1990 .lines()
1991 .filter_map(|line| line.split_once('='))
1992 .map(|(name, value)| (name.to_string(), value.to_string()))
1993 .collect();
1994
1995 assert_eq!(Path::new(&vars["HOME"]), git_env.home);
1996 assert_eq!(Path::new(&vars["XDG_CONFIG_HOME"]), git_env.config_home);
1997 assert_eq!(Path::new(&vars["GIT_CONFIG_GLOBAL"]), git_env.global_config);
1998 assert_eq!(Path::new(&vars["GIT_CONFIG_SYSTEM"]), git_env.system_config);
1999 assert_eq!(vars["GIT_CONFIG_NOSYSTEM"], "1");
2000 assert_eq!(vars["GIT_TERMINAL_PROMPT"], "0");
2001 assert!(!vars.contains_key("GIT_ASKPASS"));
2002 assert!(!vars.contains_key("GIT_SSH_COMMAND"));
2003 assert!(!vars.contains_key("SSH_AUTH_SOCK"));
2004 assert!(!vars.contains_key("GIT_CONFIG_COUNT"));
2005 assert!(!vars.contains_key("GIT_CONFIG_KEY_0"));
2006 assert!(!vars.contains_key("GIT_CONFIG_VALUE_0"));
2007 }
2008
2009 #[test]
2010 fn compute_content_hash_ignores_git_and_hash_marker() {
2011 let tmp = tempfile::tempdir().unwrap();
2012 let root = tmp.path();
2013 fs::create_dir_all(root.join(".git")).unwrap();
2014 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2015 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
2016 fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
2017 fs::write(
2018 root.join("lib.harn"),
2019 "pub fn value() -> number { return 1 }\n",
2020 )
2021 .unwrap();
2022 let first = compute_content_hash(root).unwrap();
2023 fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
2024 fs::write(root.join(".gitignore"), "changed\n").unwrap();
2025 fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
2026 let second = compute_content_hash(root).unwrap();
2027 assert_eq!(first, second);
2028 }
2029
2030 #[cfg(unix)]
2031 #[test]
2032 fn remove_materialized_package_unlinks_directory_symlink_without_touching_source() {
2033 let tmp = tempfile::tempdir().unwrap();
2034 let source = tmp.path().join("source");
2035 let packages = tmp.path().join(".harn/packages");
2036 fs::create_dir_all(&source).unwrap();
2037 fs::create_dir_all(&packages).unwrap();
2038 fs::write(
2039 source.join("lib.harn"),
2040 "pub fn value() -> number { return 1 }\n",
2041 )
2042 .unwrap();
2043
2044 let materialized = packages.join("acme");
2045 std::os::unix::fs::symlink(&source, &materialized).unwrap();
2046
2047 remove_materialized_package(&packages, "acme").unwrap();
2048
2049 assert!(!materialized.exists());
2050 assert!(source.join("lib.harn").is_file());
2051 }
2052
2053 #[test]
2054 fn package_cache_verify_detects_tampering_even_with_stale_marker() {
2055 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2056 let project_tmp = tempfile::tempdir().unwrap();
2057 let root = project_tmp.path();
2058 let workspace = TestWorkspace::new(root);
2059 fs::create_dir_all(root.join(".git")).unwrap();
2060 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2061 fs::write(
2062 root.join(MANIFEST),
2063 format!(
2064 r#"
2065 [package]
2066 name = "workspace"
2067 version = "0.1.0"
2068
2069 [dependencies]
2070 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2071 "#
2072 ),
2073 )
2074 .unwrap();
2075
2076 install_packages_in(workspace.env(), false, None, false).unwrap();
2077 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2078 let entry = lock.find("acme-lib").unwrap();
2079 let cache_dir = git_cache_dir_in(
2080 workspace.env(),
2081 &entry.source,
2082 entry.commit.as_deref().unwrap(),
2083 )
2084 .unwrap();
2085 fs::write(
2086 cache_dir.join("lib.harn"),
2087 "pub fn value() { return \"pwned\" }\n",
2088 )
2089 .unwrap();
2090
2091 let error = verify_package_cache_in(workspace.env(), false).unwrap_err();
2092 assert!(error.to_string().contains("content hash mismatch"));
2093 }
2094
2095 #[test]
2096 fn package_cache_clean_all_removes_cached_git_entries() {
2097 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2098 let project_tmp = tempfile::tempdir().unwrap();
2099 let root = project_tmp.path();
2100 let workspace = TestWorkspace::new(root);
2101 fs::create_dir_all(root.join(".git")).unwrap();
2102 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2103 fs::write(
2104 root.join(MANIFEST),
2105 format!(
2106 r#"
2107 [package]
2108 name = "workspace"
2109 version = "0.1.0"
2110
2111 [dependencies]
2112 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2113 "#
2114 ),
2115 )
2116 .unwrap();
2117
2118 install_packages_in(workspace.env(), false, None, false).unwrap();
2119 assert_eq!(
2120 discover_git_cache_entries_in(workspace.env())
2121 .unwrap()
2122 .len(),
2123 1
2124 );
2125
2126 let removed = clean_package_cache_in(workspace.env(), true).unwrap();
2127 assert_eq!(removed, 1);
2128 assert!(discover_git_cache_entries_in(workspace.env())
2129 .unwrap()
2130 .is_empty());
2131 }
2132
2133 #[test]
2134 fn registry_index_search_and_info_use_local_file_without_network() {
2135 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2136 let project_tmp = tempfile::tempdir().unwrap();
2137 let root = project_tmp.path();
2138 let workspace = TestWorkspace::new(root);
2139 let registry_path = root.join("index.toml");
2140 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2141 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2142 fs::create_dir_all(root.join(".git")).unwrap();
2143 fs::write(
2144 root.join(MANIFEST),
2145 r#"
2146 [package]
2147 name = "workspace"
2148 version = "0.1.0"
2149 "#,
2150 )
2151 .unwrap();
2152
2153 let matches = search_package_registry_in(
2154 workspace.env(),
2155 Some("acme"),
2156 Some(registry_path.to_string_lossy().as_ref()),
2157 )
2158 .unwrap();
2159 assert_eq!(matches.len(), 1);
2160 assert_eq!(matches[0].name, "@burin/acme-lib");
2161 assert_eq!(
2162 matches[0].harn.as_deref(),
2163 Some(crate::package::current_harn_range_example().as_str())
2164 );
2165 assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
2166 assert_eq!(matches[0].exports, vec!["lib"]);
2167
2168 let info = package_registry_info_in(
2169 workspace.env(),
2170 "@burin/acme-lib@1.0.0",
2171 Some(registry_path.to_string_lossy().as_ref()),
2172 )
2173 .unwrap();
2174 assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
2175 assert_eq!(
2176 info.selected_version
2177 .as_ref()
2178 .map(|version| version.git.as_str()),
2179 Some(git.as_str())
2180 );
2181 }
2182
2183 #[test]
2184 fn add_registry_dependency_preserves_provenance_in_manifest_and_lock() {
2185 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2186 let project_tmp = tempfile::tempdir().unwrap();
2187 let root = project_tmp.path();
2188 let registry_path = root.join("index.toml");
2189 let workspace =
2190 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2191 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2192 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2193 fs::create_dir_all(root.join(".git")).unwrap();
2194 fs::write(
2195 root.join(MANIFEST),
2196 r#"
2197 [package]
2198 name = "workspace"
2199 version = "0.1.0"
2200 "#,
2201 )
2202 .unwrap();
2203
2204 add_package_to(
2205 workspace.env(),
2206 "@burin/acme-lib@1.0.0",
2207 None,
2208 None,
2209 None,
2210 None,
2211 None,
2212 None,
2213 None,
2214 )
2215 .unwrap();
2216
2217 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2218 assert!(
2219 manifest.contains(&format!("git = \"{git}\"")),
2220 "registry install must record the resolved git URL: {manifest}"
2221 );
2222 assert!(
2223 manifest.contains("tag = \"v1.0.0\""),
2224 "registry install must pin the resolved tag: {manifest}"
2225 );
2226 assert!(
2227 manifest.contains("registry_name = \"@burin/acme-lib\""),
2228 "registry install must preserve the registry-side package name: {manifest}"
2229 );
2230 assert!(
2231 manifest.contains("registry_version = \"1.0.0\""),
2232 "registry install must preserve the requested registry version: {manifest}"
2233 );
2234 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2235 let entry = lock.find("acme-lib").unwrap();
2236 assert_eq!(entry.source, format!("git+{git}"));
2237 let registry = entry
2238 .registry
2239 .as_ref()
2240 .expect("registry-added entry should carry registry provenance");
2241 assert_eq!(registry.name, "@burin/acme-lib");
2242 assert_eq!(registry.version, "1.0.0");
2243 assert!(root
2244 .join(PKG_DIR)
2245 .join("acme-lib")
2246 .join("lib.harn")
2247 .is_file());
2248 }
2249
2250 #[test]
2251 fn add_registry_dependency_accepts_bare_alias_and_semver_range() {
2252 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2256 let project_tmp = tempfile::tempdir().unwrap();
2257 let root = project_tmp.path();
2258 let registry_path = root.join("index.toml");
2259 let workspace =
2260 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2261 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2262 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2263 fs::create_dir_all(root.join(".git")).unwrap();
2264 fs::write(
2265 root.join(MANIFEST),
2266 r#"
2267 [package]
2268 name = "workspace"
2269 version = "0.1.0"
2270 "#,
2271 )
2272 .unwrap();
2273
2274 add_package_to(
2276 workspace.env(),
2277 "acme-lib@^1",
2278 None,
2279 None,
2280 None,
2281 None,
2282 None,
2283 None,
2284 None,
2285 )
2286 .unwrap();
2287
2288 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2289 assert!(
2290 manifest.contains("registry_name = \"@burin/acme-lib\""),
2291 "bare-alias add must record the canonical scoped registry name: {manifest}"
2292 );
2293 assert!(
2294 manifest.contains("registry_version = \"1.0.0\""),
2295 "semver range must resolve to the highest matching exact version: {manifest}"
2296 );
2297 }
2298
2299 #[test]
2300 fn registry_index_rejects_invalid_names_and_duplicate_versions() {
2301 let content = r#"
2302 version = 1
2303
2304 [[package]]
2305 name = "@bad/"
2306 repository = "https://github.com/acme/acme-lib"
2307
2308 [[package.version]]
2309 version = "1.0.0"
2310 git = "https://github.com/acme/acme-lib"
2311 rev = "v1.0.0"
2312 "#;
2313 let error = parse_package_registry_index("fixture", content).unwrap_err();
2314 assert!(error.to_string().contains("invalid package name"));
2315
2316 let content = r#"
2317 version = 1
2318
2319 [[package]]
2320 name = "@burin/acme-lib"
2321 repository = "https://github.com/acme/acme-lib"
2322
2323 [[package.version]]
2324 version = "1.0.0"
2325 git = "https://github.com/acme/acme-lib"
2326 rev = "v1.0.0"
2327
2328 [[package.version]]
2329 version = "1.0.0"
2330 git = "https://github.com/acme/acme-lib"
2331 rev = "v1.0.0"
2332 "#;
2333 let error = parse_package_registry_index("fixture", content).unwrap_err();
2334 assert!(error.to_string().contains("more than once"));
2335 }
2336}