jdx/mise · error

a parent directory is now a symlink; left untouched

Error message

a parent directory is now a symlink; left untouched

What it means

During recovery validation the library walks every ancestor of the destination path and checks for symlinks via symlink_metadata. If any parent directory has become a symlink since the snapshot was recorded, the recovery is aborted and the parent left untouched, because writing through the symlink could redirect the restore to an attacker- or accident-controlled location. The message signals the snapshot destination's directory tree changed shape and recovery is intentionally skipped.

Solutions

  1. Inspect the destination's ancestors and replace the symlink with a real directory (or re-point the snapshot) before retrying recovery
  2. Recreate the original directory layout that existed when the snapshot was taken
  3. If the symlink is intentional, restore to a new non-symlink absolute path and copy results manually, or re-record the snapshot at the new location

Example fix

// before
# /srv/data -> /mnt/big/data (symlink created after snapshot)
recover_path(Path::new("/srv/data/state"), prior)?; // errors
// after
rm /srv/data && mkdir /srv/data   # make parents real directories again
recover_path(Path::new("/srv/data/state"), prior)?;
Defensive patterns

Strategy: validation

Validate before calling

use std::fs::symlink_metadata;
fn parents_are_real(p: &std::path::Path) -> bool {
    p.ancestors().skip(1).all(|a| {
        symlink_metadata(a).map_or(true, |m| !m.is_symlink())
    })
}

Try / catch

match recover_path(dest, prior) {
    Err(e) if e.to_string().contains("now a symlink") => {
        eprintln!("resolve the symlinked parent before recovering: {e}");
    }
    other => other?,
}

Prevention

When it happens

Trigger: Any recover_path/restore attempt where a directory in the destination's ancestor chain (e.g. `/a` in `/a/b/c/file`) is now a symlink, detected with std::fs::symlink_metadata during validate_destination.

Common situations: A user replaced a real directory with a symlink (e.g. moving data to another volume and symlinking); package managers or dotfile managers rewrote directories; tmpfs/var directories got re-pointed between snapshot and recovery.

Related errors


AI-assisted analysis of jdx/mise@533346cc37 (2026-09-17). Data as JSON: /api/errors/f0857f7d090f788d. Report an issue: GitHub.

Appendix: source

Thrown at src/system/history/recovery.rs:115

                }
            ))
    {
        bail!("directory contents cannot be verified safely; left untouched");
    }
    validate_snapshot(state_dir, prior)?;
    if PathState::observe(path) != *after {
        bail!("changed while preparing recovery; left untouched");
    }
    restore(state_dir, path, prior)
}

fn validate_destination(path: &Path) -> Result<()> {
    if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
        bail!("invalid recovery destination");
    }
    for parent in path.ancestors().skip(1) {
        if std::fs::symlink_metadata(parent).is_ok_and(|meta| meta.is_symlink()) {
            bail!("a parent directory is now a symlink; left untouched");
        }
    }
    Ok(())
}

fn validate_blob_id(hash: &str) -> Result<()> {
    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
        bail!("invalid recovery content identifier");
    }
    Ok(())
}

pub(super) fn read_blob(state_dir: &Path, blob: &Blob) -> Result<Vec<u8>> {
    use base64::Engine;
    validate_blob_id(&blob.sha256)?;
    let bytes = match &blob.inline {
        Some(inline) => base64::engine::general_purpose::STANDARD.decode(inline)?,
        None => {

View on GitHub (pinned to 533346cc37)