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

principal-store directory changed while it was opened

Error message

principal-store directory changed while it was opened

What it means

open_directory opens a principal-store subdirectory twice and compares the stable OS file identity (device+inode or file index) of both handles. If the identities differ, the directory entry was swapped or recreated between the two opens. The library throws this to defend capability-based storage against directory-swap attacks or race-condition corruption.

Source

Thrown at crates/astrid-storage/src/engine/durable/native_io.rs:60

        .open_with(name, &options)
        .map(cap_std::fs::File::into_std)
        .map_err(|source| io_error("create principal-store capability file", source))?;
    validate_regular(&file)?;
    Ok(File::native(file))
}

pub(super) fn open_directory(
    parent: &Dir,
    name: &Path,
    create: bool,
) -> Result<Option<Dir>, DurableError> {
    let open = || -> io::Result<Dir> {
        validate_directory_entry(parent, name)?;
        let first = parent.open_dir(name)?;
        validate_directory_entry(parent, name)?;
        let second = parent.open_dir(name)?;
        if directory_identity(&first)? != directory_identity(&second)? {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "principal-store directory changed while it was opened",
            ));
        }
        Ok(first)
    };
    match open() {
        Ok(directory) => Ok(Some(directory)),
        Err(source) if source.kind() == io::ErrorKind::NotFound && !create => Ok(None),
        Err(source) if source.kind() == io::ErrorKind::NotFound => {
            parent
                .create_dir(name)
                .or_else(|error| {
                    (error.kind() == io::ErrorKind::AlreadyExists)
                        .then_some(())
                        .ok_or(error)
                })
                .map_err(|source| {

View on GitHub (pinned to affd8760f4)

Solutions

  1. Ensure no other process mutates (deletes/renames) directories under the principal-store root while the store is open
  2. Re-run the open operation; if transient, the second attempt will see a stable directory
  3. Audit for concurrent GC/recovery tooling operating on the same store path and serialize access with a lock
  4. Verify the store directory is on a filesystem you trust and not exposed to other local users
  5. Recover the store from backup if the directory was genuinely replaced and data is inconsistent

Example fix

// before
rm -rf store/principals && mkdir store/principals   # while store is open
// after
lock store; perform replacement; unlock; then reopen the store
Defensive patterns

Strategy: retry

Validate before calling

// ensure no concurrent mutators before opening
assert_no_active_gc_jobs(store_root)?;
if !store_root.is_dir() { return Err("store root missing".into()); }

Try / catch

match open_store(path) {
    Err(e) if e.to_string().contains("directory changed while it was opened") => {
        // serialize with a lock file and retry once
        let _lock = LockFile::acquire(path)?;
        open_store(path)
    }
    r => r,
}

Prevention

When it happens

Trigger: Another process or thread removes and recreates the directory (e.g. rm -rf plus recreate, or an atomic rename swap) while open_directory executes its double-open identity check on the same path.

Common situations: Concurrent maintenance scripts garbage-collecting and recreating store directories; symlink/hardlink attacks by an untrusted local user; a stale background job racing a live store open on the same directory path.

Related errors


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