Skip to main content

harn_cli/package/
lockfile.rs

1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub(crate) struct LockFile {
6    pub(crate) version: u32,
7    #[serde(default, rename = "package")]
8    pub(crate) packages: Vec<LockEntry>,
9}
10
11impl Default for LockFile {
12    fn default() -> Self {
13        Self {
14            version: LOCK_FILE_VERSION,
15            packages: Vec::new(),
16        }
17    }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub(crate) struct LockEntry {
22    pub(crate) name: String,
23    pub(crate) source: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub(crate) rev_request: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub(crate) commit: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub(crate) content_hash: Option<String>,
30}
31
32impl LockFile {
33    pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
34        let content = match fs::read_to_string(path) {
35            Ok(s) => s,
36            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
37            Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
38        };
39
40        match toml::from_str::<Self>(&content) {
41            Ok(mut lock) => {
42                if lock.version != LOCK_FILE_VERSION {
43                    return Err(format!(
44                        "unsupported {} version {} (expected {})",
45                        path.display(),
46                        lock.version,
47                        LOCK_FILE_VERSION
48                    )
49                    .into());
50                }
51                lock.sort_entries();
52                Ok(Some(lock))
53            }
54            Err(_) => {
55                let legacy = toml::from_str::<LegacyLockFile>(&content)
56                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
57                let mut lock = Self {
58                    version: LOCK_FILE_VERSION,
59                    packages: legacy
60                        .packages
61                        .into_iter()
62                        .map(|entry| LockEntry {
63                            name: entry.name,
64                            source: entry
65                                .path
66                                .map(|path| format!("path+{path}"))
67                                .or_else(|| entry.git.map(|git| format!("git+{git}")))
68                                .unwrap_or_default(),
69                            rev_request: entry.rev_request.or(entry.tag),
70                            commit: entry.commit,
71                            content_hash: None,
72                        })
73                        .collect(),
74                };
75                lock.sort_entries();
76                Ok(Some(lock))
77            }
78        }
79    }
80
81    fn save(&self, path: &Path) -> Result<(), PackageError> {
82        let mut normalized = self.clone();
83        normalized.version = LOCK_FILE_VERSION;
84        normalized.sort_entries();
85        let body = toml::to_string_pretty(&normalized)
86            .map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
87        let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
88        out.push_str(&body);
89        harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
90            PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
91        })
92    }
93
94    pub(crate) fn sort_entries(&mut self) {
95        self.packages
96            .sort_by(|left, right| left.name.cmp(&right.name));
97    }
98
99    pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
100        self.packages.iter().find(|entry| entry.name == name)
101    }
102
103    fn replace(&mut self, entry: LockEntry) {
104        if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
105            *existing = entry;
106        } else {
107            self.packages.push(entry);
108        }
109        self.sort_entries();
110    }
111
112    fn remove(&mut self, name: &str) {
113        self.packages.retain(|entry| entry.name != name);
114    }
115}
116
117#[derive(Debug, Deserialize)]
118pub(crate) struct LegacyLockFile {
119    #[serde(default, rename = "package")]
120    packages: Vec<LegacyLockEntry>,
121}
122
123#[derive(Debug, Deserialize)]
124pub(crate) struct LegacyLockEntry {
125    pub(crate) name: String,
126    #[serde(default)]
127    git: Option<String>,
128    #[serde(default)]
129    tag: Option<String>,
130    #[serde(default)]
131    pub(crate) rev_request: Option<String>,
132    #[serde(default)]
133    pub(crate) commit: Option<String>,
134    #[serde(default)]
135    path: Option<String>,
136}
137
138pub(crate) fn compatible_locked_entry(
139    alias: &str,
140    dependency: &Dependency,
141    lock: &LockEntry,
142    manifest_dir: &Path,
143) -> Result<bool, PackageError> {
144    if lock.name != alias {
145        return Ok(false);
146    }
147    if let Some(path) = dependency.local_path() {
148        let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
149        return Ok(lock.source == source);
150    }
151    if let Some(url) = dependency.git_url() {
152        let source = format!("git+{}", normalize_git_url(url)?);
153        let requested = dependency
154            .branch()
155            .map(str::to_string)
156            .or_else(|| dependency.rev().map(str::to_string));
157        return Ok(lock.source == source
158            && lock.rev_request == requested
159            && lock.commit.is_some()
160            && lock.content_hash.is_some());
161    }
162    Ok(false)
163}
164
165#[derive(Debug, Clone)]
166pub(crate) struct PendingDependency {
167    alias: String,
168    dependency: Dependency,
169    manifest_dir: PathBuf,
170    parent: Option<String>,
171    parent_is_git: bool,
172}
173
174pub(crate) fn git_rev_request(
175    alias: &str,
176    dependency: &Dependency,
177) -> Result<String, PackageError> {
178    dependency
179        .branch()
180        .or_else(|| dependency.rev())
181        .map(str::to_string)
182        .ok_or_else(|| {
183            PackageError::Lockfile(format!(
184                "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or add `rev = \"...\"` to {MANIFEST}"
185            ))
186        })
187}
188
189pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
190    if source.is_dir() {
191        return Some(source.to_path_buf());
192    }
193    source.parent().map(Path::to_path_buf)
194}
195
196pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
197    let manifest_path = dir.join(MANIFEST);
198    if !manifest_path.exists() {
199        return Ok(None);
200    }
201    read_manifest_from_path(&manifest_path).map(Some)
202}
203
204pub(crate) fn dependency_conflict_message(
205    existing: &LockEntry,
206    candidate: &LockEntry,
207) -> PackageError {
208    PackageError::Lockfile(format!(
209        "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
210        candidate.name, existing.source, candidate.source
211    ))
212}
213
214pub(crate) fn replace_lock_entry(
215    lock: &mut LockFile,
216    candidate: LockEntry,
217) -> Result<bool, PackageError> {
218    validate_package_alias(&candidate.name)?;
219    if let Some(existing) = lock.find(&candidate.name) {
220        if existing == &candidate {
221            return Ok(false);
222        }
223        return Err(dependency_conflict_message(existing, &candidate));
224    }
225    lock.replace(candidate);
226    Ok(true)
227}
228
229pub(crate) fn enqueue_manifest_dependencies(
230    pending: &mut Vec<PendingDependency>,
231    manifest: Manifest,
232    manifest_dir: PathBuf,
233    parent: String,
234    parent_is_git: bool,
235) {
236    let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
237    aliases.sort();
238    for alias in aliases.into_iter().rev() {
239        if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
240            pending.push(PendingDependency {
241                alias,
242                dependency,
243                manifest_dir: manifest_dir.clone(),
244                parent: Some(parent.clone()),
245                parent_is_git,
246            });
247        }
248    }
249}
250
251pub(crate) fn build_lockfile(
252    ctx: &ManifestContext,
253    existing: Option<&LockFile>,
254    refresh_alias: Option<&str>,
255    refresh_all: bool,
256    allow_resolve: bool,
257    offline: bool,
258) -> Result<LockFile, PackageError> {
259    if manifest_has_git_dependencies(&ctx.manifest) {
260        ensure_git_available()?;
261    }
262
263    let mut lock = LockFile::default();
264    let mut pending: Vec<PendingDependency> = Vec::new();
265    let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
266    aliases.sort();
267    for alias in aliases.into_iter().rev() {
268        let dependency = ctx
269            .manifest
270            .dependencies
271            .get(&alias)
272            .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
273            .clone();
274        pending.push(PendingDependency {
275            alias,
276            dependency,
277            manifest_dir: ctx.dir.clone(),
278            parent: None,
279            parent_is_git: false,
280        });
281    }
282
283    while let Some(next) = pending.pop() {
284        let alias = next.alias;
285        validate_package_alias(&alias)?;
286        let dependency = next.dependency;
287        if dependency.local_path().is_some() && next.parent_is_git {
288            let parent = next.parent.as_deref().unwrap_or("a git package");
289            return Err(format!(
290                "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside git-installed packages; publish {alias} as a git dependency with `rev` or `branch`"
291            ).into());
292        }
293        if dependency.git_url().is_some() {
294            ensure_git_available()?;
295            git_rev_request(&alias, &dependency)?;
296        }
297        let refresh = refresh_all || refresh_alias == Some(alias.as_str());
298        if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
299            if !refresh
300                && compatible_locked_entry(&alias, &dependency, existing_lock, &next.manifest_dir)?
301            {
302                let mut entry = existing_lock.clone();
303                if entry.source.starts_with("git+") && entry.content_hash.is_none() {
304                    let url = entry.source.trim_start_matches("git+");
305                    let commit = entry
306                        .commit
307                        .as_deref()
308                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
309                    entry.content_hash = Some(ensure_git_cache_populated(
310                        url,
311                        &entry.source,
312                        commit,
313                        None,
314                        false,
315                        offline,
316                    )?);
317                }
318                let inserted = replace_lock_entry(&mut lock, entry.clone())?;
319                if entry.source.starts_with("git+") {
320                    let url = entry.source.trim_start_matches("git+");
321                    let commit = entry
322                        .commit
323                        .as_deref()
324                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
325                    let expected_hash = entry
326                        .content_hash
327                        .as_deref()
328                        .ok_or_else(|| format!("missing content hash for {alias}"))?;
329                    ensure_git_cache_populated(
330                        url,
331                        &entry.source,
332                        commit,
333                        Some(expected_hash),
334                        false,
335                        offline,
336                    )?;
337                    if inserted {
338                        let cache_dir = git_cache_dir(&entry.source, commit)?;
339                        if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
340                            enqueue_manifest_dependencies(
341                                &mut pending,
342                                manifest,
343                                cache_dir,
344                                alias,
345                                true,
346                            );
347                        }
348                    }
349                } else if inserted && entry.source.starts_with("path+") {
350                    let source = path_from_source_uri(&entry.source)?;
351                    if let Some(manifest_dir) = dependency_manifest_dir(&source) {
352                        if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
353                            enqueue_manifest_dependencies(
354                                &mut pending,
355                                manifest,
356                                manifest_dir,
357                                alias,
358                                false,
359                            );
360                        }
361                    }
362                }
363                continue;
364            }
365        }
366
367        if !allow_resolve {
368            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
369        }
370
371        if let Some(path) = dependency.local_path() {
372            let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
373            let package_alias = alias.clone();
374            let entry = LockEntry {
375                name: alias.clone(),
376                source: path_source_uri(&source)?,
377                rev_request: None,
378                commit: None,
379                content_hash: None,
380            };
381            let inserted = replace_lock_entry(&mut lock, entry)?;
382            if inserted {
383                if let Some(manifest_dir) = dependency_manifest_dir(&source) {
384                    if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
385                        enqueue_manifest_dependencies(
386                            &mut pending,
387                            manifest,
388                            manifest_dir,
389                            package_alias,
390                            false,
391                        );
392                    }
393                }
394            }
395            continue;
396        }
397
398        if let Some(url) = dependency.git_url() {
399            let rev_request = git_rev_request(&alias, &dependency)?;
400            let normalized_url = normalize_git_url(url)?;
401            let source = format!("git+{normalized_url}");
402            let commit =
403                resolve_git_commit(&normalized_url, dependency.rev(), dependency.branch())?;
404            let content_hash = ensure_git_cache_populated(
405                &normalized_url,
406                &source,
407                &commit,
408                None,
409                false,
410                offline,
411            )?;
412            let entry = LockEntry {
413                name: alias.clone(),
414                source: source.clone(),
415                rev_request: Some(rev_request),
416                commit: Some(commit.clone()),
417                content_hash: Some(content_hash),
418            };
419            let inserted = replace_lock_entry(&mut lock, entry)?;
420            if inserted {
421                let cache_dir = git_cache_dir(&source, &commit)?;
422                if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
423                    enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
424                }
425            }
426            continue;
427        }
428
429        return Err(format!("dependency {alias} is missing a git or path source").into());
430    }
431    Ok(lock)
432}
433
434pub(crate) fn materialize_dependencies_from_lock(
435    ctx: &ManifestContext,
436    lock: &LockFile,
437    refetch: Option<&str>,
438    offline: bool,
439) -> Result<usize, PackageError> {
440    let packages_dir = ctx.packages_dir();
441    fs::create_dir_all(&packages_dir)
442        .map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;
443
444    let mut installed = 0usize;
445    for entry in &lock.packages {
446        let alias = &entry.name;
447        validate_package_alias(alias)?;
448        if entry.source.starts_with("path+") {
449            let source = path_from_source_uri(&entry.source)?;
450            materialize_path_dependency(&source, &packages_dir, alias)?;
451            installed += 1;
452            continue;
453        }
454
455        let commit = entry
456            .commit
457            .as_deref()
458            .ok_or_else(|| format!("missing locked commit for {alias}"))?;
459        let expected_hash = entry
460            .content_hash
461            .as_deref()
462            .ok_or_else(|| format!("missing content hash for {alias}"))?;
463        let source = entry.source.clone();
464        let url = source.trim_start_matches("git+");
465        let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
466        ensure_git_cache_populated(
467            url,
468            &source,
469            commit,
470            Some(expected_hash),
471            refetch_this,
472            offline,
473        )?;
474        let cache_dir = git_cache_dir(&source, commit)?;
475        let dest_dir = packages_dir.join(alias);
476        if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
477            remove_materialized_package(&packages_dir, alias)?;
478            copy_dir_recursive(&cache_dir, &dest_dir)?;
479            write_cached_content_hash(&dest_dir, expected_hash)?;
480        }
481        installed += 1;
482    }
483    Ok(installed)
484}
485
486pub(crate) fn validate_lock_matches_manifest(
487    ctx: &ManifestContext,
488    lock: &LockFile,
489) -> Result<(), PackageError> {
490    for (alias, dependency) in &ctx.manifest.dependencies {
491        validate_package_alias(alias)?;
492        let entry = lock.find(alias).ok_or_else(|| {
493            format!(
494                "{} is missing an entry for {alias}",
495                ctx.lock_path().display()
496            )
497        })?;
498        if !compatible_locked_entry(alias, dependency, entry, &ctx.dir)? {
499            return Err(format!(
500                "{} is out of date for {alias}; run `harn install`",
501                ctx.lock_path().display()
502            )
503            .into());
504        }
505    }
506    Ok(())
507}
508
509pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
510    let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
511        return Ok(());
512    };
513    if manifest.dependencies.is_empty() {
514        return Ok(());
515    }
516    let ctx = ManifestContext { manifest, dir };
517    let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
518        format!(
519            "{} is missing; run `harn install`",
520            ctx.lock_path().display()
521        )
522    })?;
523    validate_lock_matches_manifest(&ctx, &lock)?;
524    materialize_dependencies_from_lock(&ctx, &lock, None, false)?;
525    Ok(())
526}
527
528pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
529    let start = lines
530        .iter()
531        .position(|line| line.trim() == "[dependencies]")?;
532    let end = lines
533        .iter()
534        .enumerate()
535        .skip(start + 1)
536        .find(|(_, line)| line.trim_start().starts_with('['))
537        .map(|(index, _)| index)
538        .unwrap_or(lines.len());
539    Some((start, end))
540}
541
542pub(crate) fn render_dependency_line(
543    alias: &str,
544    dependency: &Dependency,
545) -> Result<String, PackageError> {
546    validate_package_alias(alias)?;
547    match dependency {
548        Dependency::Path(path) => Ok(format!(
549            "{alias} = {{ path = {} }}",
550            toml_string_literal(path)?
551        )),
552        Dependency::Table(table) => {
553            let mut fields = Vec::new();
554            if let Some(path) = table.path.as_deref() {
555                fields.push(format!("path = {}", toml_string_literal(path)?));
556            }
557            if let Some(git) = table.git.as_deref() {
558                fields.push(format!("git = {}", toml_string_literal(git)?));
559            }
560            if let Some(branch) = table.branch.as_deref() {
561                fields.push(format!("branch = {}", toml_string_literal(branch)?));
562            } else if let Some(rev) = table.rev.as_deref().or(table.tag.as_deref()) {
563                fields.push(format!("rev = {}", toml_string_literal(rev)?));
564            }
565            if let Some(package) = table.package.as_deref() {
566                fields.push(format!("package = {}", toml_string_literal(package)?));
567            }
568            Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
569        }
570    }
571}
572
573pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
574    if manifest_path.exists() {
575        return fs::read_to_string(manifest_path).map_err(|error| {
576            PackageError::Lockfile(format!(
577                "failed to read {}: {error}",
578                manifest_path.display()
579            ))
580        });
581    }
582    Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
583}
584
585pub(crate) fn upsert_dependency_in_manifest(
586    manifest_path: &Path,
587    alias: &str,
588    dependency: &Dependency,
589) -> Result<(), PackageError> {
590    let content = ensure_manifest_exists(manifest_path)?;
591    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
592    if dependency_section_bounds(&lines).is_none() {
593        if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
594            lines.push(String::new());
595        }
596        lines.push("[dependencies]".to_string());
597    }
598    let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
599        format!(
600            "failed to locate [dependencies] in {}",
601            manifest_path.display()
602        )
603    })?;
604    let rendered = render_dependency_line(alias, dependency)?;
605    if let Some((index, _)) = lines
606        .iter()
607        .enumerate()
608        .skip(start + 1)
609        .take(end - start - 1)
610        .find(|(_, line)| {
611            line.split('=')
612                .next()
613                .is_some_and(|key| key.trim() == alias)
614        })
615    {
616        lines[index] = rendered;
617    } else {
618        lines.insert(end, rendered);
619    }
620    write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
621}
622
623pub(crate) fn remove_dependency_from_manifest(
624    manifest_path: &Path,
625    alias: &str,
626) -> Result<bool, PackageError> {
627    let content = fs::read_to_string(manifest_path)
628        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
629    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
630    let Some((start, end)) = dependency_section_bounds(&lines) else {
631        return Ok(false);
632    };
633    let mut removed = false;
634    lines = lines
635        .into_iter()
636        .enumerate()
637        .filter_map(|(index, line)| {
638            if index <= start || index >= end {
639                return Some(line);
640            }
641            let matches = line
642                .split('=')
643                .next()
644                .is_some_and(|key| key.trim() == alias);
645            if matches {
646                removed = true;
647                None
648            } else {
649                Some(line)
650            }
651        })
652        .collect();
653    if removed {
654        write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
655    }
656    Ok(removed)
657}
658
659pub(crate) fn install_packages_impl(
660    frozen: bool,
661    refetch: Option<&str>,
662    offline: bool,
663) -> Result<usize, PackageError> {
664    let ctx = load_current_manifest_context()?;
665    let existing = LockFile::load(&ctx.lock_path())?;
666    if ctx.manifest.dependencies.is_empty() {
667        if !frozen {
668            LockFile::default().save(&ctx.lock_path())?;
669        }
670        return Ok(0);
671    }
672
673    if (frozen || offline) && existing.is_none() {
674        return Err(format!("{} is missing", ctx.lock_path().display()).into());
675    }
676
677    let desired = build_lockfile(
678        &ctx,
679        existing.as_ref(),
680        None,
681        false,
682        !frozen && !offline,
683        offline,
684    )?;
685    if frozen || offline {
686        if existing.as_ref() != Some(&desired) {
687            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
688        }
689    } else {
690        desired.save(&ctx.lock_path())?;
691    }
692    materialize_dependencies_from_lock(&ctx, &desired, refetch, offline)
693}
694
695pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool) {
696    match install_packages_impl(frozen, refetch, offline) {
697        Ok(0) => println!("No dependencies to install."),
698        Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
699        Err(error) => {
700            eprintln!("error: {error}");
701            process::exit(1);
702        }
703    }
704}
705
706pub fn lock_packages() {
707    let result = (|| -> Result<usize, PackageError> {
708        let ctx = load_current_manifest_context()?;
709        let existing = LockFile::load(&ctx.lock_path())?;
710        let lock = build_lockfile(&ctx, existing.as_ref(), None, true, true, false)?;
711        lock.save(&ctx.lock_path())?;
712        Ok(lock.packages.len())
713    })();
714
715    match result {
716        Ok(count) => println!("Wrote {} with {count} package(s).", LOCK_FILE),
717        Err(error) => {
718            eprintln!("error: {error}");
719            process::exit(1);
720        }
721    }
722}
723
724pub fn update_packages(alias: Option<&str>, all: bool) {
725    if !all && alias.is_none() {
726        eprintln!("error: specify a dependency alias or pass --all");
727        process::exit(1);
728    }
729
730    let result = (|| -> Result<usize, PackageError> {
731        let ctx = load_current_manifest_context()?;
732        if let Some(alias) = alias {
733            validate_package_alias(alias)?;
734            if !ctx.manifest.dependencies.contains_key(alias) {
735                return Err(format!("{alias} is not present in [dependencies]").into());
736            }
737        }
738        let existing = LockFile::load(&ctx.lock_path())?;
739        let lock = build_lockfile(&ctx, existing.as_ref(), alias, all, true, false)?;
740        lock.save(&ctx.lock_path())?;
741        materialize_dependencies_from_lock(&ctx, &lock, None, false)
742    })();
743
744    match result {
745        Ok(installed) => println!("Updated {installed} package(s)."),
746        Err(error) => {
747            eprintln!("error: {error}");
748            process::exit(1);
749        }
750    }
751}
752
753pub fn remove_package(alias: &str) {
754    let result = (|| -> Result<bool, PackageError> {
755        validate_package_alias(alias)?;
756        let ctx = load_current_manifest_context()?;
757        let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
758        if !removed {
759            return Ok(false);
760        }
761        let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
762        lock.remove(alias);
763        lock.save(&ctx.lock_path())?;
764        remove_materialized_package(&ctx.packages_dir(), alias)?;
765        Ok(true)
766    })();
767
768    match result {
769        Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
770        Ok(false) => {
771            eprintln!("error: {alias} is not present in [dependencies]");
772            process::exit(1);
773        }
774        Err(error) => {
775            eprintln!("error: {error}");
776            process::exit(1);
777        }
778    }
779}
780
781pub(crate) fn normalize_add_request(
782    name_or_spec: &str,
783    alias: Option<&str>,
784    git_url: Option<&str>,
785    tag: Option<&str>,
786    rev: Option<&str>,
787    branch: Option<&str>,
788    local_path: Option<&str>,
789    registry: Option<&str>,
790) -> Result<(String, Dependency), PackageError> {
791    if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
792        return Err("path dependencies do not accept --rev, --tag, or --branch"
793            .to_string()
794            .into());
795    }
796    if git_url.is_none()
797        && local_path.is_none()
798        && rev.is_none()
799        && tag.is_none()
800        && branch.is_none()
801    {
802        if let Some(path) = existing_local_path_spec(name_or_spec) {
803            let alias = alias
804                .map(str::to_string)
805                .map(Ok)
806                .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
807            validate_package_alias(&alias)?;
808            return Ok((
809                alias,
810                Dependency::Table(DepTable {
811                    git: None,
812                    tag: None,
813                    rev: None,
814                    branch: None,
815                    path: Some(name_or_spec.to_string()),
816                    package: None,
817                }),
818            ));
819        }
820        if parse_registry_package_spec(name_or_spec).is_some() {
821            return registry_dependency_from_spec(name_or_spec, alias, registry);
822        }
823    }
824    if git_url.is_some() || local_path.is_some() {
825        if let Some(path) = local_path {
826            let alias = alias
827                .map(str::to_string)
828                .unwrap_or_else(|| name_or_spec.to_string());
829            validate_package_alias(&alias)?;
830            return Ok((
831                alias,
832                Dependency::Table(DepTable {
833                    git: None,
834                    tag: None,
835                    rev: None,
836                    branch: None,
837                    path: Some(path.to_string()),
838                    package: None,
839                }),
840            ));
841        }
842        let alias = alias.unwrap_or(name_or_spec).to_string();
843        validate_package_alias(&alias)?;
844        if rev.is_none() && tag.is_none() && branch.is_none() {
845            return Err(format!(
846                "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--rev`/`--branch`"
847            ).into());
848        }
849        let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
850        let package_name = derive_repo_name_from_source(&git)?;
851        return Ok((
852            alias.clone(),
853            Dependency::Table(DepTable {
854                git: Some(git),
855                tag: None,
856                rev: rev.or(tag).map(str::to_string),
857                branch: branch.map(str::to_string),
858                path: None,
859                package: (alias != package_name).then_some(package_name),
860            }),
861        ));
862    }
863
864    if rev.is_some() && tag.is_some() {
865        return Err("use only one of --rev or --tag".to_string().into());
866    }
867    let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
868    if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
869        return Err(
870            "specify the git ref either inline as @ref or via --rev/--branch"
871                .to_string()
872                .into(),
873        );
874    }
875    let git = normalize_git_url(raw_source)?;
876    let package_name = derive_repo_name_from_source(&git)?;
877    let alias = alias.unwrap_or(package_name.as_str()).to_string();
878    validate_package_alias(&alias)?;
879    if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
880        return Err(format!(
881            "git dependency {alias} must specify `rev` or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--rev`/`--branch`"
882        ).into());
883    }
884    Ok((
885        alias.clone(),
886        Dependency::Table(DepTable {
887            git: Some(git),
888            tag: None,
889            rev: inline_ref.or(rev).or(tag).map(str::to_string),
890            branch: branch.map(str::to_string),
891            path: None,
892            package: (alias != package_name).then_some(package_name),
893        }),
894    ))
895}
896
897#[cfg(test)]
898pub fn add_package(
899    name_or_spec: &str,
900    alias: Option<&str>,
901    git_url: Option<&str>,
902    tag: Option<&str>,
903    rev: Option<&str>,
904    branch: Option<&str>,
905    local_path: Option<&str>,
906) {
907    add_package_with_registry(
908        name_or_spec,
909        alias,
910        git_url,
911        tag,
912        rev,
913        branch,
914        local_path,
915        None,
916    )
917}
918
919pub fn add_package_with_registry(
920    name_or_spec: &str,
921    alias: Option<&str>,
922    git_url: Option<&str>,
923    tag: Option<&str>,
924    rev: Option<&str>,
925    branch: Option<&str>,
926    local_path: Option<&str>,
927    registry: Option<&str>,
928) {
929    let result = (|| -> Result<(String, usize), PackageError> {
930        let manifest_path = std::env::current_dir()
931            .map_err(|error| format!("failed to read cwd: {error}"))?
932            .join(MANIFEST);
933        let (alias, dependency) = normalize_add_request(
934            name_or_spec,
935            alias,
936            git_url,
937            tag,
938            rev,
939            branch,
940            local_path,
941            registry,
942        )?;
943        upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
944        let installed = install_packages_impl(false, None, false)?;
945        Ok((alias, installed))
946    })();
947
948    match result {
949        Ok((alias, installed)) => {
950            println!("Added {alias} to {MANIFEST}.");
951            println!("Installed {installed} package(s).");
952        }
953        Err(error) => {
954            eprintln!("error: {error}");
955            process::exit(1);
956        }
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use crate::package::test_support::*;
964
965    #[test]
966    fn lock_file_round_trips_typed_schema() {
967        let tmp = tempfile::tempdir().unwrap();
968        let path = tmp.path().join(LOCK_FILE);
969        let lock = LockFile {
970            version: LOCK_FILE_VERSION,
971            packages: vec![LockEntry {
972                name: "acme-lib".to_string(),
973                source: "git+https://github.com/acme/acme-lib".to_string(),
974                rev_request: Some("v1.0.0".to_string()),
975                commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
976                content_hash: Some("sha256:deadbeef".to_string()),
977            }],
978        };
979        lock.save(&path).unwrap();
980        let loaded = LockFile::load(&path).unwrap().unwrap();
981        assert_eq!(loaded, lock);
982    }
983
984    #[test]
985    fn add_and_remove_git_dependency_round_trip() {
986        let (_repo_tmp, repo, _branch) = create_git_package_repo();
987        let project_tmp = tempfile::tempdir().unwrap();
988        let root = project_tmp.path();
989        let cache_dir = root.join(".cache");
990        fs::create_dir_all(root.join(".git")).unwrap();
991        fs::write(
992            root.join(MANIFEST),
993            r#"
994    [package]
995    name = "workspace"
996    version = "0.1.0"
997    "#,
998        )
999        .unwrap();
1000
1001        with_test_env(root, &cache_dir, || {
1002            let spec = format!("{}@v1.0.0", repo.display());
1003            add_package(&spec, None, None, None, None, None, None);
1004
1005            let alias = "acme-lib";
1006            let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1007            assert!(manifest.contains("acme-lib"));
1008            assert!(manifest.contains("rev = \"v1.0.0\""));
1009
1010            let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1011            let entry = lock.find(alias).unwrap();
1012            assert_eq!(lock.version, LOCK_FILE_VERSION);
1013            assert!(entry.source.starts_with("git+file://"));
1014            assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1015            assert!(entry
1016                .content_hash
1017                .as_deref()
1018                .is_some_and(|hash| hash.starts_with("sha256:")));
1019            assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());
1020
1021            remove_package(alias);
1022            let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1023            assert!(!updated_manifest.contains("acme-lib ="));
1024            let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1025            assert!(updated_lock.find(alias).is_none());
1026            assert!(!root.join(PKG_DIR).join(alias).exists());
1027        });
1028    }
1029
1030    #[test]
1031    fn update_branch_dependency_refreshes_locked_commit() {
1032        let (_repo_tmp, repo, branch) = create_git_package_repo();
1033        let project_tmp = tempfile::tempdir().unwrap();
1034        let root = project_tmp.path();
1035        let cache_dir = root.join(".cache");
1036        fs::create_dir_all(root.join(".git")).unwrap();
1037        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1038        fs::write(
1039            root.join(MANIFEST),
1040            format!(
1041                r#"
1042    [package]
1043    name = "workspace"
1044    version = "0.1.0"
1045
1046    [dependencies]
1047    acme-lib = {{ git = "{git}", branch = "{branch}" }}
1048    "#
1049            ),
1050        )
1051        .unwrap();
1052
1053        with_test_env(root, &cache_dir, || {
1054            let installed = install_packages_impl(false, None, false).unwrap();
1055            assert_eq!(installed, 1);
1056            let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1057            let first_commit = first_lock
1058                .find("acme-lib")
1059                .and_then(|entry| entry.commit.clone())
1060                .unwrap();
1061
1062            fs::write(
1063                repo.join("lib.harn"),
1064                "pub fn value() -> string { return \"v2\" }\n",
1065            )
1066            .unwrap();
1067            run_git(&repo, &["add", "."]);
1068            run_git(&repo, &["commit", "-m", "update"]);
1069
1070            update_packages(Some("acme-lib"), false);
1071            let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1072            let second_commit = second_lock
1073                .find("acme-lib")
1074                .and_then(|entry| entry.commit.clone())
1075                .unwrap();
1076            assert_ne!(first_commit, second_commit);
1077        });
1078    }
1079
1080    #[test]
1081    fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
1082        let dependency_tmp = tempfile::tempdir().unwrap();
1083        let dependency_root = dependency_tmp.path().join("harn-openapi");
1084        fs::create_dir_all(&dependency_root).unwrap();
1085        fs::write(
1086            dependency_root.join(MANIFEST),
1087            r#"
1088    [package]
1089    name = "openapi"
1090    version = "0.1.0"
1091    "#,
1092        )
1093        .unwrap();
1094        fs::write(
1095            dependency_root.join("lib.harn"),
1096            "pub fn version() -> string { return \"v1\" }\n",
1097        )
1098        .unwrap();
1099
1100        let project_tmp = tempfile::tempdir().unwrap();
1101        let root = project_tmp.path();
1102        let cache_dir = root.join(".cache");
1103        fs::create_dir_all(root.join(".git")).unwrap();
1104        fs::write(
1105            root.join(MANIFEST),
1106            r#"
1107    [package]
1108    name = "workspace"
1109    version = "0.1.0"
1110    "#,
1111        )
1112        .unwrap();
1113
1114        with_test_env(root, &cache_dir, || {
1115            add_package(
1116                dependency_root.to_string_lossy().as_ref(),
1117                None,
1118                None,
1119                None,
1120                None,
1121                None,
1122                None,
1123            );
1124
1125            let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1126            assert!(
1127                manifest.contains("openapi = { path = "),
1128                "manifest should use package.name as alias: {manifest}"
1129            );
1130            let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1131            let entry = lock.find("openapi").expect("openapi lock entry");
1132            assert!(entry.source.starts_with("path+file://"));
1133            let materialized = root.join(PKG_DIR).join("openapi");
1134            assert!(materialized.join("lib.harn").is_file());
1135
1136            #[cfg(unix)]
1137            assert!(
1138                fs::symlink_metadata(&materialized)
1139                    .unwrap()
1140                    .file_type()
1141                    .is_symlink(),
1142                "path dependencies should be live-linked on Unix"
1143            );
1144
1145            #[cfg(windows)]
1146            let materialized_is_link = fs::symlink_metadata(&materialized)
1147                .unwrap()
1148                .file_type()
1149                .is_symlink();
1150
1151            fs::write(
1152                dependency_root.join("lib.harn"),
1153                "pub fn version() -> string { return \"v2\" }\n",
1154            )
1155            .unwrap();
1156            #[cfg(unix)]
1157            {
1158                let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1159                assert!(
1160                    live_source.contains("v2"),
1161                    "materialized path dependency should reflect sibling repo edits"
1162                );
1163            }
1164            #[cfg(windows)]
1165            {
1166                let materialized_source =
1167                    fs::read_to_string(materialized.join("lib.harn")).unwrap();
1168                if materialized_is_link {
1169                    assert!(
1170                        materialized_source.contains("v2"),
1171                        "Windows path dependency symlink should reflect sibling repo edits"
1172                    );
1173                } else {
1174                    assert!(
1175                        materialized_source.contains("v1"),
1176                        "Windows path dependency copy fallback should keep the copied contents"
1177                    );
1178                }
1179            }
1180
1181            remove_package("openapi");
1182            assert!(!materialized.exists());
1183            assert!(dependency_root.join("lib.harn").exists());
1184        });
1185    }
1186
1187    #[test]
1188    fn frozen_install_errors_when_lockfile_is_missing() {
1189        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1190        let project_tmp = tempfile::tempdir().unwrap();
1191        let root = project_tmp.path();
1192        let cache_dir = root.join(".cache");
1193        fs::create_dir_all(root.join(".git")).unwrap();
1194        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1195        fs::write(
1196            root.join(MANIFEST),
1197            format!(
1198                r#"
1199    [package]
1200    name = "workspace"
1201    version = "0.1.0"
1202
1203    [dependencies]
1204    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1205    "#
1206            ),
1207        )
1208        .unwrap();
1209
1210        with_test_env(root, &cache_dir, || {
1211            let error = install_packages_impl(true, None, false).unwrap_err();
1212            assert!(error.to_string().contains(LOCK_FILE));
1213        });
1214    }
1215
1216    #[test]
1217    fn offline_locked_install_materializes_from_cache_without_source_repo() {
1218        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1219        let project_tmp = tempfile::tempdir().unwrap();
1220        let root = project_tmp.path();
1221        let cache_dir = root.join(".cache");
1222        fs::create_dir_all(root.join(".git")).unwrap();
1223        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1224        fs::write(
1225            root.join(MANIFEST),
1226            format!(
1227                r#"
1228    [package]
1229    name = "workspace"
1230    version = "0.1.0"
1231
1232    [dependencies]
1233    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1234    "#
1235            ),
1236        )
1237        .unwrap();
1238
1239        with_test_env(root, &cache_dir, || {
1240            let installed = install_packages_impl(false, None, false).unwrap();
1241            assert_eq!(installed, 1);
1242            fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
1243            fs::remove_dir_all(&repo).unwrap();
1244
1245            let installed = install_packages_impl(true, None, true).unwrap();
1246            assert_eq!(installed, 1);
1247            assert!(root
1248                .join(PKG_DIR)
1249                .join("acme-lib")
1250                .join("lib.harn")
1251                .is_file());
1252        });
1253    }
1254
1255    #[test]
1256    fn offline_locked_install_fails_when_cache_is_missing() {
1257        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1258        let project_tmp = tempfile::tempdir().unwrap();
1259        let root = project_tmp.path();
1260        let cache_dir = root.join(".cache");
1261        fs::create_dir_all(root.join(".git")).unwrap();
1262        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1263        fs::write(
1264            root.join(MANIFEST),
1265            format!(
1266                r#"
1267    [package]
1268    name = "workspace"
1269    version = "0.1.0"
1270
1271    [dependencies]
1272    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1273    "#
1274            ),
1275        )
1276        .unwrap();
1277
1278        with_test_env(root, &cache_dir, || {
1279            install_packages_impl(false, None, false).unwrap();
1280            fs::remove_dir_all(cache_dir.join("git")).unwrap();
1281            let error = install_packages_impl(true, None, true).unwrap_err();
1282            assert!(error.to_string().contains("offline mode"));
1283        });
1284    }
1285
1286    #[test]
1287    fn add_github_shorthand_requires_version_or_ref() {
1288        let error = normalize_add_request(
1289            "github.com/burin-labs/harn-openapi",
1290            None,
1291            None,
1292            None,
1293            None,
1294            None,
1295            None,
1296            None,
1297        )
1298        .unwrap_err();
1299        assert!(error.to_string().contains("must specify `rev` or `branch`"));
1300    }
1301
1302    #[test]
1303    fn add_github_shorthand_with_ref_writes_git_dependency() {
1304        let (alias, dependency) = normalize_add_request(
1305            "github.com/burin-labs/harn-openapi@v1.2.3",
1306            None,
1307            None,
1308            None,
1309            None,
1310            None,
1311            None,
1312            None,
1313        )
1314        .unwrap();
1315        assert_eq!(alias, "harn-openapi");
1316        assert_eq!(
1317            render_dependency_line(&alias, &dependency).unwrap(),
1318            "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
1319        );
1320    }
1321    #[test]
1322    fn install_resolves_transitive_git_dependencies_from_clean_cache() {
1323        let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
1324            "notion-sdk-harn",
1325            "",
1326            "pub fn sdk_value() -> string { return \"sdk\" }\n",
1327        );
1328        let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
1329        let connector_tail = format!(
1330            r#"
1331
1332    [dependencies]
1333    notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
1334    "#
1335        );
1336        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1337            "notion-connector-harn",
1338            &connector_tail,
1339            r#"
1340    import "notion-sdk-harn"
1341
1342    pub fn connector_value() -> string {
1343      return "connector"
1344    }
1345    "#,
1346        );
1347
1348        let project_tmp = tempfile::tempdir().unwrap();
1349        let root = project_tmp.path();
1350        let cache_dir = root.join(".cache");
1351        fs::create_dir_all(root.join(".git")).unwrap();
1352        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1353        fs::write(
1354            root.join(MANIFEST),
1355            format!(
1356                r#"
1357    [package]
1358    name = "workspace"
1359    version = "0.1.0"
1360
1361    [dependencies]
1362    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1363    "#
1364            ),
1365        )
1366        .unwrap();
1367
1368        with_test_env(root, &cache_dir, || {
1369            let installed = install_packages_impl(false, None, false).unwrap();
1370            assert_eq!(installed, 2);
1371
1372            let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1373            assert!(lock.find("notion-connector-harn").is_some());
1374            assert!(lock.find("notion-sdk-harn").is_some());
1375            assert!(root
1376                .join(PKG_DIR)
1377                .join("notion-connector-harn")
1378                .join("lib.harn")
1379                .is_file());
1380            assert!(root
1381                .join(PKG_DIR)
1382                .join("notion-sdk-harn")
1383                .join("lib.harn")
1384                .is_file());
1385
1386            let mut vm = test_vm();
1387            let exports = futures::executor::block_on(
1388                vm.load_module_exports(
1389                    &root
1390                        .join(PKG_DIR)
1391                        .join("notion-connector-harn")
1392                        .join("lib.harn"),
1393                ),
1394            )
1395            .expect("transitive import should load from the workspace package root");
1396            assert!(exports.contains_key("connector_value"));
1397        });
1398    }
1399
1400    #[test]
1401    fn git_packages_reject_transitive_path_dependencies() {
1402        let connector_tail = r#"
1403
1404    [dependencies]
1405    local-helper = { path = "../helper" }
1406    "#;
1407        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1408            "notion-connector-harn",
1409            connector_tail,
1410            "pub fn connector_value() -> string { return \"connector\" }\n",
1411        );
1412
1413        let project_tmp = tempfile::tempdir().unwrap();
1414        let root = project_tmp.path();
1415        let cache_dir = root.join(".cache");
1416        fs::create_dir_all(root.join(".git")).unwrap();
1417        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1418        fs::write(
1419            root.join(MANIFEST),
1420            format!(
1421                r#"
1422    [package]
1423    name = "workspace"
1424    version = "0.1.0"
1425
1426    [dependencies]
1427    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1428    "#
1429            ),
1430        )
1431        .unwrap();
1432
1433        with_test_env(root, &cache_dir, || {
1434            let error = install_packages_impl(false, None, false).unwrap_err();
1435            assert!(error
1436                .to_string()
1437                .contains("path dependencies are not supported inside git-installed packages"));
1438        });
1439    }
1440
1441    #[test]
1442    fn package_alias_validation_rejects_path_traversal_names() {
1443        for alias in [
1444            "../evil",
1445            "nested/evil",
1446            "nested\\evil",
1447            ".",
1448            "..",
1449            "bad alias",
1450        ] {
1451            assert!(
1452                validate_package_alias(alias).is_err(),
1453                "{alias:?} should be rejected"
1454            );
1455        }
1456        validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
1457    }
1458
1459    #[test]
1460    fn add_package_rejects_aliases_that_escape_packages_dir() {
1461        let error = normalize_add_request(
1462            "ignored",
1463            Some("../evil"),
1464            None,
1465            None,
1466            None,
1467            None,
1468            Some("./dep"),
1469            None,
1470        )
1471        .unwrap_err();
1472        assert!(error.to_string().contains("invalid dependency alias"));
1473    }
1474
1475    #[test]
1476    fn rendered_dependency_values_are_toml_escaped() {
1477        let path = "dep\" \nmalicious = true";
1478        let line = render_dependency_line(
1479            "safe",
1480            &Dependency::Table(DepTable {
1481                git: None,
1482                tag: None,
1483                rev: None,
1484                branch: None,
1485                path: Some(path.to_string()),
1486                package: None,
1487            }),
1488        )
1489        .expect("dependency line");
1490        let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
1491        assert_eq!(parsed.dependencies.len(), 1);
1492        assert_eq!(
1493            parsed
1494                .dependencies
1495                .get("safe")
1496                .and_then(Dependency::local_path),
1497            Some(path)
1498        );
1499    }
1500
1501    #[test]
1502    fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
1503        let tmp = tempfile::tempdir().unwrap();
1504        let dep = tmp.path().join("dep");
1505        fs::create_dir_all(&dep).unwrap();
1506        fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
1507        let victim = tmp.path().join("victim");
1508        fs::create_dir_all(&victim).unwrap();
1509        fs::write(victim.join("keep.txt"), "keep").unwrap();
1510
1511        let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
1512        let ctx = ManifestContext {
1513            manifest,
1514            dir: tmp.path().to_path_buf(),
1515        };
1516        let lock = LockFile {
1517            version: LOCK_FILE_VERSION,
1518            packages: vec![LockEntry {
1519                name: "../victim".to_string(),
1520                source: path_source_uri(&dep).unwrap(),
1521                rev_request: None,
1522                commit: None,
1523                content_hash: None,
1524            }],
1525        };
1526
1527        let error = materialize_dependencies_from_lock(&ctx, &lock, None, false).unwrap_err();
1528        assert!(error.to_string().contains("invalid dependency alias"));
1529        assert!(
1530            victim.join("keep.txt").exists(),
1531            "malicious alias should not remove paths outside .harn/packages"
1532        );
1533    }
1534}