astrid-runtime/astrid · error

legacy capsule contains symlink

Error message

legacy capsule contains symlink {}

What it means

Raised by copy_legacy_tree while walking a legacy capsule directory during canonical_legacy_archive: an entry is a symlink. The library refuses to copy symlinks into the canonical legacy archive to keep archives deterministic and free of links that could point outside the tree.

Solutions

  1. Find and remove the symlink (the message names its path): replace it with a real directory or a copy of the target content.
  2. Re-copy the capsule tree with symlink dereferencing (e.g. `cp -rL`) to materialize targets as regular files, then retry the migration.
  3. If the link was intentional for out-of-tree content, move the content physically into the capsule directory and link nothing.

Example fix

# before: node_modules points elsewhere
node_modules -> /shared/deps

# after: materialize the content as real files
cp -rL /shared/deps node_modules
Defensive patterns

Strategy: validation

Validate before calling

fn has_symlinks(dir: &Path) -> std::io::Result<bool> {
    for e in walkdir(dir) {
        if e.file_type().is_symlink() { return Ok(true); }
    }
    Ok(false)
}

Type guard

fn is_regular_entry(md: &std::fs::Metadata) -> bool {
    md.is_file() || md.is_dir()
}

Try / catch

if let Err(e) = migrate(...) {
    if e.to_string().contains("contains symlink") {
        materialize_symlinks(&capsule_dir)?; // cp -rL equivalent
        migrate(...)?;
    }
}

Prevention

When it happens

Trigger: Migrating a legacy capsule whose directory tree contains a symlink anywhere under it; copy_legacy_tree recurses via canonical_legacy_archive and also calls itself, so a symlink at any depth triggers it.

Common situations: Users replaced large dependency folders with symlinks to save space; node_modules-style links present in legacy layouts; Capsule trees checked out on filesystems or via tools that create links; restored backups that turned directories into symlinks.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/d6e084e0bbf1d060. Report an issue: GitHub.

Appendix: source

Thrown at crates/astrid-capsule-install/src/storage.rs:700

        let destination = staging.path().join("wit").join(relative);
        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::copy(&source, &destination)
            .with_context(|| format!("restore content-addressed WIT blob {hash}"))?;
    }
    canonical_capsule_archive(staging.path())
}

fn copy_legacy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
    fs::create_dir_all(destination)?;
    for (path, metadata) in read_dir_sorted(source)? {
        let relative = path
            .file_name()
            .ok_or_else(|| anyhow::anyhow!("legacy capsule entry has no name"))?;
        let destination = destination.join(relative);
        if metadata.file_type().is_symlink() {
            bail!("legacy capsule contains symlink {}", path.display());
        }
        if metadata.is_dir() {
            copy_legacy_tree(&path, &destination)?;
        } else if metadata.is_file() {
            let name = path.file_name().and_then(|name| name.to_str());
            if matches!(name, Some("meta.json" | "authority.json" | ".env.json")) {
                continue;
            }
            fs::copy(path, destination)?;
        } else {
            bail!("legacy capsule contains special file {}", path.display());
        }
    }
    Ok(())
}

fn manifest_identity(source_dir: &Path) -> anyhow::Result<(String, String)> {
    let manifest = fs::read_to_string(source_dir.join("Capsule.toml"))

View on GitHub (pinned to affd8760f4)