astrid-runtime/astrid · error · io::Error

layout migration receipt is redirected or not a regular file

Error message

layout migration receipt is redirected or not a regular file: {}

What it means

retire_verified_legacy_source checks the completion receipt at var/migrations/layout-v1-to-v2.complete with symlink_metadata and requires it to be a regular file, not a symlink and not a directory or other special entry. This is a no-follow security check: a redirected receipt path could make the library read attacker-controlled migration records or delete the wrong tree. Returned as InvalidData with the offending path in the message.

Source

Thrown at crates/astrid-core/src/dirs_layout.rs:345

    pub(super) fn retire_verified_legacy_source(&self) -> io::Result<()> {
        let receipt_path = self.migrations_dir().join(LAYOUT_MIGRATION_RECEIPT);
        let intent_path = self.migrations_dir().join(LAYOUT_MIGRATION_INTENT);
        match std::fs::symlink_metadata(&receipt_path) {
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                if path_entry_present(&self.state_db_path())?
                    || path_entry_present(&self.cow_dir())?
                    || path_entry_present(&intent_path)?
                {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "layout-two home contains legacy state without a completion receipt",
                    ));
                }
                return Ok(());
            },
            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    format!(
                        "layout migration receipt is redirected or not a regular file: {}",
                        receipt_path.display()
                    ),
                ));
            },
            Ok(_) => {},
            Err(error) => return Err(error),
        }

        let receipt: LayoutMigrationReceiptV1 = read_canonical_record(&receipt_path)?;
        let intent: LayoutMigrationRecordV1 = read_canonical_record(&intent_path)?;
        let retirement_path = self.migrations_dir().join(LAYOUT_MIGRATION_RETIREMENT);
        let retirement: Option<LayoutRetirementV1> = match read_canonical_record(&retirement_path) {
            Ok(retirement) => Some(retirement),
            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
            Err(error) => return Err(error),

View on GitHub (pinned to affd8760f4)

Solutions

  1. Inspect var/migrations/layout-v1-to-v2.complete with ls -la: if it is a symlink or directory, remove it and restore the original regular receipt file from backup
  2. Re-run the v1→v2 migration from a clean legacy sentinel state so a fresh, genuine receipt is written
  3. Audit the var/migrations/ directory for tampering; ensure it is a private (0700) directory owned by the Astrid user
  4. Never hand-create the receipt as a symlink or placeholder — it must be a canonical regular file written by the migration itself

Example fix

// before: receipt is a symlink
ls -la ~/.astrid/var/migrations/layout-v1-to-v2.complete  # -> symlink
home.complete_layout_v2(&target)?; // InvalidData: redirected receipt
// after
rm ~/.astrid/var/migrations/layout-v1-to-v2.complete  # remove redirect
# restore genuine receipt from backup or re-run migration from legacy sentinel
Defensive patterns

Strategy: validation

Validate before calling

use std::os::unix::fs::FileTypeExt;
let md = std::fs::symlink_metadata(&receipt_path)?;
if md.file_type().is_symlink() || !md.is_file() {
    return Err(anyhow!("receipt is redirected or not a regular file"));
}

Type guard

fn is_regular_no_follow(p: &Path) -> bool {
    std::fs::symlink_metadata(p).map(|m| m.is_file() && !m.file_type().is_symlink()).unwrap_or(false)
}

Try / catch

match home.complete_layout_v2(&target) {
    Err(e) if e.to_string().contains("redirected or not a regular file") => {
        // remove the symlink/odd entry and restore the genuine receipt
    },
    r => r?,
}

Prevention

When it happens

Trigger: Calling complete_layout_v2 when the receipt path is a symlink (e.g. someone linked it to /tmp or another volume), a directory, a FIFO/device node, or was replaced by any non-regular file.

Common situations: Malicious or careless symlinking inside var/migrations/; a restore tool that recreated the receipt as a symlink or directory; overlay/bind mounts presenting the receipt as something other than a regular file.

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/f13df790793b2ddd. Report an issue: GitHub.