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.clone()),
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 {}: {error}", trimmed))?;
1073 let url = Url::from_file_path(canonical)
1074 .map_err(|_| format!("failed to convert {} to file:// URL", trimmed))?;
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.clone()),
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 {} at {}: expected {}, got {}",
1476 source, commit, expected, hash
1477 )
1478 .into());
1479 }
1480 }
1481 write_cached_content_hash(&temp_dir, &hash)?;
1482 write_cache_metadata(&temp_dir, source, commit, &hash)?;
1483 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1484 format!(
1485 "failed to move {} to {}: {error}",
1486 temp_dir.display(),
1487 cache_dir.display()
1488 )
1489 })?;
1490 Ok(hash)
1491 })();
1492 let hash = match populated {
1493 Ok(hash) => hash,
1494 Err(error) => {
1495 let _ = fs::remove_dir_all(&temp_dir);
1496 return Err(error);
1497 }
1498 };
1499 Ok(hash)
1500}
1501
1502#[derive(Debug, Clone)]
1503pub(crate) struct PackageCacheEntry {
1504 path: PathBuf,
1505 source_hash: String,
1506 commit: String,
1507 metadata: Option<PackageCacheMetadata>,
1508}
1509
1510pub(crate) fn git_cache_root_in(workspace: &PackageWorkspace) -> Result<PathBuf, PackageError> {
1511 Ok(workspace.cache_root()?.join("git"))
1512}
1513
1514pub(crate) fn discover_git_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1515 discover_git_cache_entries_in(&PackageWorkspace::from_current_dir()?)
1516}
1517
1518pub(crate) fn discover_git_cache_entries_in(
1519 workspace: &PackageWorkspace,
1520) -> Result<Vec<PackageCacheEntry>, PackageError> {
1521 let root = git_cache_root_in(workspace)?;
1522 let mut entries = Vec::new();
1523 let source_dirs = match fs::read_dir(&root) {
1524 Ok(source_dirs) => source_dirs,
1525 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1526 Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1527 };
1528 for source_dir in source_dirs {
1529 let source_dir = source_dir
1530 .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1531 let source_type = source_dir
1532 .file_type()
1533 .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1534 if !source_type.is_dir() {
1535 continue;
1536 }
1537 let source_hash = source_dir.file_name().to_string_lossy().to_string();
1538 let commit_dirs = fs::read_dir(source_dir.path())
1539 .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1540 for commit_dir in commit_dirs {
1541 let commit_dir = commit_dir.map_err(|error| {
1542 format!(
1543 "failed to read {} entry: {error}",
1544 source_dir.path().display()
1545 )
1546 })?;
1547 let commit_type = commit_dir.file_type().map_err(|error| {
1548 format!("failed to stat {}: {error}", commit_dir.path().display())
1549 })?;
1550 if !commit_type.is_dir() {
1551 continue;
1552 }
1553 let commit = commit_dir.file_name().to_string_lossy().to_string();
1554 if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1555 continue;
1556 }
1557 let metadata = read_cache_metadata(&commit_dir.path())?;
1558 entries.push(PackageCacheEntry {
1559 path: commit_dir.path(),
1560 source_hash: source_hash.clone(),
1561 commit,
1562 metadata,
1563 });
1564 }
1565 }
1566 entries.sort_by(|left, right| {
1567 left.source_hash
1568 .cmp(&right.source_hash)
1569 .then_with(|| left.commit.cmp(&right.commit))
1570 });
1571 Ok(entries)
1572}
1573
1574pub(crate) fn locked_git_cache_paths_in(
1575 workspace: &PackageWorkspace,
1576 lock: &LockFile,
1577) -> Result<HashSet<PathBuf>, PackageError> {
1578 let mut keep = HashSet::new();
1579 for entry in &lock.packages {
1580 validate_package_alias(&entry.name)?;
1581 if !entry.source.starts_with("git+") {
1582 continue;
1583 }
1584 let commit = entry
1585 .commit
1586 .as_deref()
1587 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1588 keep.insert(git_cache_dir_in(workspace, &entry.source, commit)?);
1589 }
1590 Ok(keep)
1591}
1592
1593pub(crate) fn verify_lock_entry_cache_in(
1594 workspace: &PackageWorkspace,
1595 entry: &LockEntry,
1596) -> Result<bool, PackageError> {
1597 validate_package_alias(&entry.name)?;
1598 if !entry.source.starts_with("git+") {
1599 if entry.source.starts_with("path+") {
1600 let path = path_from_source_uri(&entry.source)?;
1601 if !path.exists() {
1602 return Err(format!(
1603 "path dependency {} source is missing: {}",
1604 entry.name,
1605 path.display()
1606 )
1607 .into());
1608 }
1609 }
1610 return Ok(false);
1611 }
1612 let commit = entry
1613 .commit
1614 .as_deref()
1615 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1616 let expected_hash = entry
1617 .content_hash
1618 .as_deref()
1619 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1620 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
1621 if !cache_dir.is_dir() {
1622 return Err(format!(
1623 "package cache entry for {} is missing: {}",
1624 entry.name,
1625 cache_dir.display()
1626 )
1627 .into());
1628 }
1629 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1630 match read_cache_metadata(&cache_dir)? {
1631 Some(metadata)
1632 if metadata.source == entry.source
1633 && metadata.commit == commit
1634 && metadata.content_hash == expected_hash => {}
1635 Some(metadata) => {
1636 return Err(format!(
1637 "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
1638 entry.name,
1639 entry.source,
1640 commit,
1641 expected_hash,
1642 metadata.source,
1643 metadata.commit,
1644 metadata.content_hash
1645 )
1646 .into());
1647 }
1648 None => write_cache_metadata(&cache_dir, &entry.source, commit, expected_hash)?,
1649 }
1650 Ok(true)
1651}
1652
1653pub(crate) fn verify_materialized_lock_entry(
1654 ctx: &ManifestContext,
1655 entry: &LockEntry,
1656) -> Result<bool, PackageError> {
1657 validate_package_alias(&entry.name)?;
1658 let packages_dir = ctx.packages_dir();
1659 if entry.source.starts_with("path+") {
1660 let dir = packages_dir.join(&entry.name);
1661 let file = packages_dir.join(format!("{}.harn", entry.name));
1662 if !dir.exists() && !file.exists() {
1663 return Err(format!(
1664 "materialized path dependency {} is missing under {}",
1665 entry.name,
1666 packages_dir.display()
1667 )
1668 .into());
1669 }
1670 return Ok(true);
1671 }
1672 if !entry.source.starts_with("git+") {
1673 return Ok(false);
1674 }
1675 let expected_hash = entry
1676 .content_hash
1677 .as_deref()
1678 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1679 let dest_dir = packages_dir.join(&entry.name);
1680 if !dest_dir.is_dir() {
1681 return Err(format!(
1682 "materialized package {} is missing: {}",
1683 entry.name,
1684 dest_dir.display()
1685 )
1686 .into());
1687 }
1688 verify_content_hash_or_compute(&dest_dir, expected_hash)?;
1689 Ok(true)
1690}
1691
1692pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
1693 verify_package_cache_in(&PackageWorkspace::from_current_dir()?, materialized)
1694}
1695
1696pub(crate) fn verify_package_cache_in(
1697 workspace: &PackageWorkspace,
1698 materialized: bool,
1699) -> Result<usize, PackageError> {
1700 let ctx = workspace.load_manifest_context()?;
1701 let lock = LockFile::load(&ctx.lock_path())?
1702 .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
1703 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1704 let mut verified = 0usize;
1705 for entry in &lock.packages {
1706 if verify_lock_entry_cache_in(workspace, entry)? {
1707 verified += 1;
1708 }
1709 if materialized && verify_materialized_lock_entry(&ctx, entry)? {
1710 verified += 1;
1711 }
1712 }
1713 Ok(verified)
1714}
1715
1716pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
1717 clean_package_cache_in(&PackageWorkspace::from_current_dir()?, all)
1718}
1719
1720pub(crate) fn clean_package_cache_in(
1721 workspace: &PackageWorkspace,
1722 all: bool,
1723) -> Result<usize, PackageError> {
1724 let entries = discover_git_cache_entries_in(workspace)?;
1725 if entries.is_empty() {
1726 return Ok(0);
1727 }
1728 if all {
1729 let root = workspace.cache_root()?;
1730 for child in ["git", "locks"] {
1731 let path = root.join(child);
1732 if path.exists() {
1733 fs::remove_dir_all(&path)
1734 .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
1735 }
1736 }
1737 return Ok(entries.len());
1738 }
1739
1740 let ctx = workspace.load_manifest_context()?;
1741 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
1742 format!(
1743 "{} is missing; pass --all to clean every cache entry",
1744 LOCK_FILE
1745 )
1746 })?;
1747 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1748 let keep = locked_git_cache_paths_in(workspace, &lock)?;
1749 let mut removed = 0usize;
1750 for entry in entries {
1751 if keep.contains(&entry.path) {
1752 continue;
1753 }
1754 fs::remove_dir_all(&entry.path)
1755 .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
1756 removed += 1;
1757 if let Some(parent) = entry.path.parent() {
1758 let is_empty = fs::read_dir(parent)
1759 .map(|mut children| children.next().is_none())
1760 .unwrap_or(false);
1761 if is_empty {
1762 fs::remove_dir(parent)
1763 .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
1764 }
1765 }
1766 }
1767 Ok(removed)
1768}
1769
1770pub fn list_package_cache() {
1771 let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
1772 Ok((cache_root()?, discover_git_cache_entries()?))
1773 })();
1774
1775 match result {
1776 Ok((root, entries)) => {
1777 println!("Cache root: {}", root.display());
1778 if entries.is_empty() {
1779 println!("No cached git packages.");
1780 return;
1781 }
1782 println!("commit\tcontent_hash\tsource\tpath");
1783 for entry in entries {
1784 let (source, content_hash) = entry
1785 .metadata
1786 .as_ref()
1787 .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
1788 .unwrap_or(("(unknown)", "(unknown)"));
1789 println!(
1790 "{}\t{}\t{}\t{}",
1791 entry.commit,
1792 content_hash,
1793 source,
1794 entry.path.display()
1795 );
1796 }
1797 }
1798 Err(error) => {
1799 eprintln!("error: {error}");
1800 process::exit(1);
1801 }
1802 }
1803}
1804
1805pub fn clean_package_cache(all: bool) {
1806 match clean_package_cache_impl(all) {
1807 Ok(removed) => println!("Removed {removed} cached package entries."),
1808 Err(error) => {
1809 eprintln!("error: {error}");
1810 process::exit(1);
1811 }
1812 }
1813}
1814
1815pub fn verify_package_cache(materialized: bool) {
1816 match verify_package_cache_impl(materialized) {
1817 Ok(verified) => println!("Verified {verified} package cache entries."),
1818 Err(error) => {
1819 eprintln!("error: {error}");
1820 process::exit(1);
1821 }
1822 }
1823}
1824
1825pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
1826 match search_package_registry_impl(query, registry) {
1827 Ok(packages) if json => {
1828 println!(
1829 "{}",
1830 serde_json::to_string_pretty(&packages)
1831 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1832 );
1833 }
1834 Ok(packages) => {
1835 if packages.is_empty() {
1836 println!("No packages found.");
1837 return;
1838 }
1839 println!("name\tlatest\tharn\tcontract\tdescription");
1840 for package in packages {
1841 let latest = latest_registry_version(&package)
1842 .map(|version| version.version.as_str())
1843 .unwrap_or("-");
1844 println!(
1845 "{}\t{}\t{}\t{}\t{}",
1846 package.name,
1847 latest,
1848 package.harn.as_deref().unwrap_or("-"),
1849 package.connector_contract.as_deref().unwrap_or("-"),
1850 package.description.as_deref().unwrap_or("")
1851 );
1852 }
1853 }
1854 Err(error) => {
1855 eprintln!("error: {error}");
1856 process::exit(1);
1857 }
1858 }
1859}
1860
1861pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
1862 match package_registry_info_impl(spec, registry) {
1863 Ok(info) if json => {
1864 println!(
1865 "{}",
1866 serde_json::to_string_pretty(&info)
1867 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1868 );
1869 }
1870 Ok(info) => {
1871 let package = info.package;
1872 println!("{}", package.name);
1873 if let Some(description) = package.description.as_deref() {
1874 println!("description: {description}");
1875 }
1876 println!("repository: {}", package.repository);
1877 if let Some(license) = package.license.as_deref() {
1878 println!("license: {license}");
1879 }
1880 if let Some(harn) = package.harn.as_deref() {
1881 println!("harn: {harn}");
1882 }
1883 if let Some(contract) = package.connector_contract.as_deref() {
1884 println!("connector_contract: {contract}");
1885 }
1886 if let Some(docs) = package.docs_url.as_deref() {
1887 println!("docs: {docs}");
1888 }
1889 if let Some(checksum) = package.checksum.as_deref() {
1890 println!("checksum: {checksum}");
1891 }
1892 if let Some(provenance) = package.provenance.as_deref() {
1893 println!("provenance: {provenance}");
1894 }
1895 if !package.exports.is_empty() {
1896 println!("exports: {}", package.exports.join(", "));
1897 }
1898 if let Some(version) = info.selected_version {
1899 println!("selected: {}", version.version);
1900 println!("git: {}", version.git);
1901 if let Some(rev) = version.rev.as_deref() {
1902 println!("rev: {rev}");
1903 }
1904 if let Some(branch) = version.branch.as_deref() {
1905 println!("branch: {branch}");
1906 }
1907 if let Some(package_name) = version.package.as_deref() {
1908 println!("package: {package_name}");
1909 }
1910 }
1911 if !package.versions.is_empty() {
1912 let versions = package
1913 .versions
1914 .iter()
1915 .map(|version| {
1916 if version.yanked {
1917 format!("{} (yanked)", version.version)
1918 } else {
1919 version.version.clone()
1920 }
1921 })
1922 .collect::<Vec<_>>()
1923 .join(", ");
1924 println!("versions: {versions}");
1925 }
1926 }
1927 Err(error) => {
1928 eprintln!("error: {error}");
1929 process::exit(1);
1930 }
1931 }
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936 use super::*;
1937 use crate::package::test_support::*;
1938
1939 #[test]
1940 fn pick_ls_remote_commit_prefers_peeled_tag_over_tag_object() {
1941 let output = "\
1945963b6e8acfdf030a9b922bc5a73e010758ff47da\trefs/tags/v0.1.0\n\
1946bad580c5fbe8ede612b2748ad98606642ce2fc02\trefs/tags/v0.1.0^{}\n";
1947 assert_eq!(
1948 pick_ls_remote_commit(output),
1949 Some("bad580c5fbe8ede612b2748ad98606642ce2fc02"),
1950 );
1951 }
1952
1953 #[test]
1954 fn pick_ls_remote_commit_falls_back_to_first_match_for_lightweight_tags() {
1955 let output = "\
1956abc123abc123abc123abc123abc123abc1234567\trefs/tags/v0.0.1\n";
1957 assert_eq!(
1958 pick_ls_remote_commit(output),
1959 Some("abc123abc123abc123abc123abc123abc1234567"),
1960 );
1961 }
1962
1963 #[test]
1964 fn pick_ls_remote_commit_returns_none_on_empty_output() {
1965 assert_eq!(pick_ls_remote_commit(""), None);
1966 }
1967
1968 #[cfg(unix)]
1969 #[test]
1970 fn hardened_git_env_scrubs_ambient_git_credentials_and_config() {
1971 let git_env = HardenedGitEnv::new().unwrap();
1972 let mut command = process::Command::new("/usr/bin/env");
1973 command
1974 .env("HOME", "/sensitive/home")
1975 .env("XDG_CONFIG_HOME", "/sensitive/config")
1976 .env("GIT_ASKPASS", "/sensitive/askpass")
1977 .env("GIT_SSH_COMMAND", "ssh -i /sensitive/key")
1978 .env("SSH_AUTH_SOCK", "/sensitive/agent.sock")
1979 .env("GIT_CONFIG_COUNT", "1")
1980 .env(
1981 "GIT_CONFIG_KEY_0",
1982 "http.https://attacker.example/.extraheader",
1983 )
1984 .env("GIT_CONFIG_VALUE_0", "Authorization: bearer secret");
1985 git_env.apply_to(&mut command, None);
1986
1987 let output = command.output().unwrap();
1988 assert!(
1989 output.status.success(),
1990 "env probe failed: {}",
1991 String::from_utf8_lossy(&output.stderr)
1992 );
1993 let stdout = String::from_utf8(output.stdout).unwrap();
1994 let vars: std::collections::BTreeMap<_, _> = stdout
1995 .lines()
1996 .filter_map(|line| line.split_once('='))
1997 .map(|(name, value)| (name.to_string(), value.to_string()))
1998 .collect();
1999
2000 assert_eq!(Path::new(&vars["HOME"]), git_env.home);
2001 assert_eq!(Path::new(&vars["XDG_CONFIG_HOME"]), git_env.config_home);
2002 assert_eq!(Path::new(&vars["GIT_CONFIG_GLOBAL"]), git_env.global_config);
2003 assert_eq!(Path::new(&vars["GIT_CONFIG_SYSTEM"]), git_env.system_config);
2004 assert_eq!(vars["GIT_CONFIG_NOSYSTEM"], "1");
2005 assert_eq!(vars["GIT_TERMINAL_PROMPT"], "0");
2006 assert!(!vars.contains_key("GIT_ASKPASS"));
2007 assert!(!vars.contains_key("GIT_SSH_COMMAND"));
2008 assert!(!vars.contains_key("SSH_AUTH_SOCK"));
2009 assert!(!vars.contains_key("GIT_CONFIG_COUNT"));
2010 assert!(!vars.contains_key("GIT_CONFIG_KEY_0"));
2011 assert!(!vars.contains_key("GIT_CONFIG_VALUE_0"));
2012 }
2013
2014 #[test]
2015 fn compute_content_hash_ignores_git_and_hash_marker() {
2016 let tmp = tempfile::tempdir().unwrap();
2017 let root = tmp.path();
2018 fs::create_dir_all(root.join(".git")).unwrap();
2019 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2020 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
2021 fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
2022 fs::write(
2023 root.join("lib.harn"),
2024 "pub fn value() -> number { return 1 }\n",
2025 )
2026 .unwrap();
2027 let first = compute_content_hash(root).unwrap();
2028 fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
2029 fs::write(root.join(".gitignore"), "changed\n").unwrap();
2030 fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
2031 let second = compute_content_hash(root).unwrap();
2032 assert_eq!(first, second);
2033 }
2034
2035 #[cfg(unix)]
2036 #[test]
2037 fn remove_materialized_package_unlinks_directory_symlink_without_touching_source() {
2038 let tmp = tempfile::tempdir().unwrap();
2039 let source = tmp.path().join("source");
2040 let packages = tmp.path().join(".harn/packages");
2041 fs::create_dir_all(&source).unwrap();
2042 fs::create_dir_all(&packages).unwrap();
2043 fs::write(
2044 source.join("lib.harn"),
2045 "pub fn value() -> number { return 1 }\n",
2046 )
2047 .unwrap();
2048
2049 let materialized = packages.join("acme");
2050 std::os::unix::fs::symlink(&source, &materialized).unwrap();
2051
2052 remove_materialized_package(&packages, "acme").unwrap();
2053
2054 assert!(!materialized.exists());
2055 assert!(source.join("lib.harn").is_file());
2056 }
2057
2058 #[test]
2059 fn package_cache_verify_detects_tampering_even_with_stale_marker() {
2060 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2061 let project_tmp = tempfile::tempdir().unwrap();
2062 let root = project_tmp.path();
2063 let workspace = TestWorkspace::new(root);
2064 fs::create_dir_all(root.join(".git")).unwrap();
2065 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2066 fs::write(
2067 root.join(MANIFEST),
2068 format!(
2069 r#"
2070 [package]
2071 name = "workspace"
2072 version = "0.1.0"
2073
2074 [dependencies]
2075 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2076 "#
2077 ),
2078 )
2079 .unwrap();
2080
2081 install_packages_in(workspace.env(), false, None, false).unwrap();
2082 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2083 let entry = lock.find("acme-lib").unwrap();
2084 let cache_dir = git_cache_dir_in(
2085 workspace.env(),
2086 &entry.source,
2087 entry.commit.as_deref().unwrap(),
2088 )
2089 .unwrap();
2090 fs::write(
2091 cache_dir.join("lib.harn"),
2092 "pub fn value() { return \"pwned\" }\n",
2093 )
2094 .unwrap();
2095
2096 let error = verify_package_cache_in(workspace.env(), false).unwrap_err();
2097 assert!(error.to_string().contains("content hash mismatch"));
2098 }
2099
2100 #[test]
2101 fn package_cache_clean_all_removes_cached_git_entries() {
2102 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2103 let project_tmp = tempfile::tempdir().unwrap();
2104 let root = project_tmp.path();
2105 let workspace = TestWorkspace::new(root);
2106 fs::create_dir_all(root.join(".git")).unwrap();
2107 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2108 fs::write(
2109 root.join(MANIFEST),
2110 format!(
2111 r#"
2112 [package]
2113 name = "workspace"
2114 version = "0.1.0"
2115
2116 [dependencies]
2117 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2118 "#
2119 ),
2120 )
2121 .unwrap();
2122
2123 install_packages_in(workspace.env(), false, None, false).unwrap();
2124 assert_eq!(
2125 discover_git_cache_entries_in(workspace.env())
2126 .unwrap()
2127 .len(),
2128 1
2129 );
2130
2131 let removed = clean_package_cache_in(workspace.env(), true).unwrap();
2132 assert_eq!(removed, 1);
2133 assert!(discover_git_cache_entries_in(workspace.env())
2134 .unwrap()
2135 .is_empty());
2136 }
2137
2138 #[test]
2139 fn registry_index_search_and_info_use_local_file_without_network() {
2140 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2141 let project_tmp = tempfile::tempdir().unwrap();
2142 let root = project_tmp.path();
2143 let workspace = TestWorkspace::new(root);
2144 let registry_path = root.join("index.toml");
2145 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2146 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2147 fs::create_dir_all(root.join(".git")).unwrap();
2148 fs::write(
2149 root.join(MANIFEST),
2150 r#"
2151 [package]
2152 name = "workspace"
2153 version = "0.1.0"
2154 "#,
2155 )
2156 .unwrap();
2157
2158 let matches = search_package_registry_in(
2159 workspace.env(),
2160 Some("acme"),
2161 Some(registry_path.to_string_lossy().as_ref()),
2162 )
2163 .unwrap();
2164 assert_eq!(matches.len(), 1);
2165 assert_eq!(matches[0].name, "@burin/acme-lib");
2166 assert_eq!(
2167 matches[0].harn.as_deref(),
2168 Some(crate::package::current_harn_range_example().as_str())
2169 );
2170 assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
2171 assert_eq!(matches[0].exports, vec!["lib"]);
2172
2173 let info = package_registry_info_in(
2174 workspace.env(),
2175 "@burin/acme-lib@1.0.0",
2176 Some(registry_path.to_string_lossy().as_ref()),
2177 )
2178 .unwrap();
2179 assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
2180 assert_eq!(
2181 info.selected_version
2182 .as_ref()
2183 .map(|version| version.git.as_str()),
2184 Some(git.as_str())
2185 );
2186 }
2187
2188 #[test]
2189 fn add_registry_dependency_preserves_provenance_in_manifest_and_lock() {
2190 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2191 let project_tmp = tempfile::tempdir().unwrap();
2192 let root = project_tmp.path();
2193 let registry_path = root.join("index.toml");
2194 let workspace =
2195 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2196 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2197 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2198 fs::create_dir_all(root.join(".git")).unwrap();
2199 fs::write(
2200 root.join(MANIFEST),
2201 r#"
2202 [package]
2203 name = "workspace"
2204 version = "0.1.0"
2205 "#,
2206 )
2207 .unwrap();
2208
2209 add_package_to(
2210 workspace.env(),
2211 "@burin/acme-lib@1.0.0",
2212 None,
2213 None,
2214 None,
2215 None,
2216 None,
2217 None,
2218 None,
2219 )
2220 .unwrap();
2221
2222 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2223 assert!(
2224 manifest.contains(&format!("git = \"{git}\"")),
2225 "registry install must record the resolved git URL: {manifest}"
2226 );
2227 assert!(
2228 manifest.contains("tag = \"v1.0.0\""),
2229 "registry install must pin the resolved tag: {manifest}"
2230 );
2231 assert!(
2232 manifest.contains("registry_name = \"@burin/acme-lib\""),
2233 "registry install must preserve the registry-side package name: {manifest}"
2234 );
2235 assert!(
2236 manifest.contains("registry_version = \"1.0.0\""),
2237 "registry install must preserve the requested registry version: {manifest}"
2238 );
2239 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2240 let entry = lock.find("acme-lib").unwrap();
2241 assert_eq!(entry.source, format!("git+{git}"));
2242 let registry = entry
2243 .registry
2244 .as_ref()
2245 .expect("registry-added entry should carry registry provenance");
2246 assert_eq!(registry.name, "@burin/acme-lib");
2247 assert_eq!(registry.version, "1.0.0");
2248 assert!(root
2249 .join(PKG_DIR)
2250 .join("acme-lib")
2251 .join("lib.harn")
2252 .is_file());
2253 }
2254
2255 #[test]
2256 fn add_registry_dependency_accepts_bare_alias_and_semver_range() {
2257 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2261 let project_tmp = tempfile::tempdir().unwrap();
2262 let root = project_tmp.path();
2263 let registry_path = root.join("index.toml");
2264 let workspace =
2265 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2266 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2267 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2268 fs::create_dir_all(root.join(".git")).unwrap();
2269 fs::write(
2270 root.join(MANIFEST),
2271 r#"
2272 [package]
2273 name = "workspace"
2274 version = "0.1.0"
2275 "#,
2276 )
2277 .unwrap();
2278
2279 add_package_to(
2281 workspace.env(),
2282 "acme-lib@^1",
2283 None,
2284 None,
2285 None,
2286 None,
2287 None,
2288 None,
2289 None,
2290 )
2291 .unwrap();
2292
2293 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2294 assert!(
2295 manifest.contains("registry_name = \"@burin/acme-lib\""),
2296 "bare-alias add must record the canonical scoped registry name: {manifest}"
2297 );
2298 assert!(
2299 manifest.contains("registry_version = \"1.0.0\""),
2300 "semver range must resolve to the highest matching exact version: {manifest}"
2301 );
2302 }
2303
2304 #[test]
2305 fn registry_index_rejects_invalid_names_and_duplicate_versions() {
2306 let content = r#"
2307 version = 1
2308
2309 [[package]]
2310 name = "@bad/"
2311 repository = "https://github.com/acme/acme-lib"
2312
2313 [[package.version]]
2314 version = "1.0.0"
2315 git = "https://github.com/acme/acme-lib"
2316 rev = "v1.0.0"
2317 "#;
2318 let error = parse_package_registry_index("fixture", content).unwrap_err();
2319 assert!(error.to_string().contains("invalid package name"));
2320
2321 let content = r#"
2322 version = 1
2323
2324 [[package]]
2325 name = "@burin/acme-lib"
2326 repository = "https://github.com/acme/acme-lib"
2327
2328 [[package.version]]
2329 version = "1.0.0"
2330 git = "https://github.com/acme/acme-lib"
2331 rev = "v1.0.0"
2332
2333 [[package.version]]
2334 version = "1.0.0"
2335 git = "https://github.com/acme/acme-lib"
2336 rev = "v1.0.0"
2337 "#;
2338 let error = parse_package_registry_index("fixture", content).unwrap_err();
2339 assert!(error.to_string().contains("more than once"));
2340 }
2341}