1use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{BTreeMap, BTreeSet};
7
8pub type Files = BTreeMap<String, String>;
9pub fn digest(value: &impl Serialize) -> String {
10 format!(
11 "{:x}",
12 Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
13 )
14}
15fn version() -> u32 {
16 1
17}
18
19#[derive(
21 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
22)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct Anchor {
25 pub file: String,
26 pub line: usize,
27 pub column: usize,
28 pub text: String,
29}
30
31pub fn local_path(file: &str) -> bool {
32 !file.is_empty()
33 && !file.contains(['\\', ':'])
34 && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
35}
36impl Anchor {
37 pub fn new(file: &str, source: &str, start: usize, end: usize) -> Self {
38 Self {
39 file: file.into(),
40 line: source[..start].bytes().filter(|b| *b == b'\n').count() + 1,
41 column: start - source[..start].rfind('\n').map_or(0, |n| n + 1) + 1,
42 text: source[start..end].into(),
43 }
44 }
45 pub fn offset(&self, files: &Files) -> Option<usize> {
46 if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
47 return None;
48 }
49 let source = files.get(&self.file)?;
50 let start = source
51 .split_inclusive('\n')
52 .take(self.line - 1)
53 .map(str::len)
54 .sum::<usize>();
55 if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
56 return None;
57 }
58 let line = source.get(start..)?.split('\n').next()?;
59 if self.column - 1 > line.len() {
60 return None;
61 }
62 let pos = start.checked_add(self.column - 1)?;
63 source.get(pos..)?.starts_with(&self.text).then_some(pos)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct InventorySite {
70 pub at: Anchor,
71 pub operation: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct Inputs {
79 #[serde(default = "version")]
80 pub schema_version: u32,
81 pub language: String,
82 pub context_digest: String,
83 pub files: Files,
84 pub assertions: Vec<InventorySite>,
85 pub limitations: Vec<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct FileFingerprint {
91 pub sha256: String,
92 pub bytes: usize,
93}
94impl FileFingerprint {
95 pub fn of(source: &str) -> Self {
96 Self {
97 sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
98 bytes: source.len(),
99 }
100 }
101}
102pub type FileManifest = BTreeMap<String, FileFingerprint>;
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct InputManifest {
108 pub schema_version: u32,
109 pub language: String,
110 pub context_digest: String,
111 pub files: FileManifest,
112 pub assertions: Vec<InventorySite>,
113 pub limitations: Vec<String>,
114}
115impl Inputs {
116 pub fn manifest(&self) -> InputManifest {
117 InputManifest {
118 schema_version: 2,
119 language: self.language.clone(),
120 context_digest: self.context_digest.clone(),
121 files: self
122 .files
123 .iter()
124 .map(|(p, s)| (p.clone(), FileFingerprint::of(s)))
125 .collect(),
126 assertions: self.assertions.clone(),
127 limitations: self.limitations.clone(),
128 }
129 }
130 pub fn identity(&self) -> String {
131 digest(&self.manifest())
132 }
133}
134impl InputManifest {
135 pub fn with_sources(&self, files: Files) -> Inputs {
136 Inputs {
137 schema_version: 1,
138 language: self.language.clone(),
139 context_digest: self.context_digest.clone(),
140 files,
141 assertions: self.assertions.clone(),
142 limitations: self.limitations.clone(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
148#[serde(rename_all = "camelCase", deny_unknown_fields)]
149pub struct Node {
150 pub id: String,
151 pub at: Anchor,
152 #[serde(default, skip_serializing_if = "String::is_empty")]
153 pub role: String,
154 #[serde(default, skip_serializing_if = "String::is_empty")]
155 pub meaning: String,
156}
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct Edge {
160 pub from: String,
161 pub to: String,
162 pub kind: String,
163 #[serde(default, skip_serializing_if = "String::is_empty")]
164 pub basis: String,
165}
166#[derive(
167 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
168)]
169#[serde(rename_all = "camelCase", deny_unknown_fields)]
170pub struct TestSelector {
171 pub file: String,
172 pub name: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
176#[serde(rename_all = "camelCase", deny_unknown_fields)]
177pub struct Flow {
178 pub id: String,
179 #[serde(deserialize_with = "required_basis")]
180 #[schemars(required, schema_with = "basis_schema")]
181 pub basis: Option<String>,
182 pub explanation: String,
183 pub applies_to: Vec<TestSelector>,
184 pub nodes: Vec<Node>,
185 #[serde(default)]
186 pub edges: Vec<Edge>,
187 pub counts_as_asserted: Vec<String>,
188 pub watch: Vec<String>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub questions: Vec<String>,
191}
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct Assertion {
195 pub id: String,
196 pub at: Anchor,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub questions: Vec<String>,
199 #[serde(default)]
200 pub observes: Vec<String>,
201 #[serde(default)]
202 pub flows: Vec<Flow>,
203}
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
205#[serde(rename_all = "camelCase", deny_unknown_fields)]
206pub struct Retired {
207 pub assertion: Assertion,
208 pub reason: String,
209}
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct AssertionMap {
213 #[schemars(range(min = 2, max = 2))]
214 pub schema_version: u32,
215 pub assertions: Vec<Assertion>,
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub change_assessments: Vec<ChangeAssessment>,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub retired_assertions: Vec<Retired>,
220}
221
222fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
224 let value = Option::<String>::deserialize(d)?;
225 if value.as_deref().is_some_and(|s| !valid_basis(s)) {
226 return Err(serde::de::Error::custom(
227 "expected null or scov2:<64 lowercase hex digits>",
228 ));
229 }
230 Ok(value)
231}
232fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
233 schemars::json_schema!({"type":["string","null"],"pattern":"^scov2:[0-9a-f]{64}$"})
234}
235fn valid_basis(s: &str) -> bool {
236 s.strip_prefix("scov2:").is_some_and(|h| {
237 h.len() == 64
238 && h.bytes()
239 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
240 })
241}
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct ChangeAssessment {
245 pub id: String,
246 #[serde(deserialize_with = "required_basis")]
247 #[schemars(required, schema_with = "basis_schema")]
248 pub basis: Option<String>,
249 pub affected_flows: Vec<String>,
250 pub explanation: String,
251}
252
253pub fn schema() -> serde_json::Value {
256 serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
257}
258
259#[derive(Debug, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct ParseError {
262 pub pointer: String,
263 pub line: usize,
264 pub column: usize,
265 pub message: String,
266}
267impl std::fmt::Display for ParseError {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 write!(
270 f,
271 "{} at {} (JSON line {}, column {})",
272 self.message, self.pointer, self.line, self.column
273 )
274 }
275}
276pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
277 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
278 let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
279 let pointer = e
280 .path()
281 .iter()
282 .map(|segment| {
283 use serde_path_to_error::Segment;
284 let part = match segment {
285 Segment::Seq { index } => index.to_string(),
286 Segment::Map { key } => key.clone(),
287 Segment::Enum { variant } => variant.clone(),
288 Segment::Unknown => "?".into(),
289 };
290 format!("/{}", part.replace('~', "~0").replace('/', "~1"))
291 })
292 .collect();
293 ParseError {
294 pointer,
295 line: e.inner().line(),
296 column: e.inner().column(),
297 message: e.inner().to_string(),
298 }
299 })?;
300 deserializer.end().map_err(|e| ParseError {
301 pointer: String::new(),
302 line: e.line(),
303 column: e.column(),
304 message: e.to_string(),
305 })?;
306 if map.schema_version != 2 {
307 return Err(ParseError {
308 pointer: "/schemaVersion".into(),
309 line: 0,
310 column: 0,
311 message: "unsupported map schema version; expected 2".into(),
312 });
313 }
314 Ok(map)
315}
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct FlowState {
319 pub generation: String,
320 pub reasons: BTreeSet<String>,
321}
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase", deny_unknown_fields)]
324pub struct Change {
325 pub id: String,
326 pub file: Option<String>,
327 pub before: Option<String>,
328 pub after: Option<String>,
329 pub reason: String,
330 pub known_flows: BTreeSet<String>,
331}
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase", deny_unknown_fields)]
334pub struct State {
335 pub schema_version: u32,
336 pub inputs_digest: String,
337 pub evidence_digest: String,
338 pub flows: BTreeMap<String, FlowState>,
339 pub changes: Vec<Change>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub inheritance: Option<Inheritance>,
342}
343#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct Inheritance {
346 pub from: Option<String>,
347 pub skipped: Vec<SkippedMap>,
348}
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
350#[serde(rename_all = "camelCase", deny_unknown_fields)]
351pub struct SkippedMap {
352 pub run: String,
353 pub reason: String,
354}
355pub fn flow_key(a: &Assertion, f: &Flow) -> String {
356 format!("{}/{}", a.id, f.id)
357}
358fn valid_id(id: &str) -> bool {
359 !id.is_empty()
360 && id
361 .bytes()
362 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
363}
364
365pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
366 seed_manifest(&inputs.manifest(), evidence_digest)
367}
368pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
369 (
370 AssertionMap {
371 schema_version: 2,
372 assertions: inputs
373 .assertions
374 .iter()
375 .map(|site| Assertion {
376 id: format!("a_{}", &digest(&site.at)[..20]),
377 at: site.at.clone(),
378 questions: vec![],
379 observes: vec![],
380 flows: vec![],
381 })
382 .collect(),
383 change_assessments: vec![],
384 retired_assertions: vec![],
385 },
386 State {
387 schema_version: 3,
388 inputs_digest: digest(inputs),
389 evidence_digest: evidence_digest.into(),
390 flows: BTreeMap::new(),
391 changes: vec![],
392 inheritance: None,
393 },
394 )
395}
396
397pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
399 let mut errors = Vec::new();
400 if map.schema_version != 2 || inputs.schema_version != 1 {
401 errors.push("unsupported schema version".into());
402 }
403 let mut ids = BTreeSet::new();
404 let mut sites = BTreeSet::new();
405 for a in &map.assertions {
406 if !valid_id(&a.id) || !ids.insert(&a.id) {
407 errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
408 }
409 if !sites.insert(&a.at) {
410 errors.push(format!("{}: duplicate assertion location", a.id));
411 }
412 if a.at.offset(&inputs.files).is_none() {
413 errors.push(format!("{}: invalid assertion anchor", a.id));
414 }
415 let mut flows = BTreeSet::new();
418 for f in &a.flows {
419 let key = flow_key(a, f);
420 if !valid_id(&f.id) || !flows.insert(&f.id) {
421 errors.push(format!("{key}: invalid/duplicate flow ID"));
422 }
423 errors.extend(
424 validate_flow(f, &inputs.files)
425 .into_iter()
426 .map(|e| format!("{key}: {e}")),
427 );
428 }
429 }
430 let mut changes = BTreeSet::new();
431 for change in &map.change_assessments {
432 if !valid_id(&change.id) || !changes.insert(&change.id) {
433 errors.push("invalid/duplicate change assessment ID".into());
434 }
435 }
436 errors
437}
438pub fn advisories(map: &AssertionMap) -> Vec<String> {
447 let mut out = Vec::new();
448 for a in &map.assertions {
449 for f in &a.flows {
450 for file in &f.watch {
451 if crate::integrity::globally_tracked(file) {
452 out.push(format!(
453 "{}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes",
454 flow_key(a, f)
455 ));
456 }
457 }
458 }
459 }
460 out
461}
462pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
463 let mut errors = Vec::new();
464 let mut nodes = BTreeSet::new();
465 for node in &flow.nodes {
466 if !valid_id(&node.id) || !nodes.insert(&node.id) {
467 errors.push("invalid/duplicate node ID".into());
468 }
469 if node.at.offset(files).is_none() {
470 errors.push(format!("node {}: invalid anchor", node.id));
471 }
472 }
473 for edge in &flow.edges {
474 if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
475 errors.push("dangling edge".into());
476 }
477 }
478 if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
479 errors.push("unknown counted node".into());
480 }
481 let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
483 loop {
484 let size = reaches.len();
485 for edge in &flow.edges {
486 if reaches.contains(&edge.to) {
487 reaches.insert(edge.from.clone());
488 }
489 }
490 if reaches.len() == size {
491 break;
492 }
493 }
494 for id in &flow.counts_as_asserted {
495 if !reaches.contains(id) {
496 errors.push(format!(
497 "counted node {id} has no authored path to $assertion"
498 ));
499 }
500 }
501 if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
502 errors.push("missing edge kind".into());
503 }
504 if flow
505 .applies_to
506 .iter()
507 .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
508 {
509 errors.push("invalid test selector file or name".into());
510 }
511 if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
512 errors.push("duplicate test selector".into());
513 }
514 if flow
515 .counts_as_asserted
516 .iter()
517 .collect::<BTreeSet<_>>()
518 .len()
519 != flow.counts_as_asserted.len()
520 {
521 errors.push("duplicate counted node".into());
522 }
523 if flow.explanation.trim().is_empty() {
524 errors.push("missing explanation".into());
525 }
526 for file in &flow.watch {
527 if !local_path(file) || !files.contains_key(file) {
528 errors.push(format!("watched file missing: {file}"));
529 }
530 }
531 errors
532}
533
534pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
536 std::iter::once(a.at.file.as_str())
537 .chain(f.applies_to.iter().map(|t| t.file.as_str()))
538 .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
539 .chain(
550 f.watch
551 .iter()
552 .map(String::as_str)
553 .filter(|path| !crate::integrity::globally_tracked(path)),
554 )
555 .collect()
556}
557fn token(value: &impl Serialize) -> String {
558 format!("scov2:{}", digest(value))
559}
560pub fn change_errors(
561 map: &AssertionMap,
562 change: &Change,
563 response: &ChangeAssessment,
564) -> Vec<String> {
565 let keys = map
566 .assertions
567 .iter()
568 .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
569 .collect::<BTreeSet<_>>();
570 let affected = response
571 .affected_flows
572 .iter()
573 .cloned()
574 .collect::<BTreeSet<_>>();
575 let mut errors = Vec::new();
576 if response.explanation.trim().is_empty() {
577 errors.push("missing impact explanation".into());
578 }
579 if affected.len() != response.affected_flows.len() {
580 errors.push("duplicate affected flow".into());
581 }
582 if !affected.is_subset(&keys) {
583 errors.push("unknown affected flow".into());
584 }
585 if !change
586 .known_flows
587 .intersection(&keys)
588 .all(|k| affected.contains(k))
589 {
590 errors.push("known dependent flows must be included unless removed from the map".into());
591 }
592 errors
593}
594pub fn expected_change_basis(
595 change: &Change,
596 response: &ChangeAssessment,
597 inputs: &InputManifest,
598) -> String {
599 token(&(
600 "supercov-change-v2",
601 change,
602 digest(inputs),
603 &response.id,
604 &response.affected_flows,
605 &response.explanation,
606 ))
607}
608pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
609 let responses = map
610 .change_assessments
611 .iter()
612 .filter(|r| r.id == change.id)
613 .collect::<Vec<_>>();
614 matches!(responses.as_slice(), [r] if change_errors(map, change, r).is_empty() && r.basis.as_deref() == Some(expected_change_basis(change, r, inputs).as_str()))
615}
616fn generation(
617 a: &Assertion,
618 f: &Flow,
619 map: &AssertionMap,
620 state: &State,
621 inputs: &InputManifest,
622) -> String {
623 let key = flow_key(a, f);
624 let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
625 let impacts = state
626 .changes
627 .iter()
628 .filter(|c| change_current(map, c, inputs))
629 .filter_map(|c| {
630 map.change_assessments
631 .iter()
632 .find(|r| r.id == c.id && r.affected_flows.contains(&key))
633 .map(|r| (&c.id, &r.basis))
634 })
635 .collect::<BTreeMap<_, _>>();
636 if impacts.is_empty() {
637 base.into()
638 } else {
639 digest(&("supercov-generation-v2", base, impacts))
640 }
641}
642pub fn expected_basis(
643 a: &Assertion,
644 f: &Flow,
645 map: &AssertionMap,
646 state: &State,
647 inputs: &InputManifest,
648) -> String {
649 let mut claim = f.clone();
650 claim.basis = None;
651 let hashes = dependencies(a, f)
652 .into_iter()
653 .map(|p| (p, inputs.files.get(p)))
654 .collect::<BTreeMap<_, _>>();
655 token(&(
656 "supercov-flow-v2",
657 &inputs.context_digest,
658 &a.id,
659 &a.at,
660 &a.observes,
661 claim,
662 hashes,
663 generation(a, f, map, state, inputs),
664 ))
665}
666pub fn reasons(
667 a: &Assertion,
668 f: &Flow,
669 map: &AssertionMap,
670 state: &State,
671 inputs: &Inputs,
672) -> BTreeSet<String> {
673 reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
674}
675pub fn reasons_for_manifest(
676 a: &Assertion,
677 f: &Flow,
678 map: &AssertionMap,
679 state: &State,
680 inputs: &Inputs,
681 manifest: &InputManifest,
682) -> BTreeSet<String> {
683 let mut reasons = BTreeSet::new();
684 if state.schema_version != 3 || state.inputs_digest != digest(manifest) {
685 reasons.insert("state does not match run inputs".into());
686 }
687 if f.basis.as_deref() != Some(expected_basis(a, f, map, state, manifest).as_str()) {
688 reasons.insert(
689 if f.basis.is_none() {
690 "draft: input acknowledgement not recorded"
691 } else {
692 "claim or inputs changed; needs rechecking"
693 }
694 .into(),
695 );
696 if let Some(s) = state.flows.get(&flow_key(a, f)) {
697 reasons.extend(s.reasons.iter().cloned());
698 }
699 }
700 reasons.extend(validate_flow(f, &inputs.files));
701 if a.at.offset(&inputs.files).is_none() {
702 reasons.insert("invalid assertion anchor".into());
703 }
704 if !f.questions.is_empty() {
705 reasons.insert("flow has unresolved questions".into());
706 }
707 reasons
708}
709pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
711 use serde_json::json;
712 let manifest = inputs.manifest();
713 let mut errors = validate(map, inputs);
714 for r in &map.change_assessments {
715 if !state.changes.iter().any(|c| c.id == r.id) {
716 errors.push(format!("{}: unknown change assessment", r.id));
717 }
718 }
719 let changes = state.changes.iter().map(|c| {
720 let response = map.change_assessments.iter().find(|r| r.id == c.id);
721 let faults = response.map(|r| change_errors(map, c, r)).unwrap_or_default();
722 errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
723 json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,"knownFlows":c.known_flows,
724 "current":change_current(map,c,&manifest),"assessment":response,"errors":faults,
725 "expectedBasis":response.map(|r| expected_change_basis(c,r,&manifest))})
726 }).collect::<Vec<_>>();
727 let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
728 json!({"id":flow_key(a,f),"expectedBasis":expected_basis(a,f,map,state,&manifest),"reasons":reasons_for_manifest(a,f,map,state,inputs,&manifest)})
729 }).collect::<Vec<_>>();
730 json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
731 "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
732}
733pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
734 for a in &map.assertions {
735 for f in &a.flows {
736 let key = flow_key(a, f);
737 let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
738 state.flows.insert(
739 key,
740 FlowState {
741 generation: digest(&(base, reason, &state.inputs_digest)),
742 reasons: BTreeSet::from([reason.into()]),
743 },
744 );
745 }
746 }
747}
748pub fn add_change(
749 state: &mut State,
750 file: Option<String>,
751 before: Option<String>,
752 after: Option<String>,
753 reason: String,
754 known_flows: BTreeSet<String>,
755) {
756 let id = format!(
758 "c_{}",
759 &digest(&(
760 "supercov-change-id-v2",
761 &state.changes,
762 &file,
763 &before,
764 &after,
765 &reason
766 ))[..24]
767 );
768 state.changes.push(Change {
769 id,
770 file,
771 before,
772 after,
773 reason,
774 known_flows,
775 });
776}
777
778fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
779 if snippet.is_empty() {
780 return None;
781 }
782 let first = text.find(snippet)?;
783 let next = first + text[first..].chars().next()?.len_utf8();
785 text[next..]
786 .contains(snippet)
787 .then_some(())
788 .map_or(Some(first), |_| None)
789}
790fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
791 if new.contains_key(file) {
792 return Some(file.into());
793 }
794 let hash = old.get(file)?;
795 let mut matches = new.iter().filter(|(_, s)| FileFingerprint::of(s) == *hash);
796 let first = matches.next()?.0;
797 matches.next().is_none().then(|| first.clone())
798}
799pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
800 let before = old.get(&at.file)?;
801 let target = target_file(&at.file, old, new)?;
802 let after = &new[&target];
803 let mut candidate = at.clone();
804 candidate.file.clone_from(&target);
805 if FileFingerprint::of(after) == *before && candidate.offset(new).is_some() {
806 return Some(candidate);
807 }
808 let position = unique_occurrence(after, &at.text)?;
809 Some(Anchor::new(
810 &target,
811 after,
812 position,
813 position + at.text.len(),
814 ))
815}
816
817pub fn carry(
820 map: &AssertionMap,
821 state: &State,
822 old: &InputManifest,
823 new: &Inputs,
824 evidence_digest: &str,
825 context_changed: bool,
826) -> Result<(AssertionMap, State), String> {
827 if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
828 return Err("unsupported map/input schema version".into());
829 }
830 if state.inputs_digest != digest(old) || state.schema_version != 3 {
831 return Err("old map state does not match its run inputs".into());
832 }
833 let new_manifest = new.manifest();
834 let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
835 next.assertions.clear();
836 next.retired_assertions = map.retired_assertions.clone();
837 next_state.changes = state
838 .changes
839 .iter()
840 .filter(|c| !change_current(map, c, old))
841 .cloned()
842 .collect();
843 next.change_assessments = map
844 .change_assessments
845 .iter()
846 .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
847 .cloned()
848 .collect();
849 let mut consumed = BTreeSet::new();
850 let exact = map
851 .assertions
852 .iter()
853 .map(|a| {
854 relocate(&a.at, &old.files, &new.files).filter(|at| {
855 new.assertions.iter().any(|s| &s.at == at)
856 || !old.assertions.iter().any(|s| s.at == a.at)
857 })
858 })
859 .collect::<Vec<_>>();
860 let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
861 for (index, a) in map.assertions.iter().enumerate() {
862 let candidates = new
865 .assertions
866 .iter()
867 .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
868 .collect::<Vec<_>>();
869 let unmatched = map
870 .assertions
871 .iter()
872 .zip(&exact)
873 .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
874 .count();
875 let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
876 Some(&candidates[0].at)
877 } else {
878 None
879 };
880 let matched = exact[index]
881 .as_ref()
882 .or(replacement)
883 .filter(|at| !consumed.contains(*at));
884 let Some(at) = matched else {
885 next.retired_assertions.push(Retired {
886 assertion: a.clone(),
887 reason:
888 "assertion removed, changed or ambiguous; reuse its explanation after review"
889 .into(),
890 });
891 continue;
892 };
893 consumed.insert(at.clone());
894 let mut updated = a.clone();
895 updated.at = at.clone();
896 for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
897 let base = generation(a, prior, map, state, old);
898 let mut dirty = BTreeSet::new();
899 if prior
900 .basis
901 .as_deref()
902 .is_some_and(|basis| basis != expected_basis(a, prior, map, state, old))
903 {
904 dirty.insert("inherited claim still needs rechecking".into());
905 }
906 for file in dependencies(a, prior) {
907 if old.files.get(file) != new_manifest.files.get(file)
908 || !old.files.contains_key(file)
909 {
910 dirty.insert(format!("dependency file changed or removed: {file}"));
911 }
912 }
913 if replacement.is_some() {
914 dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
915 }
916 for node in &mut f.nodes {
917 if let Some(at) = relocate(&node.at, &old.files, &new.files) {
918 node.at = at;
919 } else {
920 dirty.insert(format!("node {} changed or ambiguous", node.id));
921 }
922 }
923 for file in f
924 .watch
925 .iter_mut()
926 .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
927 {
928 if let Some(target) = target_file(file, &old.files, &new.files) {
929 *file = target;
930 } else {
931 dirty.insert(format!("dependency file removed: {file}"));
932 }
933 }
934 if context_changed {
935 dirty.insert("run configuration, dependencies or execution context changed".into());
936 }
937 next_state.flows.insert(
938 flow_key(a, f),
939 FlowState {
940 generation: if dirty.is_empty() {
941 base
942 } else {
943 digest(&("supercov-carry-v2", base, &new_manifest, &dirty))
944 },
945 reasons: dirty,
946 },
947 );
948 }
949 next.assertions.push(updated);
950 }
951 let mut ids = map
952 .assertions
953 .iter()
954 .map(|a| a.id.clone())
955 .chain(
956 map.retired_assertions
957 .iter()
958 .map(|r| r.assertion.id.clone()),
959 )
960 .collect::<BTreeSet<_>>();
961 for a in seed(new, evidence_digest).0.assertions {
962 if !consumed.contains(&a.at) {
963 let mut a = a;
964 while !ids.insert(a.id.clone()) {
965 a.id.push('_');
966 }
967 next.assertions.push(a);
968 }
969 }
970 for file in old
971 .files
972 .keys()
973 .chain(new_manifest.files.keys())
974 .collect::<BTreeSet<_>>()
975 {
976 if crate::integrity::tracked_manifest(file) {
981 continue;
982 }
983 if old.files.get(file) != new_manifest.files.get(file) {
984 let known = map
985 .assertions
986 .iter()
987 .flat_map(|a| {
988 a.flows
989 .iter()
990 .filter(|f| dependencies(a, f).contains(file.as_str()))
991 .map(move |f| flow_key(a, f))
992 })
993 .collect();
994 add_change(
995 &mut next_state,
996 Some(file.clone()),
997 old.files.get(file).map(|f| f.sha256.clone()),
998 new_manifest.files.get(file).map(|f| f.sha256.clone()),
999 "captured source file changed".into(),
1000 known,
1001 );
1002 }
1003 }
1004 next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1005 Ok((next, next_state))
1006}
1007
1008#[path = "assertion_legacy.rs"]
1009mod legacy;
1010pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1011 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1012 match value
1013 .get("schemaVersion")
1014 .and_then(serde_json::Value::as_u64)
1015 {
1016 None | Some(1) => legacy::import(bytes),
1017 _ => parse(bytes).map_err(|e| e.to_string()),
1018 }
1019}
1020pub fn parse_state(
1021 bytes: &[u8],
1022 map: &AssertionMap,
1023 inputs: &InputManifest,
1024 evidence: &str,
1025 legacy_digest: Option<&str>,
1026) -> Result<State, String> {
1027 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1028 if value["schemaVersion"] == 3 {
1029 let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1030 if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1031 return Err("Assertion state belongs to different run evidence; rerun tests".into());
1032 }
1033 Ok(state)
1034 } else {
1035 legacy::state(bytes, map, inputs, evidence, legacy_digest)
1036 }
1037}