1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6 SOFTWARE_ONTOLOGY_SCHEMA, SoftwareAssertionMode, SoftwareEntity, SoftwareEntityKind,
7 SoftwareFactState, SoftwarePredicate, SoftwareSourceKind, SoftwareStatement,
8 SoftwareStatementResolution,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SoftwareShapeSeverity {
15 Error,
16 Warning,
17}
18
19impl SoftwareShapeSeverity {
20 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::Error => "error",
23 Self::Warning => "warning",
24 }
25 }
26
27 pub fn parse(value: &str) -> Option<Self> {
28 match value {
29 "error" => Some(Self::Error),
30 "warning" => Some(Self::Warning),
31 _ => None,
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SoftwareShapeDiagnostic {
39 pub diagnostic_id: String,
40 pub shape_id: String,
41 pub code: String,
42 pub severity: SoftwareShapeSeverity,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub statement_id: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub entity_key: Option<String>,
47 pub field: String,
48 pub message: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct SoftwareShapeReport {
54 pub conforms: bool,
55 pub diagnostics: Vec<SoftwareShapeDiagnostic>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SoftwareAuthorityPolicy {
61 pub predicate: SoftwarePredicate,
62 pub declared_sources: Vec<SoftwareSourceKind>,
63 pub resolved_sources: Vec<SoftwareSourceKind>,
64 pub observed_sources: Vec<SoftwareSourceKind>,
65}
66
67pub fn software_authority_policy(predicate: SoftwarePredicate) -> SoftwareAuthorityPolicy {
69 use SoftwareSourceKind as Source;
70 match predicate {
71 SoftwarePredicate::DependsOn => SoftwareAuthorityPolicy {
72 predicate,
73 declared_sources: vec![Source::Manifest],
74 resolved_sources: vec![Source::Lockfile, Source::Sbom, Source::BuildAttestation],
75 observed_sources: Vec::new(),
76 },
77 SoftwarePredicate::Builds | SoftwarePredicate::Produces => SoftwareAuthorityPolicy {
78 predicate,
79 declared_sources: vec![Source::BuildFile, Source::Ci],
80 resolved_sources: vec![Source::BuildAttestation],
81 observed_sources: vec![Source::BuildAttestation],
82 },
83 SoftwarePredicate::Deploys | SoftwarePredicate::RunsAs => SoftwareAuthorityPolicy {
84 predicate,
85 declared_sources: vec![Source::Iac, Source::ServiceDefinition],
86 resolved_sources: Vec::new(),
87 observed_sources: vec![Source::Runtime, Source::Connector],
88 },
89 SoftwarePredicate::ProvidesApi | SoftwarePredicate::ConsumesApi => {
90 SoftwareAuthorityPolicy {
91 predicate,
92 declared_sources: vec![Source::ApiSchema, Source::Code],
93 resolved_sources: vec![Source::ApiSchema],
94 observed_sources: vec![Source::Runtime],
95 }
96 }
97 _ => SoftwareAuthorityPolicy {
98 predicate,
99 declared_sources: vec![Source::Manifest, Source::Documentation, Source::Code],
100 resolved_sources: Vec::new(),
101 observed_sources: vec![Source::Runtime, Source::Connector],
102 },
103 }
104}
105
106pub fn validate_software_shapes(
108 entities: &[SoftwareEntity],
109 statements: &[SoftwareStatement],
110) -> SoftwareShapeReport {
111 let mut diagnostics = Vec::new();
112 if let Err(error) = SOFTWARE_ONTOLOGY_SCHEMA.validate() {
113 diagnostics.push(SoftwareShapeDiagnostic {
114 diagnostic_id: diagnostic_id(
115 "ontology:SchemaShape",
116 "invalid_ontology_schema",
117 "software",
118 error.field,
119 ),
120 shape_id: "ontology:SchemaShape".to_owned(),
121 code: "invalid_ontology_schema".to_owned(),
122 severity: SoftwareShapeSeverity::Error,
123 statement_id: None,
124 entity_key: None,
125 field: error.field.to_owned(),
126 message: error.message,
127 });
128 return SoftwareShapeReport {
129 conforms: false,
130 diagnostics,
131 };
132 }
133 let entity_kinds = entity_kind_index(entities, &mut diagnostics);
134 validate_stable_identities(entities, &mut diagnostics);
135 for statement in statements {
136 validate_statement(statement, &entity_kinds, &mut diagnostics);
137 }
138 diagnostics.sort_by(|left, right| left.diagnostic_id.cmp(&right.diagnostic_id));
139 SoftwareShapeReport {
140 conforms: diagnostics
141 .iter()
142 .all(|diagnostic| diagnostic.severity != SoftwareShapeSeverity::Error),
143 diagnostics,
144 }
145}
146
147pub fn reconcile_software_statements(
149 entities: &[SoftwareEntity],
150 mut statements: Vec<SoftwareStatement>,
151) -> (Vec<SoftwareStatement>, SoftwareShapeReport) {
152 let report = validate_software_shapes(entities, &statements);
153 let rejected = report
154 .diagnostics
155 .iter()
156 .filter(|diagnostic| diagnostic.severity == SoftwareShapeSeverity::Error)
157 .filter_map(|diagnostic| diagnostic.statement_id.as_deref())
158 .collect::<BTreeSet<_>>();
159 for statement in &mut statements {
160 if rejected.contains(statement.statement_id.as_str()) {
161 statement.fact_state = SoftwareFactState::Rejected;
162 } else if statement.resolution_state == SoftwareStatementResolution::Conflicting {
163 statement.fact_state = SoftwareFactState::Conflicting;
164 }
165 }
166
167 let mut competing = BTreeMap::<(String, SoftwarePredicate), BTreeSet<String>>::new();
168 for statement in statements
169 .iter()
170 .filter(|statement| statement.fact_state == SoftwareFactState::Active)
171 .filter(|statement| statement.predicate == SoftwarePredicate::Supersedes)
172 {
173 if let Some(object) = statement.object_identity() {
174 competing
175 .entry((statement.subject_id.clone(), statement.predicate))
176 .or_default()
177 .insert(object.to_owned());
178 }
179 }
180 let conflicts = competing
181 .into_iter()
182 .filter_map(|(key, objects)| (objects.len() > 1).then_some(key))
183 .collect::<BTreeSet<_>>();
184 for statement in &mut statements {
185 if statement.fact_state == SoftwareFactState::Active
186 && conflicts.contains(&(statement.subject_id.clone(), statement.predicate))
187 {
188 statement.fact_state = SoftwareFactState::Conflicting;
189 statement.resolution_state = SoftwareStatementResolution::Conflicting;
190 }
191 }
192 (statements, report)
193}
194
195#[derive(Debug)]
196struct EntityShapeIndex {
197 kind: SoftwareEntityKind,
198 source_scopes: BTreeSet<String>,
199}
200
201fn entity_kind_index(
202 entities: &[SoftwareEntity],
203 diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
204) -> HashMap<String, EntityShapeIndex> {
205 let mut kinds = HashMap::new();
206 for entity in entities {
207 if entity
208 .evidence_refs
209 .iter()
210 .any(|evidence| evidence.source_scope != entity.source_scope)
211 {
212 diagnostics.push(entity_diagnostic(
213 entity,
214 "software:ProvenanceShape",
215 "cross_scope_entity_evidence",
216 "evidence_refs",
217 "entity evidence must belong to the entity occurrence source scope",
218 ));
219 }
220 match kinds.entry(entity.entity_key.clone()) {
221 std::collections::hash_map::Entry::Vacant(entry) => {
222 entry.insert(EntityShapeIndex {
223 kind: entity.entity_kind,
224 source_scopes: BTreeSet::from([entity.source_scope.clone()]),
225 });
226 }
227 std::collections::hash_map::Entry::Occupied(mut entry) => {
228 if entry.get().kind != entity.entity_kind {
229 diagnostics.push(entity_diagnostic(
230 entity,
231 "software:StableEntityShape",
232 "entity_kind_conflict",
233 "entity_kind",
234 "one stable entity key cannot identify multiple entity kinds",
235 ));
236 }
237 entry
238 .get_mut()
239 .source_scopes
240 .insert(entity.source_scope.clone());
241 }
242 }
243 }
244 kinds
245}
246
247fn validate_stable_identities(
248 entities: &[SoftwareEntity],
249 diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
250) {
251 let mut stable =
252 BTreeMap::<(String, SoftwareEntityKind, String, Option<String>), String>::new();
253 for entity in entities
254 .iter()
255 .filter(|entity| !entity.entity_kind.is_occurrence_kind())
256 {
257 let identity = (
258 entity.repository_id.clone(),
259 entity.entity_kind,
260 entity.name.clone(),
261 entity.namespace.clone(),
262 );
263 if let Some(previous) = stable.insert(identity, entity.entity_key.clone())
264 && previous != entity.entity_key
265 {
266 diagnostics.push(entity_diagnostic(
267 entity,
268 "software:StableEntityShape",
269 "unstable_entity_key",
270 "entity_key",
271 "stable entity identity changed across source scopes",
272 ));
273 }
274 }
275}
276
277fn validate_statement(
278 statement: &SoftwareStatement,
279 entity_kinds: &HashMap<String, EntityShapeIndex>,
280 diagnostics: &mut Vec<SoftwareShapeDiagnostic>,
281) {
282 if statement.evidence_refs.is_empty() {
283 diagnostics.push(statement_diagnostic(
284 statement,
285 "software:ProvenanceShape",
286 "missing_evidence",
287 "evidence_refs",
288 "accepted statements require at least one evidence reference",
289 ));
290 }
291 if statement
292 .evidence_refs
293 .iter()
294 .any(|evidence| evidence.source_scope != statement.source_scope)
295 {
296 diagnostics.push(statement_diagnostic(
297 statement,
298 "software:ProvenanceShape",
299 "cross_scope_evidence",
300 "evidence_refs",
301 "statement evidence must belong to the statement source scope",
302 ));
303 }
304 if statement.extractor_id.is_empty() || statement.extractor_version.is_empty() {
305 diagnostics.push(statement_diagnostic(
306 statement,
307 "software:ProvenanceShape",
308 "missing_extractor",
309 "extractor_version",
310 "extractor id and version are required",
311 ));
312 }
313 if statement.confidence_basis_points > 10_000 {
314 diagnostics.push(statement_diagnostic(
315 statement,
316 "software:ConfidenceShape",
317 "invalid_confidence",
318 "confidence_basis_points",
319 "confidence must be between 0 and 10000 basis points",
320 ));
321 }
322 if statement
323 .valid_from
324 .zip(statement.valid_to)
325 .is_some_and(|(from, to)| from > to)
326 {
327 diagnostics.push(statement_diagnostic(
328 statement,
329 "software:ValidityShape",
330 "invalid_validity_interval",
331 "valid_to",
332 "valid_to must not precede valid_from",
333 ));
334 }
335 if statement.assertion_mode == SoftwareAssertionMode::Observed
336 && statement.observed_at.is_none()
337 {
338 diagnostics.push(statement_diagnostic(
339 statement,
340 "software:ObservationShape",
341 "missing_observed_at",
342 "observed_at",
343 "observed statements require observed_at",
344 ));
345 }
346 if statement.object_id.is_some() == statement.object_value.is_some() {
347 diagnostics.push(statement_diagnostic(
348 statement,
349 "software:ObjectShape",
350 "invalid_object_cardinality",
351 "object_id",
352 "exactly one of object_id or object_value is required",
353 ));
354 } else if statement.object_value.is_some() {
355 diagnostics.push(statement_diagnostic(
356 statement,
357 "ontology:ObjectPropertyShape",
358 "literal_object_for_object_property",
359 "object_value",
360 "software ontology object properties require an ontology entity object",
361 ));
362 }
363
364 let Some(subject) = entity_kinds.get(&statement.subject_id) else {
365 diagnostics.push(statement_diagnostic(
366 statement,
367 "software:RelationShape",
368 "unknown_subject",
369 "subject_id",
370 "subject_id does not reference an entity in this source scope",
371 ));
372 return;
373 };
374 if !subject.source_scopes.contains(&statement.source_scope) {
375 diagnostics.push(statement_diagnostic(
376 statement,
377 "software:RelationShape",
378 "cross_scope_subject",
379 "subject_id",
380 "subject_id has no occurrence in the statement source scope",
381 ));
382 }
383 let schema = &SOFTWARE_ONTOLOGY_SCHEMA;
384 if !schema.allows_subject(statement.predicate.as_str(), subject.kind.as_str()) {
385 diagnostics.push(statement_diagnostic(
386 statement,
387 "software:RelationShape",
388 "invalid_domain",
389 "subject_id",
390 "predicate domain does not allow the subject entity kind",
391 ));
392 }
393 if let Some(object_id) = statement.object_id.as_deref() {
394 let Some(object) = entity_kinds.get(object_id) else {
395 diagnostics.push(statement_diagnostic(
396 statement,
397 "software:RelationShape",
398 "unknown_object",
399 "object_id",
400 "object_id does not reference an entity in this source scope",
401 ));
402 return;
403 };
404 if !object.source_scopes.contains(&statement.source_scope) {
405 diagnostics.push(statement_diagnostic(
406 statement,
407 "software:RelationShape",
408 "cross_scope_object",
409 "object_id",
410 "object_id has no occurrence in the statement source scope",
411 ));
412 }
413 if !schema.allows_relation(
414 statement.predicate.as_str(),
415 subject.kind.as_str(),
416 object.kind.as_str(),
417 ) {
418 diagnostics.push(statement_diagnostic(
419 statement,
420 "software:RelationShape",
421 "invalid_range",
422 "object_id",
423 "predicate range does not allow the object entity kind",
424 ));
425 }
426 }
427}
428
429fn statement_diagnostic(
430 statement: &SoftwareStatement,
431 shape_id: &str,
432 code: &str,
433 field: &str,
434 message: &str,
435) -> SoftwareShapeDiagnostic {
436 SoftwareShapeDiagnostic {
437 diagnostic_id: diagnostic_id(shape_id, code, &statement.statement_id, field),
438 shape_id: shape_id.to_owned(),
439 code: code.to_owned(),
440 severity: SoftwareShapeSeverity::Error,
441 statement_id: Some(statement.statement_id.clone()),
442 entity_key: None,
443 field: field.to_owned(),
444 message: message.to_owned(),
445 }
446}
447
448fn entity_diagnostic(
449 entity: &SoftwareEntity,
450 shape_id: &str,
451 code: &str,
452 field: &str,
453 message: &str,
454) -> SoftwareShapeDiagnostic {
455 SoftwareShapeDiagnostic {
456 diagnostic_id: diagnostic_id(shape_id, code, &entity.entity_key, field),
457 shape_id: shape_id.to_owned(),
458 code: code.to_owned(),
459 severity: SoftwareShapeSeverity::Error,
460 statement_id: None,
461 entity_key: Some(entity.entity_key.clone()),
462 field: field.to_owned(),
463 message: message.to_owned(),
464 }
465}
466
467fn diagnostic_id(shape_id: &str, code: &str, focus: &str, field: &str) -> String {
468 super::validation::stable_software_id("software_diagnostic", [shape_id, code, focus, field])
469}
470
471#[cfg(test)]
472#[path = "shape_tests.rs"]
473mod tests;