mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-13 23:14:17 +00:00
Pin sibling config resolution to the supplied file keys
Supplied files are staged on the host filesystem and WorkflowLocation probes the fixed sibling name `workflow.toml` there. A request that supplied `Workflow.toml` beside its graph therefore attached the config on a case-insensitive host (and then failed the not-supplied check), while the identical request on ext4 registered a version with no config. The outcome of a content-addressed registration depended on the server's filesystem. After collection, every version is checked against the supplied map: when no exact sibling `workflow.toml` was supplied, no supplied key may alias that name under the same case and normalization rules the tool already applies to supplied keys. A supplied sibling config still attaches only to the graph it selects, matching checkouts, since several graphs may share one directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9bc6bf4227
commit
97a7bb06e5
2 changed files with 114 additions and 7 deletions
|
|
@ -59,18 +59,50 @@ fn collect_in_staging(
|
|||
},
|
||||
})?;
|
||||
let closure = crate::collect_workflow_versions_at_location(&location, &root, entrypoint)?;
|
||||
// A case-insensitive host must not satisfy a reference that is missing
|
||||
// from the supplied tree under its exact key.
|
||||
for (_, version) in closure.versions() {
|
||||
for path in version.version().files().keys() {
|
||||
if !files.contains_key(path) {
|
||||
return Err(WorkflowVersionCollectError::NotSupplied { path: path.clone() });
|
||||
}
|
||||
}
|
||||
confine_to_supplied(version.version(), files)?;
|
||||
}
|
||||
Ok(closure)
|
||||
}
|
||||
|
||||
/// The collector resolves references against the staged tree, so its result
|
||||
/// can depend on the host filesystem's case and normalization rules. Reject
|
||||
/// every version whose collected files are not exactly the supplied keys, and
|
||||
/// pin the one implicit probe (the sibling `workflow.toml`) to the supplied
|
||||
/// map so the same request packages identically on every host.
|
||||
fn confine_to_supplied(
|
||||
version: &fabro_types::WorkflowVersion,
|
||||
files: &BTreeMap<WorkflowPath, String>,
|
||||
) -> Result<()> {
|
||||
// A supplied sibling config attaches only when its `[workflow].graph`
|
||||
// selects this entrypoint, exactly as for a checkout; several graphs may
|
||||
// share one directory. When no exact sibling was supplied, the probe must
|
||||
// not find one either.
|
||||
let config_path = version.config_path();
|
||||
if !files.contains_key(&config_path) {
|
||||
let alias = files.keys().find(|path| {
|
||||
fabro_types::validate_workflow_source_paths([*path, &config_path]).is_err()
|
||||
});
|
||||
if let Some(alias) = alias {
|
||||
// A case-insensitive host would probe this key as the config and
|
||||
// a case-sensitive one would not; neither outcome is what was
|
||||
// asked for.
|
||||
return Err(WorkflowVersionCollectError::ConfigAlias {
|
||||
config_path,
|
||||
alias: alias.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A case-insensitive host must not satisfy a reference that is missing
|
||||
// from the supplied tree under its exact key.
|
||||
for path in version.files().keys() {
|
||||
if !files.contains_key(path) {
|
||||
return Err(WorkflowVersionCollectError::NotSupplied { path: path.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
|
@ -313,6 +345,73 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_config_resolution_does_not_depend_on_the_host_filesystem() {
|
||||
let graph = r#"digraph W { child [stack.child_workflow="sub/child.fabro"] }"#;
|
||||
let selects = |target: &str| format!("_version = 1\n[workflow]\ngraph = \"{target}\"\n");
|
||||
|
||||
// Exact sibling configs attach to the root and to the child.
|
||||
let attached = supplied("workflow.fabro", &[
|
||||
("workflow.fabro", graph),
|
||||
("workflow.toml", &selects("workflow.fabro")),
|
||||
("sub/child.fabro", "digraph Child {}"),
|
||||
("sub/workflow.toml", &selects("child.fabro")),
|
||||
]);
|
||||
for (_, version) in collect(&attached).versions() {
|
||||
let version = version.version();
|
||||
assert!(
|
||||
version.files().contains_key(&version.config_path()),
|
||||
"{} lost its config",
|
||||
version.entrypoint()
|
||||
);
|
||||
}
|
||||
|
||||
// A case variant of the implicit probe name is rejected everywhere,
|
||||
// instead of attaching on APFS and vanishing on ext4.
|
||||
for (entrypoint, files) in [
|
||||
("workflow.fabro", vec![
|
||||
("workflow.fabro", graph.to_owned()),
|
||||
("Workflow.toml", selects("workflow.fabro")),
|
||||
("sub/child.fabro", "digraph Child {}".to_owned()),
|
||||
]),
|
||||
("workflow.fabro", vec![
|
||||
("workflow.fabro", graph.to_owned()),
|
||||
("sub/child.fabro", "digraph Child {}".to_owned()),
|
||||
("sub/WORKFLOW.toml", selects("child.fabro")),
|
||||
]),
|
||||
] {
|
||||
let files = files
|
||||
.iter()
|
||||
.map(|(path, content)| (*path, content.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let input = supplied(entrypoint, &files);
|
||||
let error =
|
||||
collect_supplied_workflow_versions(&input.entrypoint, &input.files).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkflowVersionCollectError::ConfigAlias { .. }),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
// Several graphs may share a directory: a sibling config attaches only
|
||||
// to the graph it selects, as for a checkout.
|
||||
let shared_dir = supplied("workflow.fabro", &[
|
||||
("workflow.fabro", graph),
|
||||
("sub/child.fabro", "digraph Child {}"),
|
||||
("sub/other.fabro", "digraph Other {}"),
|
||||
("sub/workflow.toml", &selects("other.fabro")),
|
||||
]);
|
||||
let closure = collect(&shared_dir);
|
||||
let (_, child) = closure.versions().next().unwrap();
|
||||
assert_eq!(child.version().entrypoint().as_str(), "sub/child.fabro");
|
||||
assert!(
|
||||
!child
|
||||
.version()
|
||||
.files()
|
||||
.contains_key(&child.version().config_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_literal_scripts_without_executing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ pub enum WorkflowVersionCollectError {
|
|||
/// reference that differs from the supplied key).
|
||||
#[error("collected file `{path}` was not supplied")]
|
||||
NotSupplied { path: WorkflowPath },
|
||||
/// Supplied-content packaging: a supplied key differs from the implicit
|
||||
/// sibling config name `{config_path}` only by case or normalization, so
|
||||
/// hosts with different filesystem rules would package different trees.
|
||||
#[error("supplied file `{alias}` aliases the workflow config name `{config_path}`")]
|
||||
ConfigAlias {
|
||||
config_path: WorkflowPath,
|
||||
alias: WorkflowPath,
|
||||
},
|
||||
#[error("failed to stage supplied workflow files")]
|
||||
Stage {
|
||||
#[source]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue