astrid-runtime/astrid · error

private file has links; durable media must have exactly one

Error message

private file has {} links; durable media must have exactly one

What it means

The library requires private files to have exactly one hard link (st_nlink == 1) because durable security-sensitive media must not be reachable through a second directory entry, which would let edits bypass the enforced permissions. It throws this when validation counts more than one link.

Solutions

  1. Remove the extra hard links (`ls -li <path>` to find inodes, then delete the other directory entries) until the link count is 1.
  2. Copy the file to a fresh inode instead of hard-linking: `cp <src> <dst>` then `rm <src>` and `mv <dst> <src>`, keeping mode 0600.
  3. Disable hard-link-based backup/dedup for the private directory and re-create the file via the library.
  4. Recreate the file with atomic_write_private_file, which writes a fresh single-link inode.

Example fix

// before (nlink=2 due to backup hard link)
validate_private_file(Path::new("/home/me/.astrid/credentials"))?;
// after (replace with fresh single-link file)
// $ cp /home/me/.astrid/credentials /tmp/cred && chmod 600 /tmp/cred
// $ rm /home/me/.astrid/credentials && mv /tmp/cred /home/me/.astrid/credentials
validate_private_file(Path::new("/home/me/.astrid/credentials"))?;
Defensive patterns

Strategy: validation

Validate before calling

use std::os::unix::fs::MetadataExt;
fn has_single_link(path: &std::path::Path) -> bool {
    std::fs::metadata(path).map(|m| m.nlink() == 1).unwrap_or(false)
}

Type guard

fn is_unlinked_alias_free(path: &std::path::Path) -> bool {
    std::fs::metadata(path).map(|m| m.nlink() == 1).unwrap_or(false)
}

Try / catch

match validate_private_file(path) {
    Err(e) if e.kind() == std::io::ErrorKind::InvalidData && e.to_string().contains("links") => {
        // replace with a fresh single-link copy
    },
    other => other?,
}

Prevention

When it happens

Trigger: validate_private_file finds st_nlink > 1: someone hard-linked the file elsewhere (`ln`), or a backup/dedup tool created additional links to the inode.

Common situations: Backup tools using hard links (e.g. rsync --link-dest, rsnapshot); developer manually hard-linking the credential file into another project; deduplicating filesystems linking shared inodes.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

Thrown at crates/astrid-core/src/platform_fs.rs:563

        ));
    }
    if metadata.st_uid != nix::unistd::getuid().as_raw() {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            format!(
                "private file is not owned by the current user: {}",
                path.display()
            ),
        ));
    }
    if metadata.st_mode & 0o777 != 0o600 {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            format!("private file is not owner-only: {}", path.display()),
        ));
    }
    if metadata.st_nlink != 1 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!(
                "private file has {} links; durable media must have exactly one",
                metadata.st_nlink
            ),
        ));
    }
    validate_no_extended_acl(path)?;
    Ok(())
}

#[cfg(target_os = "macos")]
fn remove_extended_acl_macos(path: &Path) -> io::Result<()> {
    let path = absolute_command_path(path)?;
    let status = std::process::Command::new("/bin/chmod")
        .arg("-N")
        .arg(path)
        .status()?;

View on GitHub (pinned to affd8760f4)