astrid-runtime/astrid · error

swap path b has NUL

Error message

swap path b has NUL

What it means

renamex_swap converts both operands to CStrings; this error fires when the second swap operand (path b) contains an interior NUL byte and CString::new fails. The syscall itself is never reached, and an InvalidInput io::Error is returned instead.

Solutions

  1. Validate path b (and path a) for NUL bytes before calling promote
  2. Fix the source of the corrupted path b value
  3. Add caller-side NUL-byte validation on all paths passed to CoW operations
  4. Log raw path bytes to identify the corruption

Example fix

// before
renamex_swap(&a, &b)?;
// after
if b.as_os_str().as_bytes().contains(&0) {
    return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in path b"));
}
renamex_swap(&a, &b)?;
Defensive patterns

Strategy: validation

Validate before calling

fn ensure_swap_safe(a: &Path, b: &Path) -> io::Result<()> {
    for p in [a, b] {
        if p.as_os_str().as_bytes().contains(&0) {
            return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in swap path"));
        }
    }
    Ok(())
}

Type guard

fn is_c_string_safe(p: &Path) -> bool {
    !p.as_os_str().as_bytes().contains(&0)
}

Try / catch

match renamex_swap(&a, &b) {
    Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
        log::error!("bad swap path b: {:?}", b.as_os_str().as_bytes());
        return Err(e);
    }
    other => other,
}

Prevention

When it happens

Trigger: Calling promote where the second swap path (path b) contains a 0x00 byte from corrupted or untrusted input.

Common situations: Config/state corruption supplying the destination-side path; deserialized paths with embedded NUL; untrusted string concatenation building the path.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


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

Appendix: source

Thrown at crates/astrid-vfs/src/workspace_cow/apfs.rs:246

        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "clone dest path has NUL"))?;
    // SAFETY: `src_c`/`dst_c` are valid, NUL-terminated C strings that outlive
    // the call; `clonefile` reads them and returns a status code, retaining no
    // pointers. Flag `0` = default (clone contents, don't follow the final
    // symlink).
    let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
    if rc != 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}

/// `renamex_np(a, b, RENAME_SWAP)` — atomically swap two existing paths on the
/// same volume.
fn renamex_swap(a: &Path, b: &Path) -> io::Result<()> {
    let a_c = CString::new(a.as_os_str().as_bytes())
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "swap path a has NUL"))?;
    let b_c = CString::new(b.as_os_str().as_bytes())
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "swap path b has NUL"))?;
    // SAFETY: both are valid, NUL-terminated C strings outliving the call;
    // `renamex_np` reads them and returns a status code, retaining no pointers.
    let rc = unsafe { libc::renamex_np(a_c.as_ptr(), b_c.as_ptr(), libc::RENAME_SWAP) };
    if rc != 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}

View on GitHub (pinned to affd8760f4)