-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evidence): accept declared unresolved sources #729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2127,25 +2127,37 @@ fn load_fixtures( | |
| if fixtures.contains_key(path) { | ||
| continue; | ||
| } | ||
| let fixture = load_fixture(path, files).map_err(|error| error.in_artifact(path))?; | ||
| let declared_unresolved = config | ||
| .sources | ||
| .get(requirement.initial_source()) | ||
| .is_some_and(|source| source.unresolved_problem().is_some()); | ||
| let fixture = load_fixture(path, files, declared_unresolved) | ||
| .map_err(|error| error.in_artifact(path))?; | ||
| fixtures.insert(path.to_owned(), fixture); | ||
| } | ||
| Ok(fixtures) | ||
| } | ||
|
|
||
| fn load_fixture(path: &str, files: &BTreeMap<String, Vec<u8>>) -> Result<YamlValue, BundleError> { | ||
| fn load_fixture( | ||
| path: &str, | ||
| files: &BTreeMap<String, Vec<u8>>, | ||
| declared_unresolved: bool, | ||
| ) -> Result<YamlValue, BundleError> { | ||
| let bytes = files | ||
| .get(path) | ||
| .ok_or(invalid_artifact("fixture file is missing"))?; | ||
| let text = | ||
| std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fixture file is not UTF-8"))?; | ||
| let fixture: YamlValue = | ||
| serde_norway::from_str(text).map_err(|_| invalid_artifact("fixture YAML is invalid"))?; | ||
| validate_fixture_coverage(&fixture)?; | ||
| validate_fixture_coverage(&fixture, declared_unresolved)?; | ||
| Ok(fixture) | ||
| } | ||
|
|
||
| fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { | ||
| fn validate_fixture_coverage( | ||
| fixture: &YamlValue, | ||
| declared_unresolved: bool, | ||
| ) -> Result<(), BundleError> { | ||
| let root = fixture | ||
| .as_mapping() | ||
| .ok_or(invalid_artifact("fixture root must be a mapping"))?; | ||
|
|
@@ -2170,7 +2182,24 @@ fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { | |
| if id.is_empty() || id.len() > 128 || !ids.insert(id) { | ||
| return Err(invalid_artifact("fixture case id is invalid or duplicated")); | ||
| } | ||
| categories.observe(id); | ||
| let mapping = case | ||
| .as_mapping() | ||
| .ok_or(invalid_artifact("fixture case must be a mapping"))?; | ||
| let fixture_declares_unresolved = match mapping.get("declaredUnresolved") { | ||
| Some(YamlValue::Bool(true)) if declared_unresolved => true, | ||
| Some(YamlValue::Bool(true)) => { | ||
| return Err(invalid_artifact( | ||
| "fixture declared unresolved without a source declaration", | ||
| )); | ||
| } | ||
| Some(_) => { | ||
| return Err(invalid_artifact( | ||
| "fixture declared-unresolved marker must be true", | ||
| )); | ||
| } | ||
| None => false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an initial source declares AGENTS.md reference: products/evidence/AGENTS.md:L96-L102 Useful? React with 👍 / 👎. |
||
| }; | ||
| categories.observe(id, fixture_declares_unresolved); | ||
| } | ||
| if !categories.complete() { | ||
| return Err(invalid_artifact("fixture category coverage is incomplete")); | ||
|
|
@@ -2191,13 +2220,19 @@ struct FixtureCategories { | |
| } | ||
|
|
||
| impl FixtureCategories { | ||
| fn observe(&mut self, id: &str) { | ||
| fn observe(&mut self, id: &str, declared_unresolved: bool) { | ||
| self.positive |= id == "positive"; | ||
| self.negative |= id.starts_with("negative"); | ||
| self.boundary |= id.starts_with("boundary"); | ||
| self.missing |= id.starts_with("missing"); | ||
| self.no_match |= id == "no-match"; | ||
| self.ambiguous |= id.starts_with("ambiguous"); | ||
| // The configured source has already collapsed its hidden no-match and | ||
| // ambiguous states into one exact transport outcome. Evidence cannot | ||
| // truthfully label the fixture as either branch, so the neutral case | ||
| // proves the public behavior shared by both completeness categories. | ||
| self.no_match |= declared_unresolved; | ||
| self.ambiguous |= declared_unresolved; | ||
| self.source_failure |= id == "source-failure"; | ||
| self.anti_reconstruction |= id == "anti-reconstruction"; | ||
| } | ||
|
|
@@ -3317,7 +3352,28 @@ mod tests { | |
| "synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: no-match}\n - {id: ambiguous}\n - {id: source-failure}\n - {id: anti-reconstruction}\n", | ||
| ) | ||
| .expect("fixture parses"); | ||
| assert!(validate_fixture_coverage(&fixture).is_ok()); | ||
| assert!(validate_fixture_coverage(&fixture, false).is_ok()); | ||
| } | ||
|
|
||
| /// A provider that deliberately collapses hidden no-match and ambiguity | ||
| /// into one configured wire outcome leaves Evidence no truthful basis for | ||
| /// inventing two extraction responses. The one neutral, data-free case is | ||
| /// therefore sufficient for both public-collapse coverage categories. | ||
| #[test] | ||
| fn declared_unresolved_fixture_neutrally_covers_hidden_lookup_categories() { | ||
| let fixture: YamlValue = serde_norway::from_str( | ||
| "synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: unresolved, declaredUnresolved: true}\n - {id: source-failure}\n - {id: anti-reconstruction}\n", | ||
| ) | ||
| .expect("fixture parses"); | ||
|
|
||
| assert!(validate_fixture_coverage(&fixture, true).is_ok()); | ||
| assert!(validate_fixture_coverage(&fixture, false).is_err()); | ||
|
|
||
| let false_marker: YamlValue = serde_norway::from_str( | ||
| "synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: unresolved, declaredUnresolved: false}\n - {id: source-failure}\n - {id: anti-reconstruction}\n", | ||
| ) | ||
| .expect("fixture parses"); | ||
| assert!(validate_fixture_coverage(&false_marker, true).is_err()); | ||
| } | ||
|
|
||
| #[cfg(unix)] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1985,6 +1985,11 @@ pub enum SourceConfig { | |
| HttpJson { | ||
| base_url: String, | ||
| posture: AcquisitionPosture, | ||
| /// Optional exact upstream Problem Details tuple which means that the | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For any PR containing this commit, the DCO job will fail because its message has no AGENTS.md reference: AGENTS.md:L274-L274 Useful? React with 👍 / 👎. |
||
| /// source deliberately did not resolve this lookup. The transport | ||
| /// remains source-neutral: no provider code is built in. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| unresolved_problem: Option<DeclaredUnresolvedProblem>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| tls_trust_profile: Option<String>, | ||
| authentication: Box<SourceAuthentication>, | ||
|
|
@@ -2029,6 +2034,7 @@ impl SourceConfig { | |
| authentication, | ||
| request, | ||
| batch, | ||
| unresolved_problem, | ||
| .. | ||
| } => { | ||
| validate_source_origin(base_url)?; | ||
|
|
@@ -2053,6 +2059,14 @@ impl SourceConfig { | |
| } | ||
| authentication.validate()?; | ||
| request.validate()?; | ||
| if let Some(problem) = unresolved_problem { | ||
| problem.validate()?; | ||
| if batch.is_some() { | ||
| return invalid( | ||
| "declared unresolved problems are not supported by source batching", | ||
| ); | ||
| } | ||
| } | ||
| if let Some(batch) = batch { | ||
| if request.path.is_none() || request.path_template.is_some() { | ||
| return invalid("source batch optimization requires a fixed request path"); | ||
|
|
@@ -2170,6 +2184,19 @@ impl SourceConfig { | |
| } | ||
| } | ||
|
|
||
| /// The exact source-neutral unresolved tuple this HTTP source recognizes. | ||
| /// | ||
| /// Callers receive only the governed declaration, never an upstream | ||
| /// Problem Details body. Statement sources cannot declare this outcome. | ||
| pub fn unresolved_problem(&self) -> Option<&DeclaredUnresolvedProblem> { | ||
| match self { | ||
| Self::HttpJson { | ||
| unresolved_problem, .. | ||
| } => unresolved_problem.as_ref(), | ||
| Self::SqliteExtract { .. } => None, | ||
| } | ||
| } | ||
|
|
||
| /// The reviewed statement artifact, where the transport runs one. | ||
| pub fn statement(&self) -> Option<&ArtifactPath> { | ||
| match self { | ||
|
|
@@ -2301,6 +2328,49 @@ impl SourceConfig { | |
| } | ||
| } | ||
|
|
||
| /// Exact, source-neutral Problem Details tuple an HTTP source may declare as | ||
| /// an explicit unresolved lookup outcome. | ||
| #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] | ||
| #[serde(rename_all = "camelCase", deny_unknown_fields)] | ||
| pub struct DeclaredUnresolvedProblem { | ||
| pub status: u16, | ||
| #[serde(rename = "type")] | ||
| pub type_uri: String, | ||
| pub code: String, | ||
| } | ||
|
|
||
| impl DeclaredUnresolvedProblem { | ||
| fn validate(&self) -> Result<(), ConfigError> { | ||
| if self.status != 404 { | ||
| return invalid("declared unresolved problem status must be 404"); | ||
| } | ||
| if self.type_uri.chars().count() > 512 { | ||
| return invalid("declared unresolved problem type is too long"); | ||
| } | ||
| let uri = Url::parse(&self.type_uri) | ||
| .map_err(|_| ConfigError::Invalid("declared unresolved problem type is invalid"))?; | ||
| if uri.scheme() != "https" | ||
| || uri.host().is_none() | ||
| || !uri.username().is_empty() | ||
| || uri.password().is_some() | ||
| { | ||
| return invalid("declared unresolved problem type must be an absolute HTTPS URI"); | ||
| } | ||
| let bytes = self.code.as_bytes(); | ||
| if !(1..=64).contains(&bytes.len()) | ||
| || !matches!(bytes.first(), Some(b'a'..=b'z')) | ||
| || !bytes.iter().all(|byte| { | ||
| byte.is_ascii_lowercase() | ||
| || byte.is_ascii_digit() | ||
| || matches!(byte, b'.' | b'_' | b'-') | ||
| }) | ||
| { | ||
| return invalid("declared unresolved problem code is invalid"); | ||
| } | ||
| Ok(()) | ||
| } | ||
| } | ||
|
|
||
| /// One request value filled from a named field of a named selector profile. | ||
| #[derive(Debug, Clone, Copy, Eq, PartialEq)] | ||
| pub struct SelectorBinding<'a> { | ||
|
|
@@ -7558,6 +7628,96 @@ outboundTls: | |
| )), | ||
| "the batch adapter identity emitted to audit must use the closed local grammar" | ||
| ); | ||
|
|
||
| let unresolved_batch = edited( | ||
| &document, | ||
| " batch:\n", | ||
| " unresolvedProblem: {status: 404, type: https://id.example.invalid/problems/unresolved, code: consultation.unresolved}\n batch:\n", | ||
| ); | ||
| assert_eq!( | ||
| EvidenceConfig::parse_yaml(unresolved_batch.as_bytes()).err(), | ||
| Some(ConfigError::Invalid( | ||
| "declared unresolved problems are not supported by source batching" | ||
| )), | ||
| "one physical response cannot resolve one logical batch item" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn declared_unresolved_problem_tuple_is_closed_and_bounded() { | ||
| DeclaredUnresolvedProblem { | ||
| status: 404, | ||
| type_uri: "https://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: "consultation.unresolved".to_owned(), | ||
| } | ||
| .validate() | ||
| .expect("the exact source-neutral tuple validates"); | ||
|
|
||
| for (label, problem) in [ | ||
| ( | ||
| "status", | ||
| DeclaredUnresolvedProblem { | ||
| status: 403, | ||
| type_uri: "https://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: "consultation.unresolved".to_owned(), | ||
| }, | ||
| ), | ||
| ( | ||
| "type", | ||
| DeclaredUnresolvedProblem { | ||
| status: 404, | ||
| type_uri: "http://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: "consultation.unresolved".to_owned(), | ||
| }, | ||
| ), | ||
| ( | ||
| "code", | ||
| DeclaredUnresolvedProblem { | ||
| status: 404, | ||
| type_uri: "https://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: "Consultation Unresolved".to_owned(), | ||
| }, | ||
| ), | ||
| ] { | ||
| assert!(problem.validate().is_err(), "{label}"); | ||
| } | ||
|
|
||
| let overlong_type = format!("https://id.example.invalid/problems/{}", "a".repeat(513)); | ||
| let candidate = acceptance_fixture().replacen( | ||
| " posture: field-projected\n", | ||
| &format!( | ||
| " posture: field-projected\n unresolvedProblem: {{status: 404, type: {overlong_type}, code: consultation.unresolved}}\n" | ||
| ), | ||
| 1, | ||
| ); | ||
| assert_eq!( | ||
| EvidenceConfig::parse_yaml(candidate.as_bytes()).err(), | ||
| Some(ConfigError::Invalid( | ||
| "declared unresolved problem type is too long" | ||
| )), | ||
| "runtime parsing must enforce the schema's 512-character type ceiling" | ||
| ); | ||
|
|
||
| assert!( | ||
| DeclaredUnresolvedProblem { | ||
| status: 404, | ||
| type_uri: "https://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: format!("a{}", "x".repeat(63)), | ||
| } | ||
| .validate() | ||
| .is_ok(), | ||
| "the bounded ASCII code grammar admits exactly 64 characters" | ||
| ); | ||
| assert!( | ||
| DeclaredUnresolvedProblem { | ||
| status: 404, | ||
| type_uri: "https://id.example.invalid/problems/unresolved".to_owned(), | ||
| code: format!("a{}", "x".repeat(64)), | ||
| } | ||
| .validate() | ||
| .is_err(), | ||
| "the bounded ASCII code grammar rejects 65 characters" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When two requirements reference the same fixture path, this requirement-specific flag is computed only for the first one because the existing cache check at lines 2127-2129 skips every later reference. If the first requirement's initial source declares
unresolvedProblembut a later one's does not,Bundle::loadaccepts the fixture'sdeclaredUnresolvedcase without ever validating it against the later source; configuration validation does not prohibit shared fixture paths, so a directly loaded runtime bundle can bypass this new startup invariant depending on requirement order. Validate the marker for every referencing requirement before reusing the parsed fixture, or reject duplicate references at bundle load.AGENTS.md reference: products/evidence/AGENTS.md:L85-L94
Useful? React with 👍 / 👎.