Hmbown/CodeWhale · error · std::io::Error

InvalidInput

InvalidInput

Error message

artifact id and extension must contain safe ASCII characters

What it means

session_artifact_relative_path_with_extension (used by write_session_artifact_bytes for media fetches) sanitizes the artifact id (any char outside [A-Za-z0-9_-] becomes '_') and then requires the sanitized id to be non-empty and the extension — after trimming leading '.' and lowercasing — to be non-empty and purely ASCII alphanumeric. Anything else fails with InvalidInput: notably extensions containing '-', '+', or an inner dot such as 'svg+xml', 'x-tar', or 'tar.gz' are rejected, not normalized.

Source

Thrown at crates/tui/src/artifacts.rs:85

#[must_use]
pub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf {
    PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.txt"))
}

fn session_artifact_relative_path_with_extension(
    artifact_id: &str,
    extension: &str,
) -> io::Result<PathBuf> {
    let artifact_id = sanitize_id_component(artifact_id);
    let extension = extension.trim_start_matches('.').to_ascii_lowercase();
    if artifact_id.is_empty()
        || extension.is_empty()
        || !extension
            .chars()
            .all(|character| character.is_ascii_alphanumeric())
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "artifact id and extension must contain safe ASCII characters",
        ));
    }
    Ok(PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.{extension}")))
}

fn artifact_sessions_root() -> Option<PathBuf> {
    #[cfg(test)]
    if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT
        .lock()
        .unwrap_or_else(|err| err.into_inner())
        .clone()
    {
        return Some(root);
    }

    // Honor explicit HOME/USERPROFILE isolation before consulting the host

View on GitHub (pinned to 0c42157ee5)

Solutions

  1. Map MIME types to simple alphanumeric extensions before writing (image/svg+xml -> svg, application/x-tar -> tar, image/jpeg -> jpg)
  2. Filter the extension: ext.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>() and reject if empty
  3. Build ids via artifact_id_for_tool_call(tool_call_id) so the sanitized id is never empty
  4. Default a missing extension to a known-good value such as bin

Example fix

// before
let (abs, rel) = write_session_artifact_bytes(sid, art_id, "svg+xml", bytes)?; // InvalidInput

// after
let ext: String = "svg+xml".chars().filter(|c| c.is_ascii_alphanumeric()).take(4).collect::<String>(); // "svgxml"
let (abs, rel) = write_session_artifact_bytes(sid, art_id, &ext, bytes)?;
Defensive patterns

Strategy: validation

Validate before calling

fn safe_extension(ext: &str) -> Option<String> {
    let ext = ext.trim_start_matches('.').to_ascii_lowercase();
    (!ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())).then_some(ext)
}

let ext = safe_extension(raw_ext).unwrap_or_else(|| "bin".into());

Type guard

fn is_valid_artifact_extension(ext: &str) -> bool {
    let ext = ext.trim_start_matches('.').to_ascii_lowercase();
    !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
}

Prevention

When it happens

Trigger: Calling write_session_artifact_bytes(session_id, artifact_id, extension, bytes) with a MIME-derived extension like "svg+xml"/"x-tar"/"tar.gz", an empty extension (URL had no suffix and none was defaulted), or an artifact_id made entirely of unsafe characters that sanitizes to the empty string (e.g. "../.."). Note '.PNG' and 'JPG' are fine (trimmed and lowercased).

Common situations: Deriving the extension from a Content-Type subtype or URL query parameter instead of a mapped table; compound archive extensions; a missing tool_call_id producing an empty artifact id.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20). Data as JSON: /api/errors/8b3dbbc3ae879632. Report an issue: GitHub.