Reject colliding inline workflow paths during tool validation

Two inline file paths that differ only by case, or a file that is also
an ancestor directory of another, used to surface as platform-dependent
low-level I/O errors naming a private temporary directory, and the
case-only case succeeded on Linux while failing on macOS. Validate both
shapes in fabro_run_create input validation so callers get a clear
message before any staging or registration happens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-09-04 16:26:45 -04:00
parent fefc236ab9
commit 001ed111fb

View file

@ -552,6 +552,9 @@ fn validate_workflow_source(
source.entrypoint
)));
}
fabro_types::validate_workflow_path_collisions(source.files.keys())
.map_err(|err| ToolError::message(format!("inline workflow {err}")))?;
validate_inline_paths_distinct_ignoring_case(&source.files)?;
let mut total_bytes = 0usize;
for (path, content) in &source.files {
let bytes = content.len();
@ -577,6 +580,24 @@ fn validate_workflow_source(
}
}
/// Inline files are staged on, and later checked out to, filesystems that may
/// be case-insensitive, so two paths that differ only by case would silently
/// overwrite each other there. Reject them up front with a clear message
/// instead of surfacing a platform-dependent I/O error later.
fn validate_inline_paths_distinct_ignoring_case(
files: &BTreeMap<WorkflowPath, String>,
) -> ToolResult<()> {
let mut seen: HashMap<String, &WorkflowPath> = HashMap::with_capacity(files.len());
for path in files.keys() {
if let Some(existing) = seen.insert(path.as_str().to_lowercase(), path) {
return Err(ToolError::message(format!(
"inline workflow files `{existing}` and `{path}` differ only by case; workflow files must stay distinct on case-insensitive filesystems"
)));
}
}
Ok(())
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreateRunsResult {
pub runs: Vec<CreatedRunResult>,
@ -925,6 +946,33 @@ mod tests {
.contains("entrypoint")
);
for (files, expected) in [
(json!({ "a": "x", "a/b.md": "y" }), "paths collide"),
(
json!({ "main.fabro": "digraph W {}", "Prompt.md": "x", "prompt.md": "y" }),
"differ only by case",
),
] {
let entrypoint = files
.as_object()
.and_then(|files| files.keys().next().cloned())
.unwrap();
let colliding: FabroRunCreateParams = serde_json::from_value(json!({
"runs": [{
"workflow": {
"kind": "inline",
"entrypoint": entrypoint,
"files": files
}
}]
}))
.unwrap();
let error = ValidatedCreateRuns::try_from(colliding)
.expect_err("colliding inline paths must fail validation before any staging")
.to_string();
assert!(error.contains(expected), "unexpected error: {error}");
}
let too_many = (0..=fabro_types::MAX_WORKFLOW_VERSION_FILES)
.map(|index| (format!("files/{index}.md"), json!("x")))
.collect::<serde_json::Map<_, _>>();