Skip to main content

harn_cli/package/
registry.rs

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