Hmbown/CodeWhale · error

bundle path contains a NUL byte; refused

Error message

bundle path contains a NUL byte; refused

What it means

A path embedded in a config bundle contains a NUL byte (`\0`). NUL bytes are illegal in filesystem paths and are a common smuggling technique, so `resolve_bounded_path` rejects them up front before any filesystem work. This is a hard security/validity guard in the bounded-path resolver of crates/cli/src/config_bundles.rs.

Solutions

  1. Remove or fix the offending path entry in the bundle so it contains no NUL bytes.
  2. Re-export the bundle from a trusted source with the current CLI.
  3. If generating bundles programmatically, validate/sanitize path strings (reject `\0`) before writing them into the bundle.

Example fix

// before
"path": "settings\0.json"
// after
"path": "settings.json"
Defensive patterns

Strategy: validation

Validate before calling

fn candidate_path_ok(candidate: &str) -> bool {
    !candidate.contains('\0')
}

Type guard

fn is_nul_free(candidate: &str) -> bool {
    !candidate.as_bytes().contains(&b'\0')
}

Try / catch

// wrap bounded resolution when applying bundle sections
match codewhale_cli::config_bundles::resolve_bounded_path(&base_dir, candidate) {
    Ok(path) => apply(path),
    Err(e) if e.to_string().contains("NUL byte") => log::warn!("skipped bundle entry with NUL-byte path: {candidate:?}"),
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Importing a bundle whose path entries (sections referencing files) contain a string with an embedded `\0`, passed to `resolve_bounded_path`.

Common situations: A hand-crafted or tampered bundle; a bundle generator writing raw bytes from binary input; testing the guard directly (the cited caller is the guard test).

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 Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/28f5f1efc7b169d8. Report an issue: GitHub.

Appendix: source

Thrown at crates/cli/src/config_bundles.rs:714

// ---------------------------------------------------------------------------
// Path safety
// ---------------------------------------------------------------------------

/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
/// escapes. Returns the resolved path or an error naming the refusal — the
/// candidate string itself is safe to echo (it is config data, not a secret).
/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
/// escapes. Returns the joined path or an error naming the refusal.
/// Reserved for path-carrying bundle sections (none shipped yet); exercised
/// by the traversal tests so the contract cannot silently rot.
#[cfg_attr(
    not(test),
    expect(dead_code, reason = "path-carrying sections land with the next schema")
)]
pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf> {
    if candidate.contains('\0') {
        bail!("bundle path contains a NUL byte; refused");
    }
    let candidate_path = Path::new(candidate);
    if candidate_path.is_absolute() {
        bail!(
            "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted"
        );
    }
    let canonical_base = base_dir
        .canonicalize()
        .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?;
    let joined = base_dir.join(candidate_path);
    // Walk the joined path's ancestors from the deepest existing component up:
    // every existing component must canonicalize inside the base, so a symlink
    // pointing outside the config directory is refused even when the final
    // target does not exist yet.
    let deepest_existing = joined
        .ancestors()
        .find(|ancestor| ancestor.symlink_metadata().is_ok())

View on GitHub (pinned to 73e0f67d83)