use std::borrow::Cow;
use std::fmt;
use jsonschema::Validator;
use regex::Regex;
use serde_json::Value;
use serde_json::json;
use thiserror::Error;
const SUPPORTED_META_SCHEMA: &str = "http://json-schema.org/draft-07/schema#";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaKind {
Metadata,
Entries,
}
impl fmt::Display for SchemaKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SchemaKind::Metadata => f.write_str("metadata_schema"),
SchemaKind::Entries => f.write_str("entries_schema"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RuleViolation {
#[error("a workflow is required by this bucket, but none was selected")]
WorkflowRequired,
#[error("a commit message is required by this workflow, but none was provided")]
MessageRequired,
#[error("package name {name:?} does not match the required handle_pattern {pattern:?}")]
HandleMismatch { name: String, pattern: String },
#[error("package metadata does not satisfy the workflow's metadata_schema: {0}")]
MetadataInvalid(String),
#[error("package entries do not satisfy the workflow's entries_schema: {0}")]
EntriesInvalid(String),
}
#[derive(Debug, Error)]
pub enum WorkflowValidationError {
#[error("workflow {kind} is not a valid Draft-7 JSON Schema: {reason}")]
InvalidSchema { kind: SchemaKind, reason: String },
#[error("workflow {kind} uses `$ref`, which is not supported")]
UnsupportedRef { kind: SchemaKind },
#[error(
"workflow {kind} declares `$schema`: {value}, which is not supported \
(only the Draft-7 meta-schema {SUPPORTED_META_SCHEMA:?} is supported)"
)]
UnsupportedMetaSchema { kind: SchemaKind, value: String },
#[error("workflow handle_pattern {pattern:?} is not a valid regular expression: {reason}")]
InvalidHandlePattern { pattern: String, reason: String },
#[error("package does not satisfy the workflow:{}", render_violations(.0))]
Rejected(Vec<RuleViolation>),
}
fn render_violations(violations: &[RuleViolation]) -> String {
use std::fmt::Write;
let mut out = String::new();
for violation in violations {
let _ = write!(out, "\n - {violation}");
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct WorkflowRules {
pub handle_pattern: Option<String>,
pub is_message_required: bool,
pub metadata_schema: Option<Value>,
pub entries_schema: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EntryView<'a> {
pub logical_key: Cow<'a, str>,
pub size: u64,
pub meta: Option<&'a Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PackageCandidate<'a> {
pub name: &'a str,
pub message: Option<&'a str>,
pub user_meta: Option<&'a Value>,
pub entries: &'a [EntryView<'a>],
}
pub fn validate_package(
rules: Option<&WorkflowRules>,
is_workflow_required: bool,
package: &PackageCandidate<'_>,
) -> Result<(), WorkflowValidationError> {
let Some(rules) = rules else {
return if is_workflow_required {
Err(WorkflowValidationError::Rejected(vec![
RuleViolation::WorkflowRequired,
]))
} else {
Ok(())
};
};
let mut violations = Vec::new();
check_field_rules(rules, package, &mut violations)?;
check_entries_rule(rules, package, &mut violations)?;
finish(violations)
}
pub fn validate_candidate_fields(
rules: &WorkflowRules,
package: &PackageCandidate<'_>,
) -> Result<(), WorkflowValidationError> {
let mut violations = Vec::new();
check_field_rules(rules, package, &mut violations)?;
finish(violations)
}
fn check_field_rules(
rules: &WorkflowRules,
package: &PackageCandidate<'_>,
violations: &mut Vec<RuleViolation>,
) -> Result<(), WorkflowValidationError> {
if rules.is_message_required && package.message.is_none_or(str::is_empty) {
violations.push(RuleViolation::MessageRequired);
}
if let Some(pattern) = &rules.handle_pattern {
let regex =
Regex::new(pattern).map_err(|err| WorkflowValidationError::InvalidHandlePattern {
pattern: pattern.clone(),
reason: err.to_string(),
})?;
if !regex.is_match(package.name) {
violations.push(RuleViolation::HandleMismatch {
name: package.name.to_string(),
pattern: pattern.clone(),
});
}
}
if let Some(schema) = &rules.metadata_schema {
let validator = compile_schema(schema, SchemaKind::Metadata)?;
let empty = json!({});
let meta = package.user_meta.unwrap_or(&empty);
if let Some(reason) = collect_errors(&validator, meta) {
violations.push(RuleViolation::MetadataInvalid(reason));
}
}
Ok(())
}
fn check_entries_rule(
rules: &WorkflowRules,
package: &PackageCandidate<'_>,
violations: &mut Vec<RuleViolation>,
) -> Result<(), WorkflowValidationError> {
if let Some(schema) = &rules.entries_schema {
let validator = compile_schema(schema, SchemaKind::Entries)?;
let entries = project_entries(package.entries);
if let Some(reason) = collect_errors(&validator, &entries) {
violations.push(RuleViolation::EntriesInvalid(reason));
}
}
Ok(())
}
fn finish(violations: Vec<RuleViolation>) -> Result<(), WorkflowValidationError> {
if violations.is_empty() {
Ok(())
} else {
Err(WorkflowValidationError::Rejected(violations))
}
}
fn project_entries(entries: &[EntryView<'_>]) -> Value {
Value::Array(
entries
.iter()
.map(|entry| {
json!({
"logical_key": entry.logical_key.as_ref(),
"size": entry.size,
"meta": entry.meta.cloned().unwrap_or_else(|| json!({})),
})
})
.collect(),
)
}
fn compile_schema(schema: &Value, kind: SchemaKind) -> Result<Validator, WorkflowValidationError> {
if contains_ref(schema) {
return Err(WorkflowValidationError::UnsupportedRef { kind });
}
if let Value::Object(map) = schema
&& let Some(meta_schema) = map.get("$schema")
&& meta_schema.as_str() != Some(SUPPORTED_META_SCHEMA)
{
return Err(WorkflowValidationError::UnsupportedMetaSchema {
kind,
value: meta_schema.to_string(),
});
}
build_annotation_only_draft7(schema).map_err(|err| WorkflowValidationError::InvalidSchema {
kind,
reason: err.to_string(),
})
}
pub(crate) fn compile_config_schema(schema: &Value) -> Validator {
build_annotation_only_draft7(schema)
.expect("vendored workflows-config schema must compile as Draft-7")
}
fn build_annotation_only_draft7(
schema: &Value,
) -> Result<Validator, jsonschema::ValidationError<'static>> {
jsonschema::draft7::options()
.should_validate_formats(false)
.build(schema)
}
fn contains_ref(value: &Value) -> bool {
match value {
Value::Object(map) => map.contains_key("$ref") || map.values().any(contains_ref),
Value::Array(items) => items.iter().any(contains_ref),
_ => false,
}
}
fn collect_errors(validator: &Validator, instance: &Value) -> Option<String> {
let messages: Vec<String> = validator
.iter_errors(instance)
.map(|err| format!("{err} (at {})", err.instance_path()))
.collect();
if messages.is_empty() {
None
} else {
Some(messages.join("; "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
fn strict_rules() -> WorkflowRules {
WorkflowRules {
handle_pattern: Some("^team/".to_string()),
is_message_required: true,
metadata_schema: Some(json!({
"type": "object",
"required": ["owner"],
"properties": { "owner": { "type": "string" } }
})),
entries_schema: Some(json!({
"type": "array",
"items": {
"type": "object",
"required": ["logical_key", "size", "meta"],
"properties": {
"logical_key": { "type": "string" },
"size": { "type": "integer", "maximum": 100 },
"meta": { "type": "object" }
}
}
})),
}
}
fn valid_meta() -> Value {
json!({ "owner": "alice" })
}
fn candidate<'a>(
name: &'a str,
message: Option<&'a str>,
user_meta: Option<&'a Value>,
entries: &'a [EntryView<'a>],
) -> PackageCandidate<'a> {
PackageCandidate {
name,
message,
user_meta,
entries,
}
}
#[test]
fn no_workflow_not_required_passes() {
let entries = [];
let pkg = candidate("anything/goes", None, None, &entries);
assert!(validate_package(None, false, &pkg).is_ok());
}
#[test]
fn no_workflow_required_is_rejected() {
let entries = [];
let pkg = candidate("anything/goes", None, None, &entries);
let err = validate_package(None, true, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v) if v == vec![RuleViolation::WorkflowRequired]
));
}
#[test]
fn fully_valid_package_passes_cleanly() {
let rules = strict_rules();
let meta = valid_meta();
let entry_meta = json!({ "k": "v" });
let entries = [EntryView {
logical_key: "data/a.csv".into(),
size: 42,
meta: Some(&entry_meta),
}];
let pkg = candidate(
"team/dataset",
Some("initial commit"),
Some(&meta),
&entries,
);
assert!(validate_package(Some(&rules), true, &pkg).is_ok());
}
#[test]
fn metadata_pass_and_fail() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"type": "object",
"required": ["owner"],
"properties": { "owner": { "type": "string" } }
})),
entries_schema: None,
};
let entries = [];
let ok_meta = json!({ "owner": "bob" });
let pkg = candidate("p", None, Some(&ok_meta), &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
let bad_meta = json!({ "owner": 7 });
let pkg = candidate("p", None, Some(&bad_meta), &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::MetadataInvalid(_)])
));
}
#[test]
fn absent_metadata_validated_as_empty_object() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"type": "object",
"required": ["owner"]
})),
entries_schema: None,
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::MetadataInvalid(_)])
));
}
#[test]
fn entries_pass_and_fail() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: None,
entries_schema: Some(json!({
"type": "array",
"items": {
"type": "object",
"properties": { "size": { "type": "integer", "maximum": 100 } }
}
})),
};
let small = [EntryView {
logical_key: "a.txt".into(),
size: 10,
meta: None,
}];
let pkg = candidate("p", None, None, &small);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
let big = [EntryView {
logical_key: "a.txt".into(),
size: 999,
meta: None,
}];
let pkg = candidate("p", None, None, &big);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::EntriesInvalid(_)])
));
}
#[test]
fn handle_pattern_match_and_miss() {
let rules = WorkflowRules {
handle_pattern: Some("^team/".to_string()),
is_message_required: false,
metadata_schema: None,
entries_schema: None,
};
let entries = [];
let pkg = candidate("team/data", None, None, &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
let pkg = candidate("other/data", None, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::HandleMismatch { .. }])
));
}
#[test]
fn handle_pattern_is_a_substring_match() {
let rules = WorkflowRules {
handle_pattern: Some("staging".to_string()),
is_message_required: false,
metadata_schema: None,
entries_schema: None,
};
let entries = [];
let pkg = candidate("team/staging-2024", None, None, &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
let pkg = candidate("team/prod", None, None, &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_err());
}
#[test]
fn message_required() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: true,
metadata_schema: None,
entries_schema: None,
};
let entries = [];
let pkg = candidate("p", Some("has a message"), None, &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
for message in [None, Some("")] {
let pkg = candidate("p", message, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if v == vec![RuleViolation::MessageRequired]
));
}
}
#[test]
fn multiple_violations_are_collected() {
let rules = strict_rules();
let entries = []; let bad_meta = json!({});
let pkg = candidate("nope/data", None, Some(&bad_meta), &entries);
let err = validate_package(Some(&rules), true, &pkg).unwrap_err();
let WorkflowValidationError::Rejected(violations) = err else {
panic!("expected Rejected, got {err:?}");
};
assert!(violations.contains(&RuleViolation::MessageRequired));
assert!(
violations
.iter()
.any(|v| matches!(v, RuleViolation::HandleMismatch { .. }))
);
assert!(
violations
.iter()
.any(|v| matches!(v, RuleViolation::MetadataInvalid(_)))
);
}
#[test]
fn ref_in_schema_is_unsupported() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"type": "object",
"properties": { "owner": { "$ref": "#/definitions/x" } }
})),
entries_schema: None,
};
let entries = [];
let meta = valid_meta();
let pkg = candidate("p", None, Some(&meta), &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::UnsupportedRef {
kind: SchemaKind::Metadata
}
));
}
#[test]
fn format_keyword_is_annotation_only() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"type": "object",
"properties": { "Date": { "type": "string", "format": "date" } }
})),
entries_schema: None,
};
let entries = [];
let meta = json!({ "Date": "July 8, 2026" });
let pkg = candidate("p", None, Some(&meta), &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
let bad = json!({ "Date": 7 });
let pkg = candidate("p", None, Some(&bad), &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::MetadataInvalid(_)])
));
}
#[test]
fn draft7_meta_schema_declaration_is_accepted() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["owner"]
})),
entries_schema: None,
};
let entries = [];
let meta = valid_meta();
let pkg = candidate("p", None, Some(&meta), &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_ok());
}
#[test]
fn unsupported_meta_schema_is_a_hard_error() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object"
})),
entries_schema: None,
};
let entries = [];
let meta = valid_meta();
let pkg = candidate("p", None, Some(&meta), &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::UnsupportedMetaSchema {
kind: SchemaKind::Metadata,
..
}
));
assert!(err.to_string().contains("2020-12"));
}
#[test]
fn non_string_meta_schema_is_a_hard_error() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: None,
entries_schema: Some(json!({
"$schema": 42,
"type": "array"
})),
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::UnsupportedMetaSchema {
kind: SchemaKind::Entries,
..
}
));
assert!(err.to_string().contains("42"));
}
#[test]
fn invalid_schema_is_a_hard_error() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: None,
entries_schema: Some(json!({ "type": 123 })),
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::InvalidSchema {
kind: SchemaKind::Entries,
..
}
));
}
#[test]
fn invalid_handle_pattern_is_a_hard_error() {
let rules = WorkflowRules {
handle_pattern: Some("(unclosed".to_string()),
is_message_required: false,
metadata_schema: None,
entries_schema: None,
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
let err = validate_package(Some(&rules), false, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::InvalidHandlePattern { .. }
));
}
#[test]
fn candidate_fields_pass_when_message_meta_and_name_satisfy_rules() {
let rules = strict_rules();
let meta = valid_meta();
let entries = [];
let pkg = candidate("team/dataset", Some("msg"), Some(&meta), &entries);
assert!(validate_candidate_fields(&rules, &pkg).is_ok());
}
#[test]
fn candidate_fields_skip_entries_schema() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: None,
entries_schema: Some(json!({ "type": "array", "minItems": 1 })),
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
assert!(validate_package(Some(&rules), false, &pkg).is_err());
assert!(validate_candidate_fields(&rules, &pkg).is_ok());
}
#[test]
fn candidate_fields_flag_missing_message() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: true,
metadata_schema: None,
entries_schema: None,
};
let entries = [];
for message in [None, Some("")] {
let pkg = candidate("p", message, None, &entries);
let err = validate_candidate_fields(&rules, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v) if v == vec![RuleViolation::MessageRequired]
));
}
}
#[test]
fn candidate_fields_flag_metadata_and_handle_together() {
let rules = strict_rules();
let bad_meta = json!({});
let entries = [];
let pkg = candidate("nope/data", Some("msg"), Some(&bad_meta), &entries);
let WorkflowValidationError::Rejected(violations) =
validate_candidate_fields(&rules, &pkg).unwrap_err()
else {
panic!("expected Rejected");
};
assert!(
violations
.iter()
.any(|v| matches!(v, RuleViolation::HandleMismatch { .. }))
);
assert!(
violations
.iter()
.any(|v| matches!(v, RuleViolation::MetadataInvalid(_)))
);
}
#[test]
fn candidate_fields_absent_metadata_validated_as_empty_object() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({ "type": "object", "required": ["owner"] })),
entries_schema: None,
};
let entries = [];
let pkg = candidate("p", None, None, &entries);
let err = validate_candidate_fields(&rules, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::Rejected(v)
if matches!(v.as_slice(), [RuleViolation::MetadataInvalid(_)])
));
}
#[test]
fn candidate_fields_surface_metadata_schema_misconfig_as_hard_error() {
let rules = WorkflowRules {
handle_pattern: None,
is_message_required: false,
metadata_schema: Some(json!({
"type": "object",
"properties": { "owner": { "$ref": "#/definitions/x" } }
})),
entries_schema: None,
};
let entries = [];
let meta = valid_meta();
let pkg = candidate("p", Some("m"), Some(&meta), &entries);
let err = validate_candidate_fields(&rules, &pkg).unwrap_err();
assert!(matches!(
err,
WorkflowValidationError::UnsupportedRef {
kind: SchemaKind::Metadata
}
));
}
}