1use super::errors::PackageError;
2use super::*;
3use crate::net;
4use semver::{Version, VersionReq};
5
6const PRESERVED_GIT_ENV: &[&str] = &[
7 "PATH",
8 "TMPDIR",
9 "TEMP",
10 "TMP",
11 "SystemRoot",
12 "WINDIR",
13 "COMSPEC",
14 "PATHEXT",
15];
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub(crate) struct PackageCacheMetadata {
19 version: u32,
20 source: String,
21 commit: String,
22 content_hash: String,
23 cached_at_unix_ms: u128,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub(crate) struct PackageRegistryIndex {
28 version: u32,
29 #[serde(default, rename = "package")]
30 packages: Vec<RegistryPackage>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub(crate) struct RegistryPackage {
35 name: String,
36 #[serde(default)]
37 description: Option<String>,
38 repository: String,
39 #[serde(default)]
40 license: Option<String>,
41 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
42 harn: Option<String>,
43 #[serde(default)]
44 exports: Vec<String>,
45 #[serde(default, alias = "rule-pack", alias = "rulePack")]
46 rule_pack: Option<RegistryRulePackInfo>,
47 #[serde(default, alias = "connector-contract")]
48 connector_contract: Option<String>,
49 #[serde(default)]
50 docs_url: Option<String>,
51 #[serde(default)]
52 checksum: Option<String>,
53 #[serde(default)]
54 provenance: Option<String>,
55 #[serde(default, rename = "version")]
56 versions: Vec<RegistryPackageVersion>,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub(crate) struct RegistryRulePackInfo {
61 #[serde(default)]
62 pub(crate) rule_count: usize,
63 #[serde(default)]
64 pub(crate) languages: Vec<String>,
65 #[serde(default, alias = "safety-summary", alias = "safetySummary")]
66 pub(crate) safety_summary: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub(crate) struct RegistryPackageVersion {
71 version: String,
72 #[serde(default)]
73 git: Option<String>,
74 #[serde(default, alias = "archive-url", alias = "archive_url")]
75 archive: Option<String>,
76 #[serde(default)]
77 tag: Option<String>,
78 #[serde(default)]
79 rev: Option<String>,
80 #[serde(default)]
81 sha: Option<String>,
82 #[serde(default)]
83 branch: Option<String>,
84 #[serde(default)]
85 package: Option<String>,
86 #[serde(default)]
87 checksum: Option<String>,
88 #[serde(default)]
89 provenance: Option<String>,
90 #[serde(default)]
91 yanked: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub(crate) struct RegistryPackageInfo {
96 package: RegistryPackage,
97 selected_version: Option<RegistryPackageVersion>,
98}
99
100pub(crate) fn manifest_has_git_dependencies(manifest: &Manifest) -> bool {
101 manifest.dependencies.values().any(Dependency::requires_git)
102}
103
104pub(crate) fn ensure_git_available() -> Result<(), PackageError> {
105 process::Command::new("git")
106 .arg("--version")
107 .env_remove("GIT_DIR")
108 .env_remove("GIT_WORK_TREE")
109 .env_remove("GIT_INDEX_FILE")
110 .output()
111 .map(|_| ())
112 .map_err(|_| {
113 PackageError::Registry(
114 "git is required for git dependencies but was not found in PATH".to_string(),
115 )
116 })
117}
118
119pub(crate) fn cache_root() -> Result<PathBuf, PackageError> {
120 PackageWorkspace::from_current_dir()?.cache_root()
121}
122
123pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
124 hex::encode(Sha256::digest(bytes.as_ref()))
125}
126
127pub(crate) fn git_cache_dir_in(
128 workspace: &PackageWorkspace,
129 source: &str,
130 commit: &str,
131) -> Result<PathBuf, PackageError> {
132 Ok(workspace
133 .cache_root()?
134 .join("git")
135 .join(sha256_hex(source))
136 .join(commit))
137}
138
139pub(crate) fn git_cache_lock_path_in(
140 workspace: &PackageWorkspace,
141 source: &str,
142 commit: &str,
143) -> Result<PathBuf, PackageError> {
144 Ok(workspace
145 .cache_root()?
146 .join("locks")
147 .join(format!("{}-{commit}.lock", sha256_hex(source))))
148}
149
150pub(crate) fn archive_cache_key(content_hash: &str) -> Result<&str, PackageError> {
151 let Some(value) = content_hash.strip_prefix("sha256:") else {
152 return Err(format!("archive checksum must use sha256:<hex>, got {content_hash}").into());
153 };
154 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
155 return Err(
156 format!("archive checksum must use sha256:<64 hex>, got {content_hash}").into(),
157 );
158 }
159 Ok(value)
160}
161
162pub(crate) fn archive_cache_dir_in(
163 workspace: &PackageWorkspace,
164 source: &str,
165 content_hash: &str,
166) -> Result<PathBuf, PackageError> {
167 Ok(workspace
168 .cache_root()?
169 .join("archive")
170 .join(sha256_hex(source))
171 .join(archive_cache_key(content_hash)?))
172}
173
174pub(crate) fn archive_cache_lock_path_in(
175 workspace: &PackageWorkspace,
176 source: &str,
177 content_hash: &str,
178) -> Result<PathBuf, PackageError> {
179 Ok(workspace.cache_root()?.join("locks").join(format!(
180 "{}-{}.lock",
181 sha256_hex(source),
182 archive_cache_key(content_hash)?
183 )))
184}
185
186pub(crate) fn acquire_git_cache_lock_in(
187 workspace: &PackageWorkspace,
188 source: &str,
189 commit: &str,
190) -> Result<File, PackageError> {
191 let path = git_cache_lock_path_in(workspace, source, commit)?;
192 if let Some(parent) = path.parent() {
193 fs::create_dir_all(parent)
194 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
195 }
196 let file = File::create(&path)
197 .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
198 file.lock_exclusive()
199 .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
200 Ok(file)
201}
202
203pub(crate) fn acquire_archive_cache_lock_in(
204 workspace: &PackageWorkspace,
205 source: &str,
206 content_hash: &str,
207) -> Result<File, PackageError> {
208 let path = archive_cache_lock_path_in(workspace, source, content_hash)?;
209 if let Some(parent) = path.parent() {
210 fs::create_dir_all(parent)
211 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
212 }
213 let file = File::create(&path)
214 .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
215 file.lock_exclusive()
216 .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
217 Ok(file)
218}
219
220pub(crate) fn read_cached_content_hash(dir: &Path) -> Result<Option<String>, PackageError> {
221 let path = dir.join(CONTENT_HASH_FILE);
222 match fs::read_to_string(&path) {
223 Ok(value) => Ok(Some(value.trim().to_string())),
224 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
225 Err(error) => Err(format!("failed to read {}: {error}", path.display()).into()),
226 }
227}
228
229pub(crate) fn write_cached_content_hash(dir: &Path, hash: &str) -> Result<(), PackageError> {
230 let path = dir.join(CONTENT_HASH_FILE);
231 harn_vm::atomic_io::atomic_write(&path, format!("{hash}\n").as_bytes()).map_err(|error| {
232 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
233 })
234}
235
236pub(crate) fn read_cache_metadata(
237 dir: &Path,
238) -> Result<Option<PackageCacheMetadata>, PackageError> {
239 let path = dir.join(CACHE_METADATA_FILE);
240 let content = match fs::read_to_string(&path) {
241 Ok(content) => content,
242 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
243 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
244 };
245 let metadata = toml::from_str::<PackageCacheMetadata>(&content)
246 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
247 if metadata.version != CACHE_METADATA_VERSION {
248 return Err(format!(
249 "unsupported {} version {} (expected {})",
250 path.display(),
251 metadata.version,
252 CACHE_METADATA_VERSION
253 )
254 .into());
255 }
256 Ok(Some(metadata))
257}
258
259pub(crate) fn write_cache_metadata(
260 dir: &Path,
261 source: &str,
262 commit: &str,
263 content_hash: &str,
264) -> Result<(), PackageError> {
265 let cached_at_unix_ms = SystemTime::now()
266 .duration_since(UNIX_EPOCH)
267 .map_err(|error| format!("system clock error: {error}"))?
268 .as_millis();
269 let metadata = PackageCacheMetadata {
270 version: CACHE_METADATA_VERSION,
271 source: source.to_string(),
272 commit: commit.to_string(),
273 content_hash: content_hash.to_string(),
274 cached_at_unix_ms,
275 };
276 let body = toml::to_string_pretty(&metadata)
277 .map_err(|error| format!("failed to encode cache metadata: {error}"))?;
278 let path = dir.join(CACHE_METADATA_FILE);
279 harn_vm::atomic_io::atomic_write(&path, body.as_bytes()).map_err(|error| {
280 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
281 })
282}
283
284pub(crate) fn normalized_relative_path(path: &Path) -> String {
285 harn_modules::package_execution::normalized_package_relative_path(path)
286}
287
288pub(crate) fn compute_content_hash(dir: &Path) -> Result<String, PackageError> {
289 harn_modules::package_execution::compute_package_content_hash(dir)
290 .map_err(|error| PackageError::Registry(error.to_string()))
291}
292
293pub(crate) fn verify_content_hash_or_compute(
294 dir: &Path,
295 expected: &str,
296) -> Result<(), PackageError> {
297 let actual = compute_content_hash(dir)?;
298 if actual != expected {
299 return Err(format!(
300 "content hash mismatch for {}: expected {}, got {}",
301 dir.display(),
302 expected,
303 actual
304 )
305 .into());
306 }
307 if read_cached_content_hash(dir)?.as_deref() != Some(expected) {
308 write_cached_content_hash(dir, expected)?;
309 }
310 Ok(())
311}
312
313pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {
314 fs::create_dir_all(dst)
315 .map_err(|error| format!("failed to create {}: {error}", dst.display()))?;
316 for entry in
317 fs::read_dir(src).map_err(|error| format!("failed to read {}: {error}", src.display()))?
318 {
319 let entry =
320 entry.map_err(|error| format!("failed to read {} entry: {error}", src.display()))?;
321 let ty = entry
322 .file_type()
323 .map_err(|error| format!("failed to stat {}: {error}", entry.path().display()))?;
324 let name = entry.file_name();
325 if name == OsStr::new(".git")
326 || name == OsStr::new(CONTENT_HASH_FILE)
327 || name == OsStr::new(CACHE_METADATA_FILE)
328 {
329 continue;
330 }
331 let dest_path = dst.join(entry.file_name());
332 if ty.is_dir() {
333 copy_dir_recursive(&entry.path(), &dest_path)?;
334 } else if ty.is_file() {
335 if let Some(parent) = dest_path.parent() {
336 fs::create_dir_all(parent)
337 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
338 }
339 fs::copy(entry.path(), &dest_path).map_err(|error| {
340 format!(
341 "failed to copy {} to {}: {error}",
342 entry.path().display(),
343 dest_path.display()
344 )
345 })?;
346 }
347 }
348 Ok(())
349}
350
351#[cfg(unix)]
352pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
353 std::os::unix::fs::symlink(source, dest).map_err(|error| {
354 PackageError::Registry(format!(
355 "failed to symlink {} to {}: {error}",
356 source.display(),
357 dest.display()
358 ))
359 })
360}
361
362#[cfg(windows)]
363pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
364 if source.is_dir() {
365 std::os::windows::fs::symlink_dir(source, dest)
366 } else {
367 std::os::windows::fs::symlink_file(source, dest)
368 }
369 .map_err(|error| {
370 PackageError::Registry(format!(
371 "failed to symlink {} to {}: {error}",
372 source.display(),
373 dest.display()
374 ))
375 })
376}
377
378#[cfg(not(any(unix, windows)))]
379pub(crate) fn symlink_path_dependency(_source: &Path, _dest: &Path) -> Result<(), PackageError> {
380 Err("symlinks are not supported on this platform"
381 .to_string()
382 .into())
383}
384
385pub(crate) fn materialize_path_dependency(
386 source: &Path,
387 dest_root: &Path,
388 alias: &str,
389) -> Result<(), PackageError> {
390 if source.is_dir() {
391 let dest = dest_root.join(alias);
392 match symlink_path_dependency(source, &dest) {
393 Ok(()) => Ok(()),
394 Err(_) => copy_dir_recursive(source, &dest),
395 }
396 } else {
397 let dest = dest_root.join(format!("{alias}.harn"));
398 if let Some(parent) = dest.parent() {
399 fs::create_dir_all(parent)
400 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
401 }
402 match symlink_path_dependency(source, &dest) {
403 Ok(()) => Ok(()),
404 Err(_) => {
405 fs::copy(source, &dest).map_err(|error| {
406 format!(
407 "failed to copy {} to {}: {error}",
408 source.display(),
409 dest.display()
410 )
411 })?;
412 Ok(())
413 }
414 }
415 }
416}
417
418pub(crate) fn materialized_hash_matches(dir: &Path, expected: &str) -> bool {
419 verify_content_hash_or_compute(dir, expected).is_ok()
420}
421
422pub(crate) fn resolve_path_dependency_source(
423 manifest_dir: &Path,
424 raw: &str,
425) -> Result<PathBuf, PackageError> {
426 let source = {
427 let candidate = PathBuf::from(raw);
428 if candidate.is_absolute() {
429 candidate
430 } else {
431 manifest_dir.join(candidate)
432 }
433 };
434 if source.exists() {
435 return source.canonicalize().map_err(|error| {
436 PackageError::Registry(format!(
437 "failed to canonicalize {}: {error}",
438 source.display()
439 ))
440 });
441 }
442 if source.extension().is_none() {
443 let with_ext = source.with_extension("harn");
444 if with_ext.exists() {
445 return with_ext.canonicalize().map_err(|error| {
446 PackageError::Registry(format!(
447 "failed to canonicalize {}: {error}",
448 with_ext.display()
449 ))
450 });
451 }
452 }
453 Err(format!("package source not found: {}", source.display()).into())
454}
455
456pub(crate) fn path_source_uri(path: &Path) -> Result<String, PackageError> {
457 let url = Url::from_file_path(path)
458 .map_err(|_| format!("failed to convert {} to file:// URL", path.display()))?;
459 Ok(format!("path+{url}"))
460}
461
462pub(crate) fn path_from_source_uri(source: &str) -> Result<PathBuf, PackageError> {
463 let raw = source
464 .strip_prefix("path+")
465 .ok_or_else(|| format!("invalid path source: {source}"))?;
466 if crate::format::looks_like_windows_drive_path(raw) {
467 return Ok(PathBuf::from(raw));
468 }
469 if let Ok(url) = Url::parse(raw) {
470 return url
471 .to_file_path()
472 .map_err(|_| PackageError::Registry(format!("invalid file:// path source: {source}")));
473 }
474 Ok(PathBuf::from(raw))
475}
476
477pub(crate) fn archive_url_from_source_uri(source: &str) -> Result<&str, PackageError> {
478 source
479 .strip_prefix("archive+")
480 .ok_or_else(|| format!("invalid archive source: {source}").into())
481}
482
483pub(crate) fn archive_source_uri(raw: &str) -> Result<String, PackageError> {
484 Ok(format!("archive+{}", normalize_archive_url(raw)?))
485}
486
487pub(crate) fn registry_file_url_or_path(raw: &str) -> Result<Option<PathBuf>, PackageError> {
488 if crate::format::looks_like_windows_drive_path(raw) {
489 return Ok(Some(PathBuf::from(raw)));
490 }
491 if let Ok(url) = Url::parse(raw) {
492 if url.scheme() == "file" {
493 return url.to_file_path().map(Some).map_err(|_| {
494 PackageError::Registry(format!("invalid file:// registry URL: {raw}"))
495 });
496 }
497 return Ok(None);
498 }
499 Ok(Some(PathBuf::from(raw)))
500}
501
502pub(crate) fn normalize_archive_url(raw: &str) -> Result<String, PackageError> {
503 let trimmed = raw.trim();
504 if trimmed.is_empty() {
505 return Err("archive URL cannot be empty".to_string().into());
506 }
507 if crate::format::looks_like_windows_drive_path(trimmed) {
508 return normalize_archive_path(trimmed);
509 }
510 if let Ok(url) = Url::parse(trimmed) {
511 return match url.scheme() {
512 "file" => {
513 let path = url.to_file_path().map_err(|_| {
514 PackageError::Registry(format!(
515 "invalid file:// package archive URL: {trimmed}"
516 ))
517 })?;
518 if path.exists() {
519 let canonical = path.canonicalize().map_err(|error| {
520 format!("failed to canonicalize {}: {error}", path.display())
521 })?;
522 let url = Url::from_file_path(canonical)
523 .map_err(|_| format!("failed to convert {trimmed} to file:// URL"))?;
524 Ok(url.to_string())
525 } else {
526 Ok(url.to_string())
527 }
528 }
529 "http" | "https" => Ok(url.to_string()),
530 other => Err(format!("unsupported package archive URL scheme: {other}").into()),
531 };
532 }
533
534 normalize_archive_path(trimmed)
535}
536
537fn normalize_archive_path(raw: &str) -> Result<String, PackageError> {
538 let path = PathBuf::from(raw);
539 if !path.exists() {
540 return Err(format!("package archive not found: {}", path.display()).into());
541 }
542 let canonical = path
543 .canonicalize()
544 .map_err(|error| format!("failed to canonicalize {}: {error}", path.display()))?;
545 let url = Url::from_file_path(canonical)
546 .map_err(|_| format!("failed to convert {raw} to file:// URL"))?;
547 Ok(url.to_string())
548}
549
550fn package_registry_auth_token() -> Option<String> {
551 std::env::var(HARN_PACKAGE_REGISTRY_TOKEN_ENV)
552 .ok()
553 .map(|value| value.trim().to_string())
554 .filter(|value| !value.is_empty())
555}
556
557fn apply_package_registry_auth(
558 request: reqwest::blocking::RequestBuilder,
559) -> reqwest::blocking::RequestBuilder {
560 if let Some(token) = package_registry_auth_token() {
561 request.bearer_auth(token)
562 } else {
563 request
564 }
565}
566
567pub(crate) fn read_registry_source(source: &str) -> Result<String, PackageError> {
568 if let Some(path) = registry_file_url_or_path(source)? {
569 return fs::read_to_string(&path).map_err(|error| {
570 PackageError::Registry(format!(
571 "failed to read package registry {}: {error}",
572 path.display()
573 ))
574 });
575 }
576
577 let url = Url::parse(source)
578 .map_err(|error| format!("invalid package registry URL {source:?}: {error}"))?;
579 match url.scheme() {
580 "http" | "https" => {}
581 other => return Err(format!("unsupported package registry URL scheme: {other}").into()),
582 }
583 let source_owned = source.to_string();
589 std::thread::scope(|scope| {
590 scope
591 .spawn(move || fetch_registry_blocking(url, &source_owned))
592 .join()
593 .map_err(|_| PackageError::Registry("registry fetch thread panicked".to_string()))?
594 })
595}
596
597fn fetch_registry_blocking(url: Url, source: &str) -> Result<String, PackageError> {
598 let display_source = net::diagnostic_text(source);
599 let client = net::blocking_http_client("cli.package.registry", Duration::from_secs(20))
600 .map_err(PackageError::Registry)?;
601 let response = apply_package_registry_auth(client.get(url))
602 .send()
603 .map_err(|error| {
604 format!(
605 "failed to fetch package registry {display_source}: {}",
606 net::reqwest_error(&error)
607 )
608 })?;
609 let status = response.status();
610 if !status.is_success() {
611 return Err(format!("GET {display_source} returned HTTP {status}").into());
612 }
613 response.text().map_err(|error| {
614 PackageError::Registry(format!(
615 "failed to read package registry response: {}",
616 net::reqwest_error(&error)
617 ))
618 })
619}
620
621pub(crate) fn read_package_archive_bytes(source: &str) -> Result<Vec<u8>, PackageError> {
622 if let Some(path) = registry_file_url_or_path(source)? {
623 let metadata = fs::metadata(&path).map_err(|error| {
624 format!("failed to stat package archive {}: {error}", path.display())
625 })?;
626 if metadata.len() > PACKAGE_ARCHIVE_MAX_BYTES {
627 return Err(format!(
628 "package archive {} is {} bytes, above the {} byte limit",
629 path.display(),
630 metadata.len(),
631 PACKAGE_ARCHIVE_MAX_BYTES
632 )
633 .into());
634 }
635 return fs::read(&path).map_err(|error| {
636 PackageError::Registry(format!(
637 "failed to read package archive {}: {error}",
638 path.display()
639 ))
640 });
641 }
642
643 let url = Url::parse(source)
644 .map_err(|error| format!("invalid package archive URL {source:?}: {error}"))?;
645 match url.scheme() {
646 "http" | "https" => {}
647 other => return Err(format!("unsupported package archive URL scheme: {other}").into()),
648 }
649 let source_owned = source.to_string();
650 std::thread::scope(|scope| {
651 scope
652 .spawn(move || fetch_package_archive_blocking(url, &source_owned))
653 .join()
654 .map_err(|_| {
655 PackageError::Registry("package archive fetch thread panicked".to_string())
656 })?
657 })
658}
659
660fn fetch_package_archive_blocking(url: Url, source: &str) -> Result<Vec<u8>, PackageError> {
661 let display_source = net::diagnostic_text(source);
662 let client = net::blocking_http_client("cli.package.archive", Duration::from_secs(30))
663 .map_err(PackageError::Registry)?;
664 let response = apply_package_registry_auth(client.get(url))
665 .send()
666 .map_err(|error| {
667 format!(
668 "failed to fetch package archive {display_source}: {}",
669 net::reqwest_error(&error)
670 )
671 })?;
672 let status = response.status();
673 if !status.is_success() {
674 return Err(format!("GET {display_source} returned HTTP {status}").into());
675 }
676 if response
677 .content_length()
678 .is_some_and(|length| length > PACKAGE_ARCHIVE_MAX_BYTES)
679 {
680 return Err(format!(
681 "package archive {display_source} is larger than the {PACKAGE_ARCHIVE_MAX_BYTES} byte limit"
682 )
683 .into());
684 }
685 let bytes = response.bytes().map_err(|error| {
686 format!(
687 "failed to read package archive response: {}",
688 net::reqwest_error(&error)
689 )
690 })?;
691 if bytes.len() as u64 > PACKAGE_ARCHIVE_MAX_BYTES {
692 return Err(format!(
693 "package archive {display_source} is larger than the {PACKAGE_ARCHIVE_MAX_BYTES} byte limit"
694 )
695 .into());
696 }
697 Ok(bytes.to_vec())
698}
699
700pub(crate) fn is_valid_registry_segment(segment: &str) -> bool {
701 let mut chars = segment.chars();
702 let Some(first) = chars.next() else {
703 return false;
704 };
705 first.is_ascii_alphanumeric()
706 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
707}
708
709pub(crate) fn is_valid_registry_package_name(name: &str) -> bool {
710 let trimmed = name.trim();
711 if trimmed != name || trimmed.is_empty() || trimmed.contains("://") || trimmed.ends_with('/') {
712 return false;
713 }
714 if let Some(scoped) = trimmed.strip_prefix('@') {
715 let Some((scope, package)) = scoped.split_once('/') else {
716 return false;
717 };
718 return !package.contains('/')
719 && is_valid_registry_segment(scope)
720 && is_valid_registry_segment(package);
721 }
722 !trimmed.contains('/') && is_valid_registry_segment(trimmed)
723}
724
725pub(crate) fn parse_registry_package_spec(spec: &str) -> Option<(&str, Option<&str>)> {
726 let trimmed = spec.trim();
727 if !trimmed.starts_with('@') {
728 if let Some((name, version)) = trimmed.rsplit_once('@') {
729 if is_valid_registry_package_name(name) && !version.trim().is_empty() {
730 return Some((name, Some(version)));
731 }
732 }
733 if is_valid_registry_package_name(trimmed) {
734 return Some((trimmed, None));
735 }
736 return None;
737 }
738
739 if let Some((name, version)) = trimmed.rsplit_once('@') {
740 if !name.is_empty()
741 && name != trimmed
742 && is_valid_registry_package_name(name)
743 && !version.trim().is_empty()
744 {
745 return Some((name, Some(version)));
746 }
747 }
748 if is_valid_registry_package_name(trimmed) {
749 return Some((trimmed, None));
750 }
751 None
752}
753
754pub(crate) fn parse_package_registry_index(
755 source: &str,
756 content: &str,
757) -> Result<PackageRegistryIndex, PackageError> {
758 let mut index = toml::from_str::<PackageRegistryIndex>(content)
759 .map_err(|error| format!("failed to parse package registry {source}: {error}"))?;
760 if index.version != REGISTRY_INDEX_VERSION {
761 return Err(format!(
762 "unsupported package registry {source} version {} (expected {})",
763 index.version, REGISTRY_INDEX_VERSION
764 )
765 .into());
766 }
767 validate_package_registry_index(source, &mut index)?;
768 Ok(index)
769}
770
771pub(crate) fn validate_package_registry_index(
772 source: &str,
773 index: &mut PackageRegistryIndex,
774) -> Result<(), PackageError> {
775 let mut names = HashSet::new();
776 for package in &mut index.packages {
777 if !is_valid_registry_package_name(&package.name) {
778 return Err(format!(
779 "package registry {source} has invalid package name '{}'",
780 package.name
781 )
782 .into());
783 }
784 if !names.insert(package.name.clone()) {
785 return Err(format!(
786 "package registry {source} declares '{}' more than once",
787 package.name
788 )
789 .into());
790 }
791 normalize_git_url(&package.repository).map_err(|error| {
792 format!(
793 "package registry {source} has invalid repository for '{}': {error}",
794 package.name
795 )
796 })?;
797 if let Some(rule_pack) = package.rule_pack.as_mut() {
798 normalize_rule_pack_info(rule_pack);
799 }
800 let mut versions = HashSet::new();
801 for version in &package.versions {
802 if version.version.trim().is_empty() {
803 return Err(format!(
804 "package registry {source} has empty version for '{}'",
805 package.name
806 )
807 .into());
808 }
809 if !versions.insert(version.version.clone()) {
810 return Err(format!(
811 "package registry {source} declares '{}@{}' more than once",
812 package.name, version.version
813 )
814 .into());
815 }
816 parse_registry_semver(&version.version).map_err(|error| {
817 format!(
818 "package registry {source} has invalid semver for '{}@{}': {error}",
819 package.name, version.version
820 )
821 })?;
822 match (version.git.as_deref(), version.archive.as_deref()) {
823 (Some(git), None) => {
824 if selected_git_ref_count(version) != 1 {
825 return Err(format!(
826 "package registry {source} entry '{}@{}' must specify tag, rev, or branch; rev may accompany tag as a resolved commit pin",
827 package.name, version.version
828 )
829 .into());
830 }
831 normalize_git_url(git).map_err(|error| {
832 format!(
833 "package registry {source} has invalid git source for '{}@{}': {error}",
834 package.name, version.version
835 )
836 })?;
837 }
838 (None, Some(archive)) => {
839 if version.tag.is_some() || version.rev.is_some() || version.branch.is_some() {
840 return Err(format!(
841 "package registry {source} entry '{}@{}' must not combine archive with tag, rev, or branch",
842 package.name, version.version
843 )
844 .into());
845 }
846 normalize_archive_url(archive).map_err(|error| {
847 format!(
848 "package registry {source} has invalid archive source for '{}@{}': {error}",
849 package.name, version.version
850 )
851 })?;
852 let checksum = version.checksum.as_deref().ok_or_else(|| {
853 format!(
854 "package registry {source} entry '{}@{}' must specify checksum for archive source",
855 package.name, version.version
856 )
857 })?;
858 archive_cache_key(checksum).map_err(|error| {
859 format!(
860 "package registry {source} has invalid archive checksum for '{}@{}': {error}",
861 package.name, version.version
862 )
863 })?;
864 }
865 (Some(_), Some(_)) => {
866 return Err(format!(
867 "package registry {source} entry '{}@{}' must specify only one of git or archive",
868 package.name, version.version
869 )
870 .into());
871 }
872 (None, None) => {
873 return Err(format!(
874 "package registry {source} entry '{}@{}' must specify git or archive",
875 package.name, version.version
876 )
877 .into());
878 }
879 }
880 }
881 }
882 index
883 .packages
884 .sort_by(|left, right| left.name.cmp(&right.name));
885 Ok(())
886}
887
888fn normalize_rule_pack_info(rule_pack: &mut RegistryRulePackInfo) {
889 rule_pack
890 .languages
891 .retain(|language| !language.trim().is_empty());
892 rule_pack.languages.sort();
893 rule_pack.languages.dedup();
894 rule_pack
895 .safety_summary
896 .retain(|entry| !entry.trim().is_empty());
897 rule_pack.safety_summary.sort();
898 rule_pack.safety_summary.dedup();
899}
900
901fn selected_git_ref_count(version: &RegistryPackageVersion) -> usize {
902 usize::from(version.tag.is_some())
903 + usize::from(version.tag.is_none() && version.rev.is_some())
904 + usize::from(version.branch.is_some())
905}
906
907pub(crate) fn load_package_registry_in(
908 workspace: &PackageWorkspace,
909 explicit: Option<&str>,
910) -> Result<(String, PackageRegistryIndex), PackageError> {
911 let source = workspace.resolve_registry_source(explicit)?;
912 let content = read_registry_source(&source)?;
913 let index = parse_package_registry_index(&source, &content)?;
914 Ok((source, index))
915}
916
917pub(crate) fn registry_package_matches(package: &RegistryPackage, query: &str) -> bool {
918 if query.trim().is_empty() {
919 return true;
920 }
921 let query = query.to_ascii_lowercase();
922 package.name.to_ascii_lowercase().contains(&query)
923 || package
924 .description
925 .as_deref()
926 .is_some_and(|value| value.to_ascii_lowercase().contains(&query))
927 || package.repository.to_ascii_lowercase().contains(&query)
928 || package
929 .exports
930 .iter()
931 .any(|export| export.to_ascii_lowercase().contains(&query))
932 || package.rule_pack.as_ref().is_some_and(|rule_pack| {
933 rule_pack
934 .languages
935 .iter()
936 .chain(rule_pack.safety_summary.iter())
937 .any(|value| value.to_ascii_lowercase().contains(&query))
938 })
939}
940
941pub(crate) fn latest_registry_version(
942 package: &RegistryPackage,
943) -> Option<&RegistryPackageVersion> {
944 let parsed: Vec<(Version, &RegistryPackageVersion)> = package
945 .versions
946 .iter()
947 .filter(|version| !version.yanked)
948 .filter_map(|version| {
949 parse_registry_semver(&version.version)
950 .ok()
951 .map(|semver| (semver, version))
952 })
953 .collect();
954 parsed
959 .iter()
960 .filter(|(semver, _)| semver.pre.is_empty())
961 .max_by(|(left, _), (right, _)| left.cmp(right))
962 .or_else(|| {
963 parsed
964 .iter()
965 .max_by(|(left, _), (right, _)| left.cmp(right))
966 })
967 .map(|(_, version)| *version)
968}
969
970impl PackageRegistryIndex {
971 pub(crate) fn latest_unyanked_version(&self, name: &str) -> Option<&str> {
972 self.packages
973 .iter()
974 .find(|package| package.name == name)
975 .and_then(latest_registry_version)
976 .map(|version| version.version.as_str())
977 }
978
979 pub(crate) fn is_version_yanked(&self, name: &str, version: &str) -> bool {
980 self.packages
981 .iter()
982 .find(|package| package.name == name)
983 .into_iter()
984 .flat_map(|package| package.versions.iter())
985 .any(|entry| entry.version == version && entry.yanked)
986 }
987}
988
989pub(crate) fn parse_registry_semver(raw: &str) -> Result<Version, PackageError> {
990 Version::parse(raw.trim().trim_start_matches('v'))
991 .map_err(|error| PackageError::Registry(error.to_string()))
992}
993
994pub(crate) fn parse_registry_version_req(raw: &str) -> Result<VersionReq, PackageError> {
995 VersionReq::parse(&normalize_registry_version_req(raw)).map_err(|error| {
996 PackageError::Registry(format!("invalid version requirement {raw:?}: {error}"))
997 })
998}
999
1000fn normalize_registry_version_req(raw: &str) -> String {
1001 raw.split(',')
1002 .map(|part| normalize_version_req_part(part.trim()))
1003 .collect::<Vec<_>>()
1004 .join(",")
1005}
1006
1007fn normalize_version_req_part(part: &str) -> String {
1008 for op in ["<=", ">=", "!=", "=", "<", ">", "^", "~"] {
1009 if let Some(rest) = part.strip_prefix(op) {
1010 return format!("{op}{}", normalize_partial_version(rest.trim()));
1011 }
1012 }
1013 normalize_partial_version(part)
1014}
1015
1016fn normalize_partial_version(raw: &str) -> String {
1017 let trimmed = raw.trim().trim_start_matches('v');
1018 if trimmed == "*" || trimmed.eq_ignore_ascii_case("x") {
1019 return trimmed.to_string();
1020 }
1021 let (core, suffix) = trimmed
1022 .find(['-', '+'])
1023 .map(|index| (&trimmed[..index], &trimmed[index..]))
1024 .unwrap_or((trimmed, ""));
1025 let mut parts = core.split('.').collect::<Vec<_>>();
1026 if (1..=2).contains(&parts.len())
1027 && parts
1028 .iter()
1029 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
1030 {
1031 while parts.len() < 3 {
1032 parts.push("0");
1033 }
1034 return format!("{}{}", parts.join("."), suffix);
1035 }
1036 trimmed.to_string()
1037}
1038
1039fn lookup_registry_package<'a>(
1044 index: &'a PackageRegistryIndex,
1045 name: &str,
1046) -> Result<&'a RegistryPackage, PackageError> {
1047 if let Some(package) = index.packages.iter().find(|package| package.name == name) {
1048 return Ok(package);
1049 }
1050 let matches: Vec<&RegistryPackage> = index
1051 .packages
1052 .iter()
1053 .filter(|package| {
1054 package
1055 .versions
1056 .iter()
1057 .any(|entry| entry.package.as_deref() == Some(name))
1058 })
1059 .collect();
1060 match matches.as_slice() {
1061 [package] => Ok(package),
1062 [] => Err(format!("package registry does not contain {name}").into()),
1063 many => Err(format!(
1064 "package alias {name} is ambiguous in the registry — found {} packages; use the scoped name (e.g. {})",
1065 many.len(),
1066 many[0].name,
1067 )
1068 .into()),
1069 }
1070}
1071
1072pub(crate) fn find_registry_package_version(
1073 index: &PackageRegistryIndex,
1074 name: &str,
1075 version: Option<&str>,
1076) -> Result<RegistryPackageInfo, PackageError> {
1077 let package = lookup_registry_package(index, name)?;
1078 let selected_version = match version {
1079 Some(version) => Some(
1080 package
1081 .versions
1082 .iter()
1083 .find(|entry| entry.version == version)
1084 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?
1085 .clone(),
1086 ),
1087 None => latest_registry_version(package).cloned(),
1088 };
1089 Ok(RegistryPackageInfo {
1090 package: package.clone(),
1091 selected_version,
1092 })
1093}
1094
1095pub(crate) fn find_registry_package_version_matching(
1096 index: &PackageRegistryIndex,
1097 name: &str,
1098 requirement: &str,
1099) -> Result<RegistryPackageInfo, PackageError> {
1100 let package = lookup_registry_package(index, name)?;
1101 let req = parse_registry_version_req(requirement)?;
1102 let selected_version = package
1103 .versions
1104 .iter()
1105 .filter(|entry| !entry.yanked)
1106 .filter_map(|entry| {
1107 parse_registry_semver(&entry.version)
1108 .ok()
1109 .filter(|version| req.matches(version))
1110 .map(|version| (version, entry.clone()))
1111 })
1112 .max_by(|(left, _), (right, _)| left.cmp(right))
1113 .map(|(_, entry)| entry)
1114 .ok_or_else(|| {
1115 format!("package registry does not contain {name} matching {requirement}")
1116 })?;
1117 Ok(RegistryPackageInfo {
1118 package: package.clone(),
1119 selected_version: Some(selected_version),
1120 })
1121}
1122
1123pub(crate) fn search_package_registry_impl(
1124 query: Option<&str>,
1125 registry: Option<&str>,
1126) -> Result<Vec<RegistryPackage>, PackageError> {
1127 search_package_registry_in(&PackageWorkspace::from_current_dir()?, query, registry)
1128}
1129
1130pub(crate) fn search_package_registry_in(
1131 workspace: &PackageWorkspace,
1132 query: Option<&str>,
1133 registry: Option<&str>,
1134) -> Result<Vec<RegistryPackage>, PackageError> {
1135 let (_, index) = load_package_registry_in(workspace, registry)?;
1136 Ok(index
1137 .packages
1138 .into_iter()
1139 .filter(|package| registry_package_matches(package, query.unwrap_or("")))
1140 .collect())
1141}
1142
1143pub(crate) fn search_rule_package_registry_impl(
1144 query: Option<&str>,
1145 registry: Option<&str>,
1146) -> Result<Vec<RegistryPackage>, PackageError> {
1147 search_rule_package_registry_in(&PackageWorkspace::from_current_dir()?, query, registry)
1148}
1149
1150pub(crate) fn search_rule_package_registry_in(
1151 workspace: &PackageWorkspace,
1152 query: Option<&str>,
1153 registry: Option<&str>,
1154) -> Result<Vec<RegistryPackage>, PackageError> {
1155 Ok(search_package_registry_in(workspace, query, registry)?
1156 .into_iter()
1157 .filter(registry_package_is_rule_pack)
1158 .collect())
1159}
1160
1161fn registry_package_is_rule_pack(package: &RegistryPackage) -> bool {
1162 package.rule_pack.is_some()
1163}
1164
1165pub(crate) fn package_registry_info_impl(
1166 spec: &str,
1167 registry: Option<&str>,
1168) -> Result<RegistryPackageInfo, PackageError> {
1169 package_registry_info_in(&PackageWorkspace::from_current_dir()?, spec, registry)
1170}
1171
1172pub(crate) fn package_registry_info_in(
1173 workspace: &PackageWorkspace,
1174 spec: &str,
1175 registry: Option<&str>,
1176) -> Result<RegistryPackageInfo, PackageError> {
1177 let Some((name, version)) = parse_registry_package_spec(spec) else {
1178 return Err(format!(
1179 "invalid registry package name '{spec}'; use names like @burin/notion-sdk or acme-lib"
1180 )
1181 .into());
1182 };
1183 let (_, index) = load_package_registry_in(workspace, registry)?;
1184 find_registry_package_version(&index, name, version)
1185}
1186
1187pub(crate) fn registry_dependency_from_spec_in(
1188 workspace: &PackageWorkspace,
1189 spec: &str,
1190 alias: Option<&str>,
1191 registry: Option<&str>,
1192) -> Result<(String, Dependency), PackageError> {
1193 let Some((name, Some(version))) = parse_registry_package_spec(spec) else {
1194 return Err(format!(
1195 "registry dependency '{spec}' must include a version, for example {spec}@1.2.3"
1196 )
1197 .into());
1198 };
1199 let registry_source = workspace.resolve_registry_source(registry)?;
1200 let (_, index) = load_package_registry_in(workspace, registry)?;
1201 let info = if is_exact_semver(version) {
1205 find_registry_package_version(&index, name, Some(version))?
1206 } else {
1207 find_registry_package_version_matching(&index, name, version)?
1208 };
1209 let selected = info
1210 .selected_version
1211 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?;
1212 if selected.yanked {
1213 return Err(format!("{name}@{version} is yanked in the package registry").into());
1214 }
1215 let package_name = registry_package_version_alias(&info.package.name, &selected)?;
1216 let alias = alias.unwrap_or(package_name.as_str()).to_string();
1217 let resolved_version = selected.version.clone();
1218 Ok((
1219 alias.clone(),
1220 registry_dependency_table(
1221 info.package.name,
1222 selected,
1223 package_name,
1224 alias,
1225 registry_source,
1226 resolved_version,
1227 )?,
1228 ))
1229}
1230
1231fn is_exact_semver(spec: &str) -> bool {
1232 parse_registry_semver(spec).is_ok()
1233}
1234
1235pub(crate) fn registry_dependency_from_manifest_constraint_in(
1236 workspace: &PackageWorkspace,
1237 alias: &str,
1238 table: &DepTable,
1239) -> Result<Dependency, PackageError> {
1240 let requirement = table
1241 .version
1242 .as_deref()
1243 .ok_or_else(|| format!("dependency {alias} is missing `version`"))?;
1244 let registry_source = workspace.resolve_registry_source(table.registry.as_deref())?;
1245 let registry_name = table.registry_name.as_deref().unwrap_or(alias);
1246 let (_, index) = load_package_registry_in(workspace, Some(®istry_source))?;
1247 let info = find_registry_package_version_matching(&index, registry_name, requirement)?;
1248 let selected = info.selected_version.ok_or_else(|| {
1249 format!("package registry does not contain {registry_name} matching {requirement}")
1250 })?;
1251 let package_name = selected
1252 .package
1253 .clone()
1254 .or_else(|| table.package.clone())
1255 .unwrap_or_else(|| alias.to_string());
1256 let resolved_version = selected.version.clone();
1257 registry_dependency_table(
1258 registry_name.to_string(),
1259 selected,
1260 package_name,
1261 alias.to_string(),
1262 registry_source,
1263 resolved_version,
1264 )
1265}
1266
1267fn registry_package_version_alias(
1268 registry_name: &str,
1269 selected: &RegistryPackageVersion,
1270) -> Result<String, PackageError> {
1271 if let Some(package) = selected.package.clone() {
1272 return Ok(package);
1273 }
1274 if let Some(git) = selected.git.as_deref() {
1275 return derive_repo_name_from_source(&normalize_git_url(git)?);
1276 }
1277 derive_registry_alias_from_name(registry_name)
1278}
1279
1280fn derive_registry_alias_from_name(registry_name: &str) -> Result<String, PackageError> {
1281 let alias = registry_name
1282 .strip_prefix('@')
1283 .and_then(|scoped| scoped.split_once('/').map(|(_, package)| package))
1284 .unwrap_or(registry_name)
1285 .to_string();
1286 validate_package_alias(&alias)?;
1287 Ok(alias)
1288}
1289
1290fn registry_dependency_table(
1291 registry_name: String,
1292 selected: RegistryPackageVersion,
1293 package_name: String,
1294 alias: String,
1295 registry_source: String,
1296 resolved_version: String,
1297) -> Result<Dependency, PackageError> {
1298 let package = (alias != package_name).then_some(package_name);
1299 let RegistryPackageVersion {
1300 git,
1301 archive,
1302 tag,
1303 rev,
1304 branch,
1305 checksum,
1306 ..
1307 } = selected;
1308 let table = if let Some(git) = git {
1309 let git = normalize_git_url(&git)?;
1310 let rev = if tag.is_some() { None } else { rev };
1311 DepTable {
1312 git: Some(git),
1313 tag,
1314 rev,
1315 branch,
1316 package,
1317 registry: Some(registry_source),
1318 registry_name: Some(registry_name),
1322 registry_version: Some(resolved_version),
1323 ..DepTable::default()
1324 }
1325 } else if let Some(archive) = archive {
1326 DepTable {
1327 archive: Some(normalize_archive_url(&archive)?),
1328 package,
1329 checksum,
1330 registry: Some(registry_source),
1331 registry_name: Some(registry_name),
1332 registry_version: Some(resolved_version),
1333 ..DepTable::default()
1334 }
1335 } else {
1336 return Err("registry package version is missing git or archive source"
1337 .to_string()
1338 .into());
1339 };
1340 Ok(Dependency::Table(Box::new(table)))
1341}
1342
1343pub(crate) fn is_probable_shorthand_git_url(raw: &str) -> bool {
1344 !raw.contains("://")
1345 && !raw.starts_with("git@")
1346 && raw.contains('/')
1347 && raw
1348 .split('/')
1349 .next()
1350 .is_some_and(|segment| segment.contains('.'))
1351}
1352
1353pub(crate) fn normalize_git_url(raw: &str) -> Result<String, PackageError> {
1354 let trimmed = raw.trim();
1355 if trimmed.is_empty() {
1356 return Err("git URL cannot be empty".to_string().into());
1357 }
1358
1359 let candidate_path = PathBuf::from(trimmed);
1360 if candidate_path.exists() {
1361 let canonical = candidate_path
1362 .canonicalize()
1363 .map_err(|error| format!("failed to canonicalize {trimmed}: {error}"))?;
1364 let url = Url::from_file_path(canonical)
1365 .map_err(|_| format!("failed to convert {trimmed} to file:// URL"))?;
1366 return Ok(url.to_string().trim_end_matches('/').to_string());
1367 }
1368
1369 if let Some(rest) = trimmed.strip_prefix("git@") {
1370 if let Some((host, path)) = rest.split_once(':') {
1371 return Ok(format!(
1372 "ssh://git@{}/{}",
1373 host,
1374 path.trim_start_matches('/').trim_end_matches('/')
1375 ));
1376 }
1377 }
1378
1379 let with_scheme = if is_probable_shorthand_git_url(trimmed) {
1380 format!("https://{trimmed}")
1381 } else {
1382 trimmed.to_string()
1383 };
1384 let parsed =
1385 Url::parse(&with_scheme).map_err(|error| format!("invalid git URL {trimmed}: {error}"))?;
1386 let mut normalized = parsed.to_string();
1387 while normalized.ends_with('/') {
1388 normalized.pop();
1389 }
1390 if parsed.scheme() != "file" && normalized.ends_with(".git") {
1391 normalized.truncate(normalized.len() - 4);
1392 }
1393 Ok(normalized)
1394}
1395
1396pub(crate) fn derive_repo_name_from_source(source: &str) -> Result<String, PackageError> {
1397 let url = Url::parse(source).map_err(|error| format!("invalid git URL {source}: {error}"))?;
1398 let segment = url
1399 .path_segments()
1400 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
1401 .ok_or_else(|| format!("failed to derive package name from {source}"))?;
1402 Ok(segment.trim_end_matches(".git").to_string())
1403}
1404
1405pub(crate) fn parse_positional_git_spec(spec: &str) -> (&str, Option<&str>) {
1406 if let Some((source, candidate_ref)) = spec.rsplit_once('@') {
1407 if !candidate_ref.is_empty()
1408 && !candidate_ref.contains('/')
1409 && !candidate_ref.contains(':')
1410 && !source.ends_with("://")
1411 {
1412 return (source, Some(candidate_ref));
1413 }
1414 }
1415 (spec, None)
1416}
1417
1418pub(crate) fn existing_local_path_spec(spec: &str) -> Option<PathBuf> {
1419 if spec.trim().is_empty() || spec.contains("://") || spec.starts_with("git@") {
1420 return None;
1421 }
1422 let candidate = PathBuf::from(spec);
1423 if candidate.exists() {
1424 return Some(candidate);
1425 }
1426 if candidate.extension().is_none() {
1427 let with_ext = candidate.with_extension("harn");
1428 if with_ext.exists() {
1429 return Some(with_ext);
1430 }
1431 }
1432 if is_probable_shorthand_git_url(spec) {
1433 return None;
1434 }
1435 None
1436}
1437
1438pub(crate) fn package_manifest_name(path: &Path) -> Option<String> {
1439 let manifest_path = if path.is_dir() {
1440 path.join(MANIFEST)
1441 } else {
1442 path.parent()?.join(MANIFEST)
1443 };
1444 let manifest = read_manifest_from_path(&manifest_path).ok()?;
1445 manifest
1446 .package
1447 .and_then(|pkg| pkg.name)
1448 .map(|name| name.trim().to_string())
1449 .filter(|name| !name.is_empty())
1450}
1451
1452pub(crate) fn derive_package_alias_from_path(path: &Path) -> Result<String, PackageError> {
1453 if let Some(name) = package_manifest_name(path) {
1454 return Ok(name);
1455 }
1456 let fallback = if path.is_dir() {
1457 path.file_name()
1458 } else {
1459 path.file_stem()
1460 };
1461 fallback
1462 .and_then(|name| name.to_str())
1463 .map(str::trim)
1464 .filter(|name| !name.is_empty())
1465 .map(str::to_string)
1466 .ok_or_else(|| {
1467 PackageError::Registry(format!(
1468 "failed to derive package alias from {}",
1469 path.display()
1470 ))
1471 })
1472}
1473
1474pub(crate) fn is_full_git_sha(value: &str) -> bool {
1475 value.len() == 40 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
1476}
1477
1478struct HardenedGitEnv {
1479 _temp_dir: tempfile::TempDir,
1480 home: PathBuf,
1481 config_home: PathBuf,
1482 global_config: PathBuf,
1483 system_config: PathBuf,
1484}
1485
1486impl HardenedGitEnv {
1487 fn new() -> Result<Self, PackageError> {
1488 let temp_dir = tempfile::Builder::new()
1489 .prefix("harn-git-env-")
1490 .tempdir()
1491 .map_err(|error| {
1492 PackageError::Registry(format!("failed to create isolated git env: {error}"))
1493 })?;
1494 let home = temp_dir.path().join("home");
1495 let config_home = temp_dir.path().join("xdg-config");
1496 fs::create_dir_all(&home)
1497 .map_err(|error| format!("failed to create {}: {error}", home.display()))?;
1498 fs::create_dir_all(&config_home)
1499 .map_err(|error| format!("failed to create {}: {error}", config_home.display()))?;
1500 let global_config = home.join(".gitconfig");
1501 let system_config = temp_dir.path().join("gitconfig-system");
1502 Ok(Self {
1503 _temp_dir: temp_dir,
1504 home,
1505 config_home,
1506 global_config,
1507 system_config,
1508 })
1509 }
1510
1511 fn apply_to(&self, command: &mut process::Command, cwd: Option<&Path>) {
1512 if let Some(dir) = cwd {
1513 command.current_dir(dir);
1514 }
1515 command.env_clear();
1518 for name in PRESERVED_GIT_ENV {
1519 if let Some(value) = std::env::var_os(name) {
1520 command.env(name, value);
1521 }
1522 }
1523 command
1524 .env("HOME", &self.home)
1525 .env("XDG_CONFIG_HOME", &self.config_home)
1526 .env("GIT_CONFIG_GLOBAL", &self.global_config)
1527 .env("GIT_CONFIG_SYSTEM", &self.system_config)
1528 .env("GIT_CONFIG_NOSYSTEM", "1")
1529 .env("GIT_TERMINAL_PROMPT", "0");
1530 }
1531}
1532
1533pub(crate) fn git_output<I, S>(
1534 args: I,
1535 cwd: Option<&Path>,
1536) -> Result<std::process::Output, PackageError>
1537where
1538 I: IntoIterator<Item = S>,
1539 S: AsRef<OsStr>,
1540{
1541 let git_env = HardenedGitEnv::new()?;
1542 let mut command = process::Command::new("git");
1543 git_env.apply_to(&mut command, cwd);
1544 command.args(args);
1545 command
1546 .output()
1547 .map_err(|error| PackageError::Registry(format!("failed to run git: {error}")))
1548}
1549
1550pub(crate) fn resolve_git_commit(
1551 url: &str,
1552 rev: Option<&str>,
1553 tag: Option<&str>,
1554 branch: Option<&str>,
1555) -> Result<String, PackageError> {
1556 let requested = branch.or(rev).or(tag).unwrap_or("HEAD");
1557 if branch.is_none() && tag.is_none() && is_full_git_sha(requested) {
1558 return Ok(requested.to_string());
1559 }
1560
1561 let refs = if let Some(branch) = branch {
1562 vec![format!("refs/heads/{branch}")]
1563 } else if let Some(tag) = tag {
1564 vec![format!("refs/tags/{tag}^{{}}"), format!("refs/tags/{tag}")]
1565 } else if requested == "HEAD" {
1566 vec!["HEAD".to_string()]
1567 } else {
1568 vec![
1569 requested.to_string(),
1570 format!("refs/tags/{requested}^{{}}"),
1571 format!("refs/tags/{requested}"),
1572 format!("refs/heads/{requested}"),
1573 ]
1574 };
1575
1576 let output = git_output(
1577 std::iter::once("ls-remote".to_string())
1578 .chain(std::iter::once(url.to_string()))
1579 .chain(refs),
1580 None,
1581 )?;
1582 if !output.status.success() {
1583 return Err(format!(
1584 "failed to resolve git ref from {url}: {}",
1585 String::from_utf8_lossy(&output.stderr).trim()
1586 )
1587 .into());
1588 }
1589 let stdout = String::from_utf8_lossy(&output.stdout);
1590 pick_ls_remote_commit(&stdout)
1591 .map(str::to_string)
1592 .ok_or_else(|| format!("could not resolve {requested} from {url}").into())
1593}
1594
1595fn pick_ls_remote_commit(stdout: &str) -> Option<&str> {
1603 let parsed: Vec<(&str, &str)> = stdout
1604 .lines()
1605 .filter_map(|line| {
1606 let mut parts = line.split_whitespace();
1607 let sha = parts.next()?;
1608 let refname = parts.next().unwrap_or("");
1609 is_full_git_sha(sha).then_some((sha, refname))
1610 })
1611 .collect();
1612 parsed
1613 .iter()
1614 .find_map(|(sha, refname)| refname.ends_with("^{}").then_some(*sha))
1615 .or_else(|| parsed.first().map(|(sha, _)| *sha))
1616}
1617
1618pub(crate) fn clone_git_commit_to(
1619 url: &str,
1620 commit: &str,
1621 dest: &Path,
1622) -> Result<(), PackageError> {
1623 if dest.exists() {
1624 fs::remove_dir_all(dest)
1625 .map_err(|error| format!("failed to reset {}: {error}", dest.display()))?;
1626 }
1627 fs::create_dir_all(dest)
1628 .map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
1629
1630 let init = git_output(["init", "--quiet"], Some(dest))?;
1631 if !init.status.success() {
1632 return Err(format!(
1633 "failed to initialize git repo in {}: {}",
1634 dest.display(),
1635 String::from_utf8_lossy(&init.stderr).trim()
1636 )
1637 .into());
1638 }
1639
1640 let remote = git_output(["remote", "add", "origin", url], Some(dest))?;
1641 if !remote.status.success() {
1642 return Err(format!(
1643 "failed to add git remote {url}: {}",
1644 String::from_utf8_lossy(&remote.stderr).trim()
1645 )
1646 .into());
1647 }
1648
1649 let fetch = git_output(["fetch", "--depth", "1", "origin", commit], Some(dest))?;
1650 if !fetch.status.success() {
1651 let fallback_dir = dest.with_extension("full-clone");
1652 if fallback_dir.exists() {
1653 fs::remove_dir_all(&fallback_dir)
1654 .map_err(|error| format!("failed to remove {}: {error}", fallback_dir.display()))?;
1655 }
1656 let clone = git_output(
1657 ["clone", url, fallback_dir.to_string_lossy().as_ref()],
1658 None,
1659 )?;
1660 if !clone.status.success() {
1661 return Err(format!(
1662 "failed to fetch {commit} from {url}: {}",
1663 String::from_utf8_lossy(&fetch.stderr).trim()
1664 )
1665 .into());
1666 }
1667 let checkout = git_output(["checkout", commit], Some(&fallback_dir))?;
1668 if !checkout.status.success() {
1669 return Err(format!(
1670 "failed to checkout {commit} in {}: {}",
1671 fallback_dir.display(),
1672 String::from_utf8_lossy(&checkout.stderr).trim()
1673 )
1674 .into());
1675 }
1676 fs::remove_dir_all(dest)
1677 .map_err(|error| format!("failed to remove {}: {error}", dest.display()))?;
1678 fs::rename(&fallback_dir, dest).map_err(|error| {
1679 format!(
1680 "failed to move {} to {}: {error}",
1681 fallback_dir.display(),
1682 dest.display()
1683 )
1684 })?;
1685 } else {
1686 let checkout = git_output(["checkout", "--detach", "FETCH_HEAD"], Some(dest))?;
1687 if !checkout.status.success() {
1688 return Err(format!(
1689 "failed to checkout FETCH_HEAD in {}: {}",
1690 dest.display(),
1691 String::from_utf8_lossy(&checkout.stderr).trim()
1692 )
1693 .into());
1694 }
1695 }
1696
1697 let git_dir = dest.join(".git");
1698 if git_dir.exists() {
1699 fs::remove_dir_all(&git_dir)
1700 .map_err(|error| format!("failed to remove {}: {error}", git_dir.display()))?;
1701 }
1702 Ok(())
1703}
1704
1705pub(crate) fn unique_temp_dir(base: &Path, label: &str) -> Result<PathBuf, PackageError> {
1706 for _ in 0..16 {
1707 let suffix = uuid::Uuid::now_v7();
1708 let candidate = base.join(format!("{label}-{suffix}"));
1709 if !candidate.exists() {
1710 return Ok(candidate);
1711 }
1712 }
1713 Err(format!(
1714 "failed to allocate a unique temporary directory under {}",
1715 base.display()
1716 )
1717 .into())
1718}
1719
1720pub(crate) fn ensure_git_cache_populated_in(
1721 workspace: &PackageWorkspace,
1722 url: &str,
1723 source: &str,
1724 commit: &str,
1725 expected_hash: Option<&str>,
1726 refetch: bool,
1727 offline: bool,
1728) -> Result<String, PackageError> {
1729 let cache_dir = git_cache_dir_in(workspace, source, commit)?;
1730 let _lock = acquire_git_cache_lock_in(workspace, source, commit)?;
1731 if refetch && cache_dir.exists() {
1732 fs::remove_dir_all(&cache_dir)
1733 .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1734 }
1735 if cache_dir.exists() {
1736 if let Some(expected) = expected_hash {
1737 verify_content_hash_or_compute(&cache_dir, expected)?;
1738 write_cache_metadata(&cache_dir, source, commit, expected)?;
1739 return Ok(expected.to_string());
1740 }
1741 let hash = compute_content_hash(&cache_dir)?;
1742 write_cached_content_hash(&cache_dir, &hash)?;
1743 write_cache_metadata(&cache_dir, source, commit, &hash)?;
1744 return Ok(hash);
1745 }
1746
1747 if offline {
1748 return Err(format!(
1749 "package cache entry for {source} at {commit} is missing; cannot fetch in offline mode"
1750 )
1751 .into());
1752 }
1753
1754 let parent = cache_dir
1755 .parent()
1756 .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1757 fs::create_dir_all(parent)
1758 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1759 let temp_dir = unique_temp_dir(parent, "tmp")?;
1760 let populated = (|| -> Result<String, PackageError> {
1761 clone_git_commit_to(url, commit, &temp_dir)?;
1762 let hash = compute_content_hash(&temp_dir)?;
1763 if let Some(expected) = expected_hash {
1764 if hash != expected {
1765 return Err(format!(
1766 "content hash mismatch for {source} at {commit}: expected {expected}, got {hash}"
1767 )
1768 .into());
1769 }
1770 }
1771 write_cached_content_hash(&temp_dir, &hash)?;
1772 write_cache_metadata(&temp_dir, source, commit, &hash)?;
1773 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1774 format!(
1775 "failed to move {} to {}: {error}",
1776 temp_dir.display(),
1777 cache_dir.display()
1778 )
1779 })?;
1780 Ok(hash)
1781 })();
1782 let hash = match populated {
1783 Ok(hash) => hash,
1784 Err(error) => {
1785 let _ = fs::remove_dir_all(&temp_dir);
1786 return Err(error);
1787 }
1788 };
1789 Ok(hash)
1790}
1791
1792fn archive_entry_relative_path(path: &Path) -> Result<PathBuf, PackageError> {
1793 let mut out = PathBuf::new();
1794 for component in path.components() {
1795 match component {
1796 std::path::Component::Normal(value) => out.push(value),
1797 std::path::Component::CurDir => {}
1798 _ => {
1799 return Err(format!(
1800 "package archive entry must be relative and contained within the package root: {}",
1801 path.display()
1802 )
1803 .into());
1804 }
1805 }
1806 }
1807 if out.as_os_str().is_empty() {
1808 return Err("package archive entry path cannot be empty"
1809 .to_string()
1810 .into());
1811 }
1812 Ok(out)
1813}
1814
1815pub(crate) fn unpack_package_archive_bytes(bytes: &[u8], dest: &Path) -> Result<(), PackageError> {
1816 let decoder = flate2::read::GzDecoder::new(bytes);
1817 let mut archive = tar::Archive::new(decoder);
1818 let entries = archive
1819 .entries()
1820 .map_err(|error| format!("failed to read package archive: {error}"))?;
1821 let mut unpacked_bytes = 0u64;
1822 for entry in entries {
1823 let mut entry =
1824 entry.map_err(|error| format!("failed to read package archive entry: {error}"))?;
1825 let entry_type = entry.header().entry_type();
1826 let raw_path = entry
1827 .path()
1828 .map_err(|error| format!("failed to read package archive entry path: {error}"))?;
1829 let relative = archive_entry_relative_path(&raw_path)?;
1830 let target = dest.join(relative);
1831 if entry_type.is_dir() {
1832 fs::create_dir_all(&target)
1833 .map_err(|error| format!("failed to create {}: {error}", target.display()))?;
1834 } else if entry_type.is_file() {
1835 unpacked_bytes = unpacked_bytes
1836 .checked_add(entry.size())
1837 .ok_or_else(|| "package archive expanded size overflowed".to_string())?;
1838 if unpacked_bytes > PACKAGE_ARCHIVE_MAX_UNPACKED_BYTES {
1839 return Err(format!(
1840 "package archive expands above the {PACKAGE_ARCHIVE_MAX_UNPACKED_BYTES} byte limit"
1841 )
1842 .into());
1843 }
1844 if let Some(parent) = target.parent() {
1845 fs::create_dir_all(parent)
1846 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1847 }
1848 entry
1849 .unpack(&target)
1850 .map_err(|error| format!("failed to unpack {}: {error}", target.display()))?;
1851 } else {
1852 return Err(format!(
1853 "package archive entry {} has unsupported type {:?}",
1854 raw_path.display(),
1855 entry_type
1856 )
1857 .into());
1858 }
1859 }
1860 if !dest.join(MANIFEST).is_file() {
1861 return Err(format!("package archive is missing {MANIFEST} at its root").into());
1862 }
1863 Ok(())
1864}
1865
1866pub(crate) fn ensure_archive_cache_populated_in(
1867 workspace: &PackageWorkspace,
1868 url: &str,
1869 source: &str,
1870 expected_hash: &str,
1871 refetch: bool,
1872 offline: bool,
1873) -> Result<String, PackageError> {
1874 archive_cache_key(expected_hash)?;
1875 let cache_dir = archive_cache_dir_in(workspace, source, expected_hash)?;
1876 let _lock = acquire_archive_cache_lock_in(workspace, source, expected_hash)?;
1877 if refetch && cache_dir.exists() {
1878 fs::remove_dir_all(&cache_dir)
1879 .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1880 }
1881 if cache_dir.exists() {
1882 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1883 write_cache_metadata(&cache_dir, source, expected_hash, expected_hash)?;
1884 return Ok(expected_hash.to_string());
1885 }
1886 if offline {
1887 return Err(format!(
1888 "package cache entry for {source} at {expected_hash} is missing; cannot fetch in offline mode"
1889 )
1890 .into());
1891 }
1892
1893 let parent = cache_dir
1894 .parent()
1895 .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1896 fs::create_dir_all(parent)
1897 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1898 let temp_dir = unique_temp_dir(parent, "tmp")?;
1899 let populated = (|| -> Result<String, PackageError> {
1900 fs::create_dir_all(&temp_dir)
1901 .map_err(|error| format!("failed to create {}: {error}", temp_dir.display()))?;
1902 let bytes = read_package_archive_bytes(url)?;
1903 unpack_package_archive_bytes(&bytes, &temp_dir)?;
1904 let hash = compute_content_hash(&temp_dir)?;
1905 if hash != expected_hash {
1906 return Err(format!(
1907 "content hash mismatch for {source}: expected {expected_hash}, got {hash}"
1908 )
1909 .into());
1910 }
1911 write_cached_content_hash(&temp_dir, expected_hash)?;
1912 write_cache_metadata(&temp_dir, source, expected_hash, expected_hash)?;
1913 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1914 format!(
1915 "failed to move {} to {}: {error}",
1916 temp_dir.display(),
1917 cache_dir.display()
1918 )
1919 })?;
1920 Ok(expected_hash.to_string())
1921 })();
1922 match populated {
1923 Ok(hash) => Ok(hash),
1924 Err(error) => {
1925 let _ = fs::remove_dir_all(&temp_dir);
1926 Err(error)
1927 }
1928 }
1929}
1930
1931#[derive(Debug, Clone)]
1932pub(crate) struct PackageCacheEntry {
1933 path: PathBuf,
1934 kind: &'static str,
1935 source_hash: String,
1936 commit: String,
1937 metadata: Option<PackageCacheMetadata>,
1938}
1939
1940pub(crate) fn discover_package_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1941 discover_package_cache_entries_in(&PackageWorkspace::from_current_dir()?)
1942}
1943
1944pub(crate) fn discover_package_cache_entries_in(
1945 workspace: &PackageWorkspace,
1946) -> Result<Vec<PackageCacheEntry>, PackageError> {
1947 let mut entries = discover_cache_entries_for_kind(workspace, "git")?;
1948 entries.extend(discover_cache_entries_for_kind(workspace, "archive")?);
1949 entries.sort_by(|left, right| {
1950 left.kind
1951 .cmp(right.kind)
1952 .then_with(|| left.source_hash.cmp(&right.source_hash))
1953 .then_with(|| left.commit.cmp(&right.commit))
1954 });
1955 Ok(entries)
1956}
1957
1958fn discover_cache_entries_for_kind(
1959 workspace: &PackageWorkspace,
1960 kind: &'static str,
1961) -> Result<Vec<PackageCacheEntry>, PackageError> {
1962 let root = workspace.cache_root()?.join(kind);
1963 let mut entries = Vec::new();
1964 let source_dirs = match fs::read_dir(&root) {
1965 Ok(source_dirs) => source_dirs,
1966 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1967 Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1968 };
1969 for source_dir in source_dirs {
1970 let source_dir = source_dir
1971 .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1972 let source_type = source_dir
1973 .file_type()
1974 .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1975 if !source_type.is_dir() {
1976 continue;
1977 }
1978 let source_hash = source_dir.file_name().to_string_lossy().to_string();
1979 let commit_dirs = fs::read_dir(source_dir.path())
1980 .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1981 for commit_dir in commit_dirs {
1982 let commit_dir = commit_dir.map_err(|error| {
1983 format!(
1984 "failed to read {} entry: {error}",
1985 source_dir.path().display()
1986 )
1987 })?;
1988 let commit_type = commit_dir.file_type().map_err(|error| {
1989 format!("failed to stat {}: {error}", commit_dir.path().display())
1990 })?;
1991 if !commit_type.is_dir() {
1992 continue;
1993 }
1994 let commit = commit_dir.file_name().to_string_lossy().to_string();
1995 if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1996 continue;
1997 }
1998 let metadata = read_cache_metadata(&commit_dir.path())?;
1999 entries.push(PackageCacheEntry {
2000 path: commit_dir.path(),
2001 kind,
2002 source_hash: source_hash.clone(),
2003 commit,
2004 metadata,
2005 });
2006 }
2007 }
2008 entries.sort_by(|left, right| {
2009 left.source_hash
2010 .cmp(&right.source_hash)
2011 .then_with(|| left.commit.cmp(&right.commit))
2012 });
2013 Ok(entries)
2014}
2015
2016pub(crate) fn locked_package_cache_paths_in(
2017 workspace: &PackageWorkspace,
2018 lock: &LockFile,
2019) -> Result<HashSet<PathBuf>, PackageError> {
2020 let mut keep = HashSet::new();
2021 for entry in &lock.packages {
2022 validate_package_alias(&entry.name)?;
2023 if entry.source.starts_with("git+") {
2024 let commit = entry
2025 .commit
2026 .as_deref()
2027 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
2028 keep.insert(git_cache_dir_in(workspace, &entry.source, commit)?);
2029 } else if entry.source.starts_with("archive+") {
2030 let expected_hash = entry
2031 .content_hash
2032 .as_deref()
2033 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
2034 keep.insert(archive_cache_dir_in(
2035 workspace,
2036 &entry.source,
2037 expected_hash,
2038 )?);
2039 }
2040 }
2041 Ok(keep)
2042}
2043
2044pub(crate) fn verify_lock_entry_cache_in(
2045 workspace: &PackageWorkspace,
2046 entry: &LockEntry,
2047) -> Result<bool, PackageError> {
2048 validate_package_alias(&entry.name)?;
2049 if entry.source.starts_with("path+") {
2050 let path = path_from_source_uri(&entry.source)?;
2051 if !path.exists() {
2052 return Err(format!(
2053 "path dependency {} source is missing: {}",
2054 entry.name,
2055 path.display()
2056 )
2057 .into());
2058 }
2059 return Ok(false);
2060 }
2061 let expected_hash = entry
2062 .content_hash
2063 .as_deref()
2064 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
2065 let (cache_dir, cache_key) = if entry.source.starts_with("git+") {
2066 let commit = entry
2067 .commit
2068 .as_deref()
2069 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
2070 (git_cache_dir_in(workspace, &entry.source, commit)?, commit)
2071 } else if entry.source.starts_with("archive+") {
2072 (
2073 archive_cache_dir_in(workspace, &entry.source, expected_hash)?,
2074 expected_hash,
2075 )
2076 } else {
2077 return Ok(false);
2078 };
2079 if !cache_dir.is_dir() {
2080 return Err(format!(
2081 "package cache entry for {} is missing: {}",
2082 entry.name,
2083 cache_dir.display()
2084 )
2085 .into());
2086 }
2087 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
2088 match read_cache_metadata(&cache_dir)? {
2089 Some(metadata)
2090 if metadata.source == entry.source
2091 && metadata.commit == cache_key
2092 && metadata.content_hash == expected_hash => {}
2093 Some(metadata) => {
2094 return Err(format!(
2095 "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
2096 entry.name,
2097 entry.source,
2098 cache_key,
2099 expected_hash,
2100 metadata.source,
2101 metadata.commit,
2102 metadata.content_hash
2103 )
2104 .into());
2105 }
2106 None => write_cache_metadata(&cache_dir, &entry.source, cache_key, expected_hash)?,
2107 }
2108 Ok(true)
2109}
2110
2111pub(crate) fn verify_materialized_lock_entry(
2112 packages_dir: &Path,
2113 entry: &LockEntry,
2114) -> Result<bool, PackageError> {
2115 validate_package_alias(&entry.name)?;
2116 if entry.source.starts_with("path+") {
2117 let dir = packages_dir.join(&entry.name);
2118 let file = packages_dir.join(format!("{}.harn", entry.name));
2119 if !dir.exists() && !file.exists() {
2120 return Err(format!(
2121 "materialized path dependency {} is missing under {}",
2122 entry.name,
2123 packages_dir.display()
2124 )
2125 .into());
2126 }
2127 return Ok(true);
2128 }
2129 if !entry.source.starts_with("git+") && !entry.source.starts_with("archive+") {
2130 return Ok(false);
2131 }
2132 let expected_hash = entry
2133 .content_hash
2134 .as_deref()
2135 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
2136 let dest_dir = packages_dir.join(&entry.name);
2137 if !dest_dir.is_dir() {
2138 return Err(format!(
2139 "materialized package {} is missing: {}",
2140 entry.name,
2141 dest_dir.display()
2142 )
2143 .into());
2144 }
2145 verify_content_hash_or_compute(&dest_dir, expected_hash)?;
2146 Ok(true)
2147}
2148
2149pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
2150 verify_package_cache_in(&PackageWorkspace::from_current_dir()?, materialized)
2151}
2152
2153pub(crate) fn verify_package_cache_in(
2154 workspace: &PackageWorkspace,
2155 materialized: bool,
2156) -> Result<usize, PackageError> {
2157 let ctx = workspace.load_manifest_context()?;
2158 let lock = LockFile::load(&ctx.lock_path())?
2159 .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
2160 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
2161 let snapshot = materialized
2162 .then(|| current_package_snapshot(&ctx))
2163 .transpose()?;
2164 let mut verified = 0usize;
2165 for entry in &lock.packages {
2166 if verify_lock_entry_cache_in(workspace, entry)? {
2167 verified += 1;
2168 }
2169 if let Some(snapshot) = snapshot.as_ref() {
2170 if verify_materialized_lock_entry(snapshot.packages_root(), entry)? {
2171 verified += 1;
2172 }
2173 }
2174 }
2175 Ok(verified)
2176}
2177
2178pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
2179 clean_package_cache_in(&PackageWorkspace::from_current_dir()?, all)
2180}
2181
2182pub(crate) fn clean_package_cache_in(
2183 workspace: &PackageWorkspace,
2184 all: bool,
2185) -> Result<usize, PackageError> {
2186 let entries = discover_package_cache_entries_in(workspace)?;
2187 if entries.is_empty() {
2188 return Ok(0);
2189 }
2190 if all {
2191 let root = workspace.cache_root()?;
2192 for child in ["git", "archive", "locks"] {
2193 let path = root.join(child);
2194 if path.exists() {
2195 fs::remove_dir_all(&path)
2196 .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
2197 }
2198 }
2199 return Ok(entries.len());
2200 }
2201
2202 let ctx = workspace.load_manifest_context()?;
2203 let lock = LockFile::load(&ctx.lock_path())?
2204 .ok_or_else(|| format!("{LOCK_FILE} is missing; pass --all to clean every cache entry"))?;
2205 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
2206 let keep = locked_package_cache_paths_in(workspace, &lock)?;
2207 let mut removed = 0usize;
2208 for entry in entries {
2209 if keep.contains(&entry.path) {
2210 continue;
2211 }
2212 fs::remove_dir_all(&entry.path)
2213 .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
2214 removed += 1;
2215 if let Some(parent) = entry.path.parent() {
2216 let is_empty = fs::read_dir(parent)
2217 .map(|mut children| children.next().is_none())
2218 .unwrap_or(false);
2219 if is_empty {
2220 fs::remove_dir(parent)
2221 .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
2222 }
2223 }
2224 }
2225 Ok(removed)
2226}
2227
2228pub fn list_package_cache() {
2229 let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
2230 Ok((cache_root()?, discover_package_cache_entries()?))
2231 })();
2232
2233 match result {
2234 Ok((root, entries)) => {
2235 println!("Cache root: {}", root.display());
2236 if entries.is_empty() {
2237 println!("No cached packages.");
2238 return;
2239 }
2240 println!("kind\tkey\tcontent_hash\tsource\tpath");
2241 for entry in entries {
2242 let (source, content_hash) = entry
2243 .metadata
2244 .as_ref()
2245 .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
2246 .unwrap_or(("(unknown)", "(unknown)"));
2247 println!(
2248 "{}\t{}\t{}\t{}\t{}",
2249 entry.kind,
2250 entry.commit,
2251 content_hash,
2252 source,
2253 entry.path.display()
2254 );
2255 }
2256 }
2257 Err(error) => {
2258 eprintln!("error: {error}");
2259 process::exit(1);
2260 }
2261 }
2262}
2263
2264pub fn clean_package_cache(all: bool) {
2265 match clean_package_cache_impl(all) {
2266 Ok(removed) => println!("Removed {removed} cached package entries."),
2267 Err(error) => {
2268 eprintln!("error: {error}");
2269 process::exit(1);
2270 }
2271 }
2272}
2273
2274pub fn verify_package_cache(materialized: bool) {
2275 match verify_package_cache_impl(materialized) {
2276 Ok(verified) => println!("Verified {verified} package cache entries."),
2277 Err(error) => {
2278 eprintln!("error: {error}");
2279 process::exit(1);
2280 }
2281 }
2282}
2283
2284pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
2285 match search_package_registry_impl(query, registry) {
2286 Ok(packages) if json => {
2287 println!(
2288 "{}",
2289 serde_json::to_string_pretty(&packages)
2290 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
2291 );
2292 }
2293 Ok(packages) => {
2294 if packages.is_empty() {
2295 println!("No packages found.");
2296 return;
2297 }
2298 println!("name\tlatest\tharn\tcontract\tdescription");
2299 for package in packages {
2300 let latest = latest_registry_version(&package)
2301 .map(|version| version.version.as_str())
2302 .unwrap_or("-");
2303 println!(
2304 "{}\t{}\t{}\t{}\t{}",
2305 package.name,
2306 latest,
2307 package.harn.as_deref().unwrap_or("-"),
2308 package.connector_contract.as_deref().unwrap_or("-"),
2309 package.description.as_deref().unwrap_or("")
2310 );
2311 }
2312 }
2313 Err(error) => {
2314 eprintln!("error: {error}");
2315 process::exit(1);
2316 }
2317 }
2318}
2319
2320pub fn search_rule_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
2321 match search_rule_package_registry_impl(query, registry) {
2322 Ok(packages) if json => {
2323 println!(
2324 "{}",
2325 serde_json::to_string_pretty(&packages)
2326 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
2327 );
2328 }
2329 Ok(packages) => {
2330 if packages.is_empty() {
2331 println!("No rule packs found.");
2332 return;
2333 }
2334 println!("name\tlatest\tlanguages\trules\tsafety\tdescription");
2335 for package in packages {
2336 let latest = latest_registry_version(&package)
2337 .map(|version| version.version.as_str())
2338 .unwrap_or("-")
2339 .to_string();
2340 let rule_pack = package.rule_pack.unwrap_or_default();
2341 let languages = if rule_pack.languages.is_empty() {
2342 "-".to_string()
2343 } else {
2344 rule_pack.languages.join(",")
2345 };
2346 let safety = if rule_pack.safety_summary.is_empty() {
2347 "-".to_string()
2348 } else {
2349 rule_pack.safety_summary.join(",")
2350 };
2351 println!(
2352 "{}\t{}\t{}\t{}\t{}\t{}",
2353 package.name,
2354 latest,
2355 languages,
2356 rule_pack.rule_count,
2357 safety,
2358 package.description.as_deref().unwrap_or("")
2359 );
2360 }
2361 }
2362 Err(error) => {
2363 eprintln!("error: {error}");
2364 process::exit(1);
2365 }
2366 }
2367}
2368
2369pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
2370 match package_registry_info_impl(spec, registry) {
2371 Ok(info) if json => {
2372 println!(
2373 "{}",
2374 serde_json::to_string_pretty(&info)
2375 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
2376 );
2377 }
2378 Ok(info) => {
2379 let package = info.package;
2380 println!("{}", package.name);
2381 if let Some(description) = package.description.as_deref() {
2382 println!("description: {description}");
2383 }
2384 println!("repository: {}", package.repository);
2385 if let Some(license) = package.license.as_deref() {
2386 println!("license: {license}");
2387 }
2388 if let Some(harn) = package.harn.as_deref() {
2389 println!("harn: {harn}");
2390 }
2391 if let Some(contract) = package.connector_contract.as_deref() {
2392 println!("connector_contract: {contract}");
2393 }
2394 if let Some(docs) = package.docs_url.as_deref() {
2395 println!("docs: {docs}");
2396 }
2397 if let Some(checksum) = package.checksum.as_deref() {
2398 println!("checksum: {checksum}");
2399 }
2400 if let Some(provenance) = package.provenance.as_deref() {
2401 println!("provenance: {provenance}");
2402 }
2403 if !package.exports.is_empty() {
2404 println!("exports: {}", package.exports.join(", "));
2405 }
2406 if let Some(rule_pack) = package.rule_pack.as_ref() {
2407 println!("rule_pack: yes");
2408 println!("rules: {}", rule_pack.rule_count);
2409 if !rule_pack.languages.is_empty() {
2410 println!("languages: {}", rule_pack.languages.join(", "));
2411 }
2412 if !rule_pack.safety_summary.is_empty() {
2413 println!("safety: {}", rule_pack.safety_summary.join(", "));
2414 }
2415 }
2416 if let Some(version) = info.selected_version {
2417 println!("selected: {}", version.version);
2418 if let Some(git) = version.git.as_deref() {
2419 println!("git: {git}");
2420 }
2421 if let Some(archive) = version.archive.as_deref() {
2422 println!("archive: {archive}");
2423 }
2424 if let Some(rev) = version.rev.as_deref() {
2425 println!("rev: {rev}");
2426 }
2427 if let Some(branch) = version.branch.as_deref() {
2428 println!("branch: {branch}");
2429 }
2430 if let Some(package_name) = version.package.as_deref() {
2431 println!("package: {package_name}");
2432 }
2433 }
2434 if !package.versions.is_empty() {
2435 let versions = package
2436 .versions
2437 .iter()
2438 .map(|version| {
2439 if version.yanked {
2440 format!("{} (yanked)", version.version)
2441 } else {
2442 version.version.clone()
2443 }
2444 })
2445 .collect::<Vec<_>>()
2446 .join(", ");
2447 println!("versions: {versions}");
2448 }
2449 }
2450 Err(error) => {
2451 eprintln!("error: {error}");
2452 process::exit(1);
2453 }
2454 }
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459 use super::*;
2460 use crate::package::test_support::*;
2461
2462 #[test]
2463 fn windows_drive_registry_sources_are_file_paths_not_url_schemes() {
2464 let source = r"C:\Users\RUNNER~1\AppData\Local\Temp\index.toml";
2465 let path = registry_file_url_or_path(source).unwrap().unwrap();
2466 assert_eq!(path, PathBuf::from(source));
2467
2468 let archive_error = normalize_archive_url(source).unwrap_err();
2469 assert!(
2470 archive_error
2471 .to_string()
2472 .contains("package archive not found"),
2473 "expected missing Windows path to be treated as a file path, got: {archive_error}"
2474 );
2475 }
2476
2477 #[test]
2478 fn pick_ls_remote_commit_prefers_peeled_tag_over_tag_object() {
2479 let output = "\
2483963b6e8acfdf030a9b922bc5a73e010758ff47da\trefs/tags/v0.1.0\n\
2484bad580c5fbe8ede612b2748ad98606642ce2fc02\trefs/tags/v0.1.0^{}\n";
2485 assert_eq!(
2486 pick_ls_remote_commit(output),
2487 Some("bad580c5fbe8ede612b2748ad98606642ce2fc02"),
2488 );
2489 }
2490
2491 #[test]
2492 fn pick_ls_remote_commit_falls_back_to_first_match_for_lightweight_tags() {
2493 let output = "\
2494abc123abc123abc123abc123abc123abc1234567\trefs/tags/v0.0.1\n";
2495 assert_eq!(
2496 pick_ls_remote_commit(output),
2497 Some("abc123abc123abc123abc123abc123abc1234567"),
2498 );
2499 }
2500
2501 #[test]
2502 fn pick_ls_remote_commit_returns_none_on_empty_output() {
2503 assert_eq!(pick_ls_remote_commit(""), None);
2504 }
2505
2506 fn registry_package_with_versions(versions: &[(&str, bool)]) -> RegistryPackage {
2507 let entries: Vec<serde_json::Value> = versions
2508 .iter()
2509 .map(|(version, yanked)| {
2510 serde_json::json!({
2511 "version": version,
2512 "git": "https://example.com/pkg.git",
2513 "yanked": yanked,
2514 })
2515 })
2516 .collect();
2517 serde_json::from_value(serde_json::json!({
2518 "name": "@burin/pkg",
2519 "repository": "https://example.com/pkg.git",
2520 "version": entries,
2521 }))
2522 .expect("registry package fixture deserializes")
2523 }
2524
2525 #[test]
2526 fn latest_registry_version_prefers_stable_over_newer_prerelease() {
2527 let package = registry_package_with_versions(&[
2528 ("1.2.0", false),
2529 ("2.0.0-rc.1", false),
2530 ("1.3.0", false),
2531 ]);
2532 let latest = latest_registry_version(&package).expect("a version is selected");
2533 assert_eq!(
2534 latest.version, "1.3.0",
2535 "a prerelease must not shadow the highest stable release"
2536 );
2537 }
2538
2539 #[test]
2540 fn latest_registry_version_falls_back_to_prerelease_when_no_stable_exists() {
2541 let package =
2542 registry_package_with_versions(&[("0.1.0-alpha.1", false), ("0.1.0-alpha.2", false)]);
2543 let latest = latest_registry_version(&package).expect("a version is selected");
2544 assert_eq!(
2545 latest.version, "0.1.0-alpha.2",
2546 "packages with only prereleases still resolve to the highest prerelease"
2547 );
2548 }
2549
2550 #[cfg(unix)]
2551 #[test]
2552 fn hardened_git_env_scrubs_ambient_git_credentials_and_config() {
2553 let git_env = HardenedGitEnv::new().unwrap();
2554 let mut command = process::Command::new("/usr/bin/env");
2555 command
2556 .env("HOME", "/sensitive/home")
2557 .env("XDG_CONFIG_HOME", "/sensitive/config")
2558 .env("GIT_ASKPASS", "/sensitive/askpass")
2559 .env("GIT_SSH_COMMAND", "ssh -i /sensitive/key")
2560 .env("SSH_AUTH_SOCK", "/sensitive/agent.sock")
2561 .env("GIT_CONFIG_COUNT", "1")
2562 .env(
2563 "GIT_CONFIG_KEY_0",
2564 "http.https://attacker.example/.extraheader",
2565 )
2566 .env("GIT_CONFIG_VALUE_0", "Authorization: bearer secret");
2567 git_env.apply_to(&mut command, None);
2568
2569 let output = command.output().unwrap();
2570 assert!(
2571 output.status.success(),
2572 "env probe failed: {}",
2573 String::from_utf8_lossy(&output.stderr)
2574 );
2575 let stdout = String::from_utf8(output.stdout).unwrap();
2576 let vars: std::collections::BTreeMap<_, _> = stdout
2577 .lines()
2578 .filter_map(|line| line.split_once('='))
2579 .map(|(name, value)| (name.to_string(), value.to_string()))
2580 .collect();
2581
2582 assert_eq!(Path::new(&vars["HOME"]), git_env.home);
2583 assert_eq!(Path::new(&vars["XDG_CONFIG_HOME"]), git_env.config_home);
2584 assert_eq!(Path::new(&vars["GIT_CONFIG_GLOBAL"]), git_env.global_config);
2585 assert_eq!(Path::new(&vars["GIT_CONFIG_SYSTEM"]), git_env.system_config);
2586 assert_eq!(vars["GIT_CONFIG_NOSYSTEM"], "1");
2587 assert_eq!(vars["GIT_TERMINAL_PROMPT"], "0");
2588 assert!(!vars.contains_key("GIT_ASKPASS"));
2589 assert!(!vars.contains_key("GIT_SSH_COMMAND"));
2590 assert!(!vars.contains_key("SSH_AUTH_SOCK"));
2591 assert!(!vars.contains_key("GIT_CONFIG_COUNT"));
2592 assert!(!vars.contains_key("GIT_CONFIG_KEY_0"));
2593 assert!(!vars.contains_key("GIT_CONFIG_VALUE_0"));
2594 }
2595
2596 #[test]
2597 fn compute_content_hash_ignores_git_and_hash_marker() {
2598 let tmp = tempfile::tempdir().unwrap();
2599 let root = tmp.path();
2600 fs::create_dir_all(root.join(".git")).unwrap();
2601 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2602 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
2603 fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
2604 fs::write(
2605 root.join("lib.harn"),
2606 "pub fn value() -> number { return 1 }\n",
2607 )
2608 .unwrap();
2609 let first = compute_content_hash(root).unwrap();
2610 fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
2611 fs::write(root.join(".gitignore"), "changed\n").unwrap();
2612 fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
2613 let second = compute_content_hash(root).unwrap();
2614 assert_eq!(first, second);
2615 }
2616
2617 #[test]
2618 fn package_cache_verify_detects_tampering_even_with_stale_marker() {
2619 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2620 let project_tmp = tempfile::tempdir().unwrap();
2621 let root = project_tmp.path();
2622 let workspace = TestWorkspace::new(root);
2623 fs::create_dir_all(root.join(".git")).unwrap();
2624 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2625 fs::write(
2626 root.join(MANIFEST),
2627 format!(
2628 r#"
2629 [package]
2630 name = "workspace"
2631 version = "0.1.0"
2632
2633 [dependencies]
2634 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2635 "#
2636 ),
2637 )
2638 .unwrap();
2639
2640 install_packages_in(workspace.env(), false, None, false).unwrap();
2641 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2642 let entry = lock.find("acme-lib").unwrap();
2643 let cache_dir = git_cache_dir_in(
2644 workspace.env(),
2645 &entry.source,
2646 entry.commit.as_deref().unwrap(),
2647 )
2648 .unwrap();
2649 fs::write(
2650 cache_dir.join("lib.harn"),
2651 "pub fn value() { return \"pwned\" }\n",
2652 )
2653 .unwrap();
2654
2655 let error = verify_package_cache_in(workspace.env(), false).unwrap_err();
2656 assert!(error.to_string().contains("content hash mismatch"));
2657 }
2658
2659 #[test]
2660 fn package_cache_clean_all_removes_cached_git_entries() {
2661 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2662 let project_tmp = tempfile::tempdir().unwrap();
2663 let root = project_tmp.path();
2664 let workspace = TestWorkspace::new(root);
2665 fs::create_dir_all(root.join(".git")).unwrap();
2666 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2667 fs::write(
2668 root.join(MANIFEST),
2669 format!(
2670 r#"
2671 [package]
2672 name = "workspace"
2673 version = "0.1.0"
2674
2675 [dependencies]
2676 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2677 "#
2678 ),
2679 )
2680 .unwrap();
2681
2682 install_packages_in(workspace.env(), false, None, false).unwrap();
2683 assert_eq!(
2684 discover_package_cache_entries_in(workspace.env())
2685 .unwrap()
2686 .len(),
2687 1
2688 );
2689
2690 let removed = clean_package_cache_in(workspace.env(), true).unwrap();
2691 assert_eq!(removed, 1);
2692 assert!(discover_package_cache_entries_in(workspace.env())
2693 .unwrap()
2694 .is_empty());
2695 }
2696
2697 #[test]
2698 fn registry_index_search_and_info_use_local_file_without_network() {
2699 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2700 let project_tmp = tempfile::tempdir().unwrap();
2701 let root = project_tmp.path();
2702 let workspace = TestWorkspace::new(root);
2703 let registry_path = root.join("index.toml");
2704 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2705 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2706 fs::create_dir_all(root.join(".git")).unwrap();
2707 fs::write(
2708 root.join(MANIFEST),
2709 r#"
2710 [package]
2711 name = "workspace"
2712 version = "0.1.0"
2713 "#,
2714 )
2715 .unwrap();
2716
2717 let matches = search_package_registry_in(
2718 workspace.env(),
2719 Some("acme"),
2720 Some(registry_path.to_string_lossy().as_ref()),
2721 )
2722 .unwrap();
2723 assert_eq!(matches.len(), 1);
2724 assert_eq!(matches[0].name, "@burin/acme-lib");
2725 assert_eq!(
2726 matches[0].harn.as_deref(),
2727 Some(crate::package::current_harn_range_example().as_str())
2728 );
2729 assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
2730 assert_eq!(matches[0].exports, vec!["lib"]);
2731
2732 let info = package_registry_info_in(
2733 workspace.env(),
2734 "@burin/acme-lib@1.0.0",
2735 Some(registry_path.to_string_lossy().as_ref()),
2736 )
2737 .unwrap();
2738 assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
2739 assert_eq!(
2740 info.selected_version
2741 .as_ref()
2742 .and_then(|version| version.git.as_deref()),
2743 Some(git.as_str())
2744 );
2745 }
2746
2747 #[test]
2748 fn rule_registry_search_filters_to_rule_pack_metadata() {
2749 let project_tmp = tempfile::tempdir().unwrap();
2750 let root = project_tmp.path();
2751 let workspace = TestWorkspace::new(root);
2752 let registry_path = root.join("index.toml");
2753 fs::write(
2754 ®istry_path,
2755 r#"
2756version = 1
2757
2758[[package]]
2759name = "@acme/plain"
2760description = "Plain package"
2761repository = "https://github.com/acme/plain"
2762
2763[[package.version]]
2764version = "1.0.0"
2765git = "https://github.com/acme/plain"
2766tag = "v1.0.0"
2767
2768[[package]]
2769name = "@acme/rules"
2770description = "TypeScript and Rust cleanup rules"
2771repository = "https://github.com/acme/rules"
2772
2773[package.rule_pack]
2774rule_count = 3
2775languages = ["typescript", "rust", "typescript"]
2776safety_summary = ["no-fix:1", "behavior-preserving:2"]
2777
2778[[package.version]]
2779version = "1.0.0"
2780git = "https://github.com/acme/rules"
2781tag = "v1.0.0"
2782"#,
2783 )
2784 .unwrap();
2785 fs::write(root.join(MANIFEST), "[package]\nname = \"workspace\"\n").unwrap();
2786
2787 let matches = search_rule_package_registry_in(
2788 workspace.env(),
2789 Some("rust"),
2790 Some(registry_path.to_string_lossy().as_ref()),
2791 )
2792 .unwrap();
2793
2794 assert_eq!(matches.len(), 1);
2795 assert_eq!(matches[0].name, "@acme/rules");
2796 let metadata = matches[0].rule_pack.as_ref().expect("rule pack metadata");
2797 assert_eq!(metadata.rule_count, 3);
2798 assert_eq!(metadata.languages, vec!["rust", "typescript"]);
2799 assert_eq!(
2800 metadata.safety_summary,
2801 vec!["behavior-preserving:2", "no-fix:1"]
2802 );
2803 }
2804
2805 #[test]
2806 fn add_registry_dependency_preserves_provenance_in_manifest_and_lock() {
2807 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2808 let project_tmp = tempfile::tempdir().unwrap();
2809 let root = project_tmp.path();
2810 let registry_path = root.join("index.toml");
2811 let workspace =
2812 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2813 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2814 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2815 fs::create_dir_all(root.join(".git")).unwrap();
2816 fs::write(
2817 root.join(MANIFEST),
2818 r#"
2819 [package]
2820 name = "workspace"
2821 version = "0.1.0"
2822 "#,
2823 )
2824 .unwrap();
2825
2826 add_package_to(
2827 workspace.env(),
2828 "@burin/acme-lib@1.0.0",
2829 None,
2830 None,
2831 None,
2832 None,
2833 None,
2834 None,
2835 None,
2836 )
2837 .unwrap();
2838
2839 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2840 assert!(
2841 manifest.contains(&format!("git = \"{git}\"")),
2842 "registry install must record the resolved git URL: {manifest}"
2843 );
2844 assert!(
2845 manifest.contains("tag = \"v1.0.0\""),
2846 "registry install must pin the resolved tag: {manifest}"
2847 );
2848 assert!(
2849 manifest.contains("registry_name = \"@burin/acme-lib\""),
2850 "registry install must preserve the registry-side package name: {manifest}"
2851 );
2852 assert!(
2853 manifest.contains("registry_version = \"1.0.0\""),
2854 "registry install must preserve the requested registry version: {manifest}"
2855 );
2856 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2857 let entry = lock.find("acme-lib").unwrap();
2858 assert_eq!(entry.source, format!("git+{git}"));
2859 let registry = entry
2860 .registry
2861 .as_ref()
2862 .expect("registry-added entry should carry registry provenance");
2863 assert_eq!(registry.name, "@burin/acme-lib");
2864 assert_eq!(registry.version, "1.0.0");
2865 assert!(current_packages_dir(root)
2866 .join("acme-lib")
2867 .join("lib.harn")
2868 .is_file());
2869 }
2870
2871 #[test]
2872 fn add_registry_dependency_accepts_bare_alias_and_semver_range() {
2873 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2877 let project_tmp = tempfile::tempdir().unwrap();
2878 let root = project_tmp.path();
2879 let registry_path = root.join("index.toml");
2880 let workspace =
2881 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2882 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2883 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2884 fs::create_dir_all(root.join(".git")).unwrap();
2885 fs::write(
2886 root.join(MANIFEST),
2887 r#"
2888 [package]
2889 name = "workspace"
2890 version = "0.1.0"
2891 "#,
2892 )
2893 .unwrap();
2894
2895 add_package_to(
2897 workspace.env(),
2898 "acme-lib@^1",
2899 None,
2900 None,
2901 None,
2902 None,
2903 None,
2904 None,
2905 None,
2906 )
2907 .unwrap();
2908
2909 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2910 assert!(
2911 manifest.contains("registry_name = \"@burin/acme-lib\""),
2912 "bare-alias add must record the canonical scoped registry name: {manifest}"
2913 );
2914 assert!(
2915 manifest.contains("registry_version = \"1.0.0\""),
2916 "semver range must resolve to the highest matching exact version: {manifest}"
2917 );
2918 }
2919
2920 #[test]
2921 fn registry_index_accepts_archive_versions_and_requires_checksums() {
2922 let content = r#"
2923version = 1
2924
2925[[package]]
2926name = "@acme/rules"
2927repository = "https://github.com/acme/rules"
2928
2929[[package.version]]
2930version = "1.0.0"
2931archive = "https://cdn.example.test/acme-rules-1.0.0.tar.gz"
2932package = "acme-rules"
2933checksum = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
2934"#;
2935 let index = parse_package_registry_index("fixture", content).unwrap();
2936 assert_eq!(index.packages[0].versions[0].git, None);
2937 assert_eq!(
2938 index.packages[0].versions[0].archive.as_deref(),
2939 Some("https://cdn.example.test/acme-rules-1.0.0.tar.gz")
2940 );
2941
2942 let missing_checksum = r#"
2943version = 1
2944
2945[[package]]
2946name = "@acme/rules"
2947repository = "https://github.com/acme/rules"
2948
2949[[package.version]]
2950version = "1.0.0"
2951archive = "https://cdn.example.test/acme-rules-1.0.0.tar.gz"
2952"#;
2953 let error = parse_package_registry_index("fixture", missing_checksum).unwrap_err();
2954 assert!(error.to_string().contains("must specify checksum"));
2955 }
2956
2957 #[test]
2958 fn registry_index_rejects_invalid_names_and_duplicate_versions() {
2959 let content = r#"
2960 version = 1
2961
2962 [[package]]
2963 name = "@bad/"
2964 repository = "https://github.com/acme/acme-lib"
2965
2966 [[package.version]]
2967 version = "1.0.0"
2968 git = "https://github.com/acme/acme-lib"
2969 rev = "v1.0.0"
2970 "#;
2971 let error = parse_package_registry_index("fixture", content).unwrap_err();
2972 assert!(error.to_string().contains("invalid package name"));
2973
2974 let content = r#"
2975 version = 1
2976
2977 [[package]]
2978 name = "@burin/acme-lib"
2979 repository = "https://github.com/acme/acme-lib"
2980
2981 [[package.version]]
2982 version = "1.0.0"
2983 git = "https://github.com/acme/acme-lib"
2984 rev = "v1.0.0"
2985
2986 [[package.version]]
2987 version = "1.0.0"
2988 git = "https://github.com/acme/acme-lib"
2989 rev = "v1.0.0"
2990 "#;
2991 let error = parse_package_registry_index("fixture", content).unwrap_err();
2992 assert!(error.to_string().contains("more than once"));
2993 }
2994}