{"record":{"id":"8b3dbbc3ae879632","repo":"Hmbown/CodeWhale","slug":"invalidinput","errorCode":"InvalidInput","errorMessage":"artifact id and extension must contain safe ASCII characters","messagePattern":"artifact id and extension must contain safe ASCII characters","errorType":"validation","errorClass":"std::io::Error","httpStatus":null,"severity":"error","filePath":"crates/tui/src/artifacts.rs","lineNumber":85,"sourceCode":"\n#[must_use]\npub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf {\n    PathBuf::from(ARTIFACTS_DIR_NAME).join(format!(\"{artifact_id}.txt\"))\n}\n\nfn session_artifact_relative_path_with_extension(\n    artifact_id: &str,\n    extension: &str,\n) -> io::Result<PathBuf> {\n    let artifact_id = sanitize_id_component(artifact_id);\n    let extension = extension.trim_start_matches('.').to_ascii_lowercase();\n    if artifact_id.is_empty()\n        || extension.is_empty()\n        || !extension\n            .chars()\n            .all(|character| character.is_ascii_alphanumeric())\n    {\n        return Err(io::Error::new(\n            io::ErrorKind::InvalidInput,\n            \"artifact id and extension must contain safe ASCII characters\",\n        ));\n    }\n    Ok(PathBuf::from(ARTIFACTS_DIR_NAME).join(format!(\"{artifact_id}.{extension}\")))\n}\n\nfn artifact_sessions_root() -> Option<PathBuf> {\n    #[cfg(test)]\n    if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT\n        .lock()\n        .unwrap_or_else(|err| err.into_inner())\n        .clone()\n    {\n        return Some(root);\n    }\n\n    // Honor explicit HOME/USERPROFILE isolation before consulting the host","sourceCodeStart":67,"sourceCodeEnd":103,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/0c42157ee52f9d55af2b506d71b46249910f77d3/crates/tui/src/artifacts.rs#L67-L103","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Map MIME types to simple alphanumeric extensions before writing (image/svg+xml -> svg, application/x-tar -> tar, image/jpeg -> jpg)","Filter the extension: ext.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>() and reject if empty","Build ids via artifact_id_for_tool_call(tool_call_id) so the sanitized id is never empty","Default a missing extension to a known-good value such as bin"],"exampleFix":"// before\nlet (abs, rel) = write_session_artifact_bytes(sid, art_id, \"svg+xml\", bytes)?; // InvalidInput\n\n// after\nlet ext: String = \"svg+xml\".chars().filter(|c| c.is_ascii_alphanumeric()).take(4).collect::<String>(); // \"svgxml\"\nlet (abs, rel) = write_session_artifact_bytes(sid, art_id, &ext, bytes)?;","handlingStrategy":"validation","validationCode":"fn safe_extension(ext: &str) -> Option<String> {\n    let ext = ext.trim_start_matches('.').to_ascii_lowercase();\n    (!ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())).then_some(ext)\n}\n\nlet ext = safe_extension(raw_ext).unwrap_or_else(|| \"bin\".into());","typeGuard":"fn is_valid_artifact_extension(ext: &str) -> bool {\n    let ext = ext.trim_start_matches('.').to_ascii_lowercase();\n    !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())\n}","tryCatchPattern":null,"preventionTips":["Map MIME types to a fixed extension table instead of passing subtype strings through","Default unknown/empty extensions to 'bin' rather than failing the whole fetch","Build artifact ids via artifact_id_for_tool_call so sanitized ids are never empty"],"tags":["validation","artifacts","file-extension","sanitization"],"backgroundTag":"input-validation-failed","analyzedSha":"0c42157ee52f9d55af2b506d71b46249910f77d3","analyzedAt":"2026-08-20T21:50:45.477Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}