{"record":{"id":"0b678279bc51c1f0","repo":"astrid-runtime/astrid","slug":"swap-path-b-has-nul","errorCode":null,"errorMessage":"swap path b has NUL","messagePattern":"swap path b has NUL","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-vfs/src/workspace_cow/apfs.rs","lineNumber":246,"sourceCode":"        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, \"clone dest path has NUL\"))?;\n    // SAFETY: `src_c`/`dst_c` are valid, NUL-terminated C strings that outlive\n    // the call; `clonefile` reads them and returns a status code, retaining no\n    // pointers. Flag `0` = default (clone contents, don't follow the final\n    // symlink).\n    let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };\n    if rc != 0 {\n        return Err(io::Error::last_os_error());\n    }\n    Ok(())\n}\n\n/// `renamex_np(a, b, RENAME_SWAP)` — atomically swap two existing paths on the\n/// same volume.\nfn renamex_swap(a: &Path, b: &Path) -> io::Result<()> {\n    let a_c = CString::new(a.as_os_str().as_bytes())\n        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, \"swap path a has NUL\"))?;\n    let b_c = CString::new(b.as_os_str().as_bytes())\n        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, \"swap path b has NUL\"))?;\n    // SAFETY: both are valid, NUL-terminated C strings outliving the call;\n    // `renamex_np` reads them and returns a status code, retaining no pointers.\n    let rc = unsafe { libc::renamex_np(a_c.as_ptr(), b_c.as_ptr(), libc::RENAME_SWAP) };\n    if rc != 0 {\n        return Err(io::Error::last_os_error());\n    }\n    Ok(())\n}\n","sourceCodeStart":228,"sourceCodeEnd":255,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-vfs/src/workspace_cow/apfs.rs#L228-L255","documentation":"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.","triggerScenarios":"Calling promote where the second swap path (path b) contains a 0x00 byte from corrupted or untrusted input.","commonSituations":"Config/state corruption supplying the destination-side path; deserialized paths with embedded NUL; untrusted string concatenation building the path.","solutions":["Validate path b (and path a) for NUL bytes before calling promote","Fix the source of the corrupted path b value","Add caller-side NUL-byte validation on all paths passed to CoW operations","Log raw path bytes to identify the corruption"],"exampleFix":"// before\nrenamex_swap(&a, &b)?;\n// after\nif b.as_os_str().as_bytes().contains(&0) {\n    return Err(io::Error::new(io::ErrorKind::InvalidInput, \"NUL in path b\"));\n}\nrenamex_swap(&a, &b)?;","handlingStrategy":"validation","validationCode":"fn ensure_swap_safe(a: &Path, b: &Path) -> io::Result<()> {\n    for p in [a, b] {\n        if p.as_os_str().as_bytes().contains(&0) {\n            return Err(io::Error::new(io::ErrorKind::InvalidInput, \"NUL in swap path\"));\n        }\n    }\n    Ok(())\n}","typeGuard":"fn is_c_string_safe(p: &Path) -> bool {\n    !p.as_os_str().as_bytes().contains(&0)\n}","tryCatchPattern":"match renamex_swap(&a, &b) {\n    Err(e) if e.kind() == io::ErrorKind::InvalidInput => {\n        log::error!(\"bad swap path b: {:?}\", b.as_os_str().as_bytes());\n        return Err(e);\n    }\n    other => other,\n}","preventionTips":["Validate both operands, not just the source, before promote","Sanitize paths on ingestion from config/state/IPC","Use validated-path newtypes to prevent bad paths reaching FFI","Keep a single shared NUL-check helper"],"tags":["macos","path","ffi","rename"],"backgroundTag":"invalid-argument-value","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-17T15:17:12.973Z"}