Skip to main content

harn_cli/package/
registry.rs

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    // `reqwest::blocking` builds its own current-thread tokio runtime and
584    // panics if dropped from inside an already-running tokio runtime — which
585    // is exactly what `harn add` / `harn install` do today. Hop onto a fresh
586    // OS thread so the blocking client's lifetime is fully outside any
587    // ambient runtime.
588    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    // Match cargo/npm: a bare `harn add foo` (no constraint) resolves the
955    // highest *stable* release. Prereleases are only considered when the
956    // package has published nothing stable yet, so an `x.y.z-rc.1` tag never
957    // shadows a real release.
958    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
1039/// Look up a registry package by either its scoped registry name
1040/// (`@burin/notion-sdk`) or any `[[package.version]].package` alias
1041/// (`notion-sdk-harn`). Bare-name lookup falls back to the alias so
1042/// `harn add notion-sdk-harn@0.1.0` works the same as the scoped form.
1043fn 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    // Accept both exact versions (`@1.2.3`) and semver constraints
1202    // (`@^0.1`, `@~1.4`, `@>=1,<2`). The latter resolve to the highest
1203    // matching unyanked entry.
1204    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(&registry_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            // Store the canonical scoped registry name (e.g. `@burin/notion-sdk`)
1319            // even when the user typed the bare alias (`notion-sdk-harn`) so
1320            // re-resolves stay anchored to the same registry row.
1321            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        // Registry git URLs are untrusted input, so fetches must not inherit
1516        // user Git config, credential helpers, SSH agents, or askpass hooks.
1517        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
1595/// Pick the commit SHA from `git ls-remote` output.
1596///
1597/// Annotated tags surface as two refs: `refs/tags/X` (the tag object) and
1598/// `refs/tags/X^{}` (the commit the tag points at). Prefer the peeled form so
1599/// the lockfile records the commit SHA, not the tag-object SHA — checking out
1600/// the tag object still recovers the commit, but the SHA recorded in the lock
1601/// is less surprising and round-trips through normal git commands.
1602fn 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        // Real-world example from notion-sdk-harn v0.1.0: the tag is
2480        // annotated, so ls-remote returns both the tag-object SHA and the
2481        // commit it points at.
2482        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(&registry_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            &registry_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(&registry_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        // Covers the literal acceptance from the free-tier package-manager
2874        // epic (harn#2157): `harn add notion-sdk-harn@^0.1` should resolve
2875        // even though the registry-side name is `@burin/notion-sdk`.
2876        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(&registry_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        // Bare alias + semver range. Highest matching unyanked version wins.
2896        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}