1use crate::source_units::{Code, Diff, named};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::{BTreeMap, BTreeSet};
8
9pub type Files = BTreeMap<String, String>;
10pub fn digest(value: &impl Serialize) -> String {
11 format!(
12 "{:x}",
13 Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
14 )
15}
16fn version() -> u32 {
17 1
18}
19
20#[derive(
22 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
23)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct Anchor {
26 pub file: String,
27 pub line: usize,
28 pub column: usize,
29 pub text: String,
30}
31
32pub fn local_path(file: &str) -> bool {
33 !file.is_empty()
34 && !file.contains(['\\', ':'])
35 && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
36}
37impl Anchor {
38 pub fn new(file: &str, source: &str, start: usize, end: usize) -> Self {
39 Self {
40 file: file.into(),
41 line: source[..start].bytes().filter(|b| *b == b'\n').count() + 1,
42 column: start - source[..start].rfind('\n').map_or(0, |n| n + 1) + 1,
43 text: source[start..end].into(),
44 }
45 }
46 pub fn offset(&self, files: &Files) -> Option<usize> {
47 if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
48 return None;
49 }
50 let source = files.get(&self.file)?;
51 let start = source
52 .split_inclusive('\n')
53 .take(self.line - 1)
54 .map(str::len)
55 .sum::<usize>();
56 if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
57 return None;
58 }
59 let line = source.get(start..)?.split('\n').next()?;
60 if self.column - 1 > line.len() {
61 return None;
62 }
63 let pos = start.checked_add(self.column - 1)?;
64 source.get(pos..)?.starts_with(&self.text).then_some(pos)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct InventorySite {
71 pub at: Anchor,
72 pub operation: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79pub struct Inputs {
80 #[serde(default = "version")]
81 pub schema_version: u32,
82 pub language: String,
83 pub context_digest: String,
84 pub files: Files,
85 pub assertions: Vec<InventorySite>,
86 pub limitations: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct FileFingerprint {
92 pub sha256: String,
93 pub bytes: usize,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub code: Option<Code>,
100}
101impl FileFingerprint {
102 pub fn of(source: &str) -> Self {
103 Self {
104 sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
105 bytes: source.len(),
106 code: None,
107 }
108 }
109 pub fn read(path: &str, source: &str) -> Self {
111 let mut fingerprint = Self::of(source);
112 fingerprint.code = crate::source_units::code(path, source);
113 fingerprint
114 }
115 pub fn same_bytes(&self, other: &Self) -> bool {
116 self.sha256 == other.sha256
117 }
118}
119pub type FileManifest = BTreeMap<String, FileFingerprint>;
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct InputManifest {
125 pub schema_version: u32,
126 pub language: String,
127 pub context_digest: String,
128 pub files: FileManifest,
129 pub assertions: Vec<InventorySite>,
130 pub limitations: Vec<String>,
131}
132impl Inputs {
133 pub fn manifest(&self) -> InputManifest {
134 InputManifest {
135 schema_version: 2,
136 language: self.language.clone(),
137 context_digest: self.context_digest.clone(),
138 files: self
139 .files
140 .iter()
141 .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
142 .collect(),
143 assertions: self.assertions.clone(),
144 limitations: self.limitations.clone(),
145 }
146 }
147 pub fn identity(&self) -> String {
148 digest(&self.manifest())
149 }
150}
151impl InputManifest {
152 pub fn with_sources(&self, files: Files) -> Inputs {
153 Inputs {
154 schema_version: 1,
155 language: self.language.clone(),
156 context_digest: self.context_digest.clone(),
157 files,
158 assertions: self.assertions.clone(),
159 limitations: self.limitations.clone(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct Node {
167 pub id: String,
168 pub at: Anchor,
169 #[serde(default, skip_serializing_if = "String::is_empty")]
170 pub role: String,
171 #[serde(default, skip_serializing_if = "String::is_empty")]
172 pub meaning: String,
173}
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct Edge {
177 pub from: String,
178 pub to: String,
179 pub kind: String,
180 #[serde(default, skip_serializing_if = "String::is_empty")]
181 pub basis: String,
182}
183#[derive(
184 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
185)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct TestSelector {
188 pub file: String,
189 pub name: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct Flow {
195 pub id: String,
196 #[serde(deserialize_with = "required_basis")]
197 #[schemars(required, schema_with = "basis_schema")]
198 pub basis: Option<String>,
199 pub explanation: String,
200 pub applies_to: Vec<TestSelector>,
201 pub nodes: Vec<Node>,
202 #[serde(default)]
203 pub edges: Vec<Edge>,
204 pub counts_as_asserted: Vec<String>,
205 pub watch: Vec<String>,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub questions: Vec<String>,
208}
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211pub struct Assertion {
212 pub id: String,
213 pub at: Anchor,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub questions: Vec<String>,
216 #[serde(default)]
217 pub observes: Vec<String>,
218 #[serde(default)]
219 pub flows: Vec<Flow>,
220}
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct Retired {
224 pub assertion: Assertion,
225 pub reason: String,
226}
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct AssertionMap {
230 #[schemars(range(min = 2, max = 2))]
231 pub schema_version: u32,
232 pub assertions: Vec<Assertion>,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub change_assessments: Vec<ChangeAssessment>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub retired_assertions: Vec<Retired>,
237}
238
239fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
241 let value = Option::<String>::deserialize(d)?;
242 if value.as_deref().is_some_and(|s| !valid_basis(s)) {
243 return Err(serde::de::Error::custom(
244 "expected null or scov3:<64 lowercase hex digits>",
245 ));
246 }
247 Ok(value)
248}
249fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
250 schemars::json_schema!({"type":["string","null"],"pattern":"^scov[23]:[0-9a-f]{64}$"})
251}
252fn valid_basis(s: &str) -> bool {
253 s.strip_prefix("scov3:")
254 .or_else(|| s.strip_prefix("scov2:"))
255 .is_some_and(|h| {
256 h.len() == 64
257 && h.bytes()
258 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
259 })
260}
261pub fn superseded_basis(s: &str) -> bool {
266 s.starts_with("scov2:")
267}
268pub const SUPERSEDED_BASIS: &str = "acknowledged under an earlier Supercov basis format; reread the claim and copy the current expectedBasis";
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct ChangeAssessment {
272 pub id: String,
273 #[serde(deserialize_with = "required_basis")]
274 #[schemars(required, schema_with = "basis_schema")]
275 pub basis: Option<String>,
276 pub affected_flows: Vec<String>,
277 pub explanation: String,
278}
279
280pub fn schema() -> serde_json::Value {
283 serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
284}
285
286#[derive(Debug, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ParseError {
289 pub pointer: String,
290 pub line: usize,
291 pub column: usize,
292 pub message: String,
293}
294impl std::fmt::Display for ParseError {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 write!(
297 f,
298 "{} at {} (JSON line {}, column {})",
299 self.message, self.pointer, self.line, self.column
300 )
301 }
302}
303pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
304 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
305 let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
306 let pointer = e
307 .path()
308 .iter()
309 .map(|segment| {
310 use serde_path_to_error::Segment;
311 let part = match segment {
312 Segment::Seq { index } => index.to_string(),
313 Segment::Map { key } => key.clone(),
314 Segment::Enum { variant } => variant.clone(),
315 Segment::Unknown => "?".into(),
316 };
317 format!("/{}", part.replace('~', "~0").replace('/', "~1"))
318 })
319 .collect();
320 ParseError {
321 pointer,
322 line: e.inner().line(),
323 column: e.inner().column(),
324 message: e.inner().to_string(),
325 }
326 })?;
327 deserializer.end().map_err(|e| ParseError {
328 pointer: String::new(),
329 line: e.line(),
330 column: e.column(),
331 message: e.to_string(),
332 })?;
333 if map.schema_version != 2 {
334 return Err(ParseError {
335 pointer: "/schemaVersion".into(),
336 line: 0,
337 column: 0,
338 message: "unsupported map schema version; expected 2".into(),
339 });
340 }
341 Ok(map)
342}
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct FlowState {
346 pub generation: String,
347 pub reasons: BTreeSet<String>,
348 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
351 pub notices: BTreeSet<String>,
352}
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356pub struct Executions {
357 pub tests: Vec<Execution>,
358 pub probed: BTreeMap<String, Vec<usize>>,
361}
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct Execution {
365 pub test: TestSelector,
366 pub passed: bool,
367 pub files: BTreeMap<String, Vec<usize>>,
369}
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct Change {
373 pub id: String,
374 pub file: Option<String>,
375 pub before: Option<String>,
376 pub after: Option<String>,
377 pub reason: String,
378 pub known_flows: BTreeSet<String>,
381 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
385 pub exposed: BTreeSet<String>,
386}
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase", deny_unknown_fields)]
389pub struct State {
390 pub schema_version: u32,
391 pub inputs_digest: String,
392 pub evidence_digest: String,
393 pub flows: BTreeMap<String, FlowState>,
394 pub changes: Vec<Change>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub inheritance: Option<Inheritance>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub executions: Option<Executions>,
399}
400#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
401#[serde(rename_all = "camelCase", deny_unknown_fields)]
402pub struct Inheritance {
403 pub from: Option<String>,
404 pub skipped: Vec<SkippedMap>,
405}
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
407#[serde(rename_all = "camelCase", deny_unknown_fields)]
408pub struct SkippedMap {
409 pub run: String,
410 pub reason: String,
411}
412pub fn flow_key(a: &Assertion, f: &Flow) -> String {
413 format!("{}/{}", a.id, f.id)
414}
415fn valid_id(id: &str) -> bool {
416 !id.is_empty()
417 && id
418 .bytes()
419 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
420}
421
422pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
423 seed_manifest(&inputs.manifest(), evidence_digest)
424}
425pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
426 (
427 AssertionMap {
428 schema_version: 2,
429 assertions: inputs
430 .assertions
431 .iter()
432 .map(|site| Assertion {
433 id: format!("a_{}", &digest(&site.at)[..20]),
434 at: site.at.clone(),
435 questions: vec![],
436 observes: vec![],
437 flows: vec![],
438 })
439 .collect(),
440 change_assessments: vec![],
441 retired_assertions: vec![],
442 },
443 State {
444 schema_version: 3,
445 inputs_digest: digest(inputs),
446 evidence_digest: evidence_digest.into(),
447 flows: BTreeMap::new(),
448 changes: vec![],
449 inheritance: None,
450 executions: None,
451 },
452 )
453}
454
455pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
457 let mut errors = Vec::new();
458 if map.schema_version != 2 || inputs.schema_version != 1 {
459 errors.push("unsupported schema version".into());
460 }
461 let mut ids = BTreeSet::new();
462 let mut sites = BTreeSet::new();
463 for a in &map.assertions {
464 if !valid_id(&a.id) || !ids.insert(&a.id) {
465 errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
466 }
467 if !sites.insert(&a.at) {
468 errors.push(format!("{}: duplicate assertion location", a.id));
469 }
470 if a.at.offset(&inputs.files).is_none() {
471 errors.push(format!("{}: invalid assertion anchor", a.id));
472 }
473 let mut flows = BTreeSet::new();
476 for f in &a.flows {
477 let key = flow_key(a, f);
478 if !valid_id(&f.id) || !flows.insert(&f.id) {
479 errors.push(format!("{key}: invalid/duplicate flow ID"));
480 }
481 errors.extend(
482 validate_flow(f, &inputs.files)
483 .into_iter()
484 .map(|e| format!("{key}: {e}")),
485 );
486 }
487 }
488 let mut changes = BTreeSet::new();
489 for change in &map.change_assessments {
490 if !valid_id(&change.id) || !changes.insert(&change.id) {
491 errors.push("invalid/duplicate change assessment ID".into());
492 }
493 }
494 errors
495}
496pub fn advisories(map: &AssertionMap) -> Vec<String> {
505 let mut out = Vec::new();
506 for a in &map.assertions {
507 for f in &a.flows {
508 for file in &f.watch {
509 if crate::integrity::globally_tracked(file) {
510 out.push(format!(
511 "{}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes",
512 flow_key(a, f)
513 ));
514 }
515 }
516 }
517 }
518 out
519}
520pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
521 let mut errors = Vec::new();
522 let mut nodes = BTreeSet::new();
523 for node in &flow.nodes {
524 if !valid_id(&node.id) || !nodes.insert(&node.id) {
525 errors.push("invalid/duplicate node ID".into());
526 }
527 if node.at.offset(files).is_none() {
528 errors.push(format!("node {}: invalid anchor", node.id));
529 }
530 }
531 for edge in &flow.edges {
532 if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
533 errors.push("dangling edge".into());
534 }
535 }
536 if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
537 errors.push("unknown counted node".into());
538 }
539 let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
541 loop {
542 let size = reaches.len();
543 for edge in &flow.edges {
544 if reaches.contains(&edge.to) {
545 reaches.insert(edge.from.clone());
546 }
547 }
548 if reaches.len() == size {
549 break;
550 }
551 }
552 for id in &flow.counts_as_asserted {
553 if !reaches.contains(id) {
554 errors.push(format!(
555 "counted node {id} has no authored path to $assertion"
556 ));
557 }
558 }
559 if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
560 errors.push("missing edge kind".into());
561 }
562 if flow
563 .applies_to
564 .iter()
565 .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
566 {
567 errors.push("invalid test selector file or name".into());
568 }
569 if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
570 errors.push("duplicate test selector".into());
571 }
572 if flow
573 .counts_as_asserted
574 .iter()
575 .collect::<BTreeSet<_>>()
576 .len()
577 != flow.counts_as_asserted.len()
578 {
579 errors.push("duplicate counted node".into());
580 }
581 if flow.explanation.trim().is_empty() {
582 errors.push("missing explanation".into());
583 }
584 for file in &flow.watch {
585 if !local_path(file) || !files.contains_key(file) {
586 errors.push(format!("watched file missing: {file}"));
587 }
588 }
589 errors
590}
591
592pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
594 std::iter::once(a.at.file.as_str())
595 .chain(f.applies_to.iter().map(|t| t.file.as_str()))
596 .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
597 .chain(
608 f.watch
609 .iter()
610 .map(String::as_str)
611 .filter(|path| !crate::integrity::globally_tracked(path)),
612 )
613 .collect()
614}
615fn token(value: &impl Serialize) -> String {
616 format!("scov3:{}", digest(value))
617}
618fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
619 map.assertions
620 .iter()
621 .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
622 .collect()
623}
624pub fn change_errors(
625 map: &AssertionMap,
626 change: &Change,
627 response: &ChangeAssessment,
628) -> Vec<String> {
629 change_errors_with(&flow_keys(map), change, response)
630}
631fn change_errors_with(
632 keys: &BTreeSet<String>,
633 change: &Change,
634 response: &ChangeAssessment,
635) -> Vec<String> {
636 let affected = response
637 .affected_flows
638 .iter()
639 .cloned()
640 .collect::<BTreeSet<_>>();
641 let mut errors = Vec::new();
642 if response.explanation.trim().is_empty() {
643 errors.push("missing impact explanation".into());
644 }
645 if affected.len() != response.affected_flows.len() {
646 errors.push("duplicate affected flow".into());
647 }
648 if !affected.is_subset(keys) {
649 errors.push("unknown affected flow".into());
650 }
651 if !change
652 .known_flows
653 .intersection(keys)
654 .all(|k| affected.contains(k))
655 {
656 errors.push("known dependent flows must be included unless removed from the map".into());
657 }
658 errors
659}
660pub fn expected_change_basis(
661 change: &Change,
662 response: &ChangeAssessment,
663 inputs: &InputManifest,
664) -> String {
665 expected_change_basis_with(change, response, &digest(inputs))
666}
667fn expected_change_basis_with(
668 change: &Change,
669 response: &ChangeAssessment,
670 inputs_digest: &str,
671) -> String {
672 token(&(
673 "supercov-change-v2",
674 change,
675 inputs_digest,
676 &response.id,
677 &response.affected_flows,
678 &response.explanation,
679 ))
680}
681pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
682 Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
683}
684pub struct Ledger<'a> {
691 pub inputs_digest: String,
692 keys: BTreeSet<String>,
693 current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
696}
697impl<'a> Ledger<'a> {
698 pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
699 Self::with_changes(map, &state.changes, inputs)
700 }
701 fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
702 let inputs_digest = digest(inputs);
703 let keys = flow_keys(map);
704 let current = changes
705 .iter()
706 .filter_map(|change| {
707 let responses = map
708 .change_assessments
709 .iter()
710 .filter(|r| r.id == change.id)
711 .collect::<Vec<_>>();
712 match responses.as_slice() {
713 [r] if change_errors_with(&keys, change, r).is_empty()
714 && r.basis.as_deref()
715 == Some(
716 expected_change_basis_with(change, r, &inputs_digest).as_str(),
717 ) =>
718 {
719 Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
720 }
721 _ => None,
722 }
723 })
724 .collect();
725 Self {
726 inputs_digest,
727 keys,
728 current,
729 }
730 }
731 pub fn current(&self, change: &str) -> bool {
733 self.current.contains_key(change)
734 }
735 pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
736 expected_change_basis_with(change, response, &self.inputs_digest)
737 }
738 pub fn change_errors(&self, change: &Change, response: &ChangeAssessment) -> Vec<String> {
739 change_errors_with(&self.keys, change, response)
740 }
741}
742fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
752 let mut roles: Vec<String> = Vec::new();
753 let lines = f
754 .nodes
755 .iter()
756 .filter(|n| n.at.file == file)
757 .map(|n| format!("{}:{}", n.id, n.at.line))
758 .collect::<Vec<_>>();
759 if !lines.is_empty() {
760 roles.push(format!("holds this flow's {}", lines.join(", ")));
761 }
762 if a.at.file == file {
763 roles.push(format!("holds the assertion, line {}", a.at.line));
764 }
765 if f.applies_to.iter().any(|t| t.file == file) {
766 roles.push("is the test this claim applies to".to_owned());
767 }
768 if f.watch.iter().any(|w| w == file) {
769 roles.push("is watched by this flow".to_owned());
770 }
771 roles
772}
773fn in_role(roles: &[String]) -> String {
774 if roles.is_empty() {
775 String::new()
778 } else {
779 format!(" ({})", roles.join("; "))
780 }
781}
782fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
786 a.at.file == file
787 || f.applies_to.iter().any(|t| t.file == file)
788 || f.watch.iter().any(|w| w == file)
789}
790#[derive(Serialize)]
799struct Site<'a> {
800 file: &'a str,
801 text: &'a str,
802 #[serde(skip_serializing_if = "Option::is_none")]
803 unit: Option<&'a str>,
804 line: usize,
805 #[serde(skip_serializing_if = "Option::is_none")]
806 column: Option<usize>,
807}
808fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
809 let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
810 let Some(code) = code else {
811 return Site {
812 file: &at.file,
813 text: &at.text,
814 unit: None,
815 line: at.line,
816 column: Some(at.column),
817 };
818 };
819 let holder = code.unit_at(at.line, at.column);
820 let mut boundary = code.units[holder].line;
821 for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
822 if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
823 boundary = child.end_line;
824 }
825 }
826 Site {
827 file: &at.file,
828 text: &at.text,
829 unit: Some(&code.units[holder].path),
830 line: code
831 .code_line(at.line)
832 .saturating_sub(code.code_line(boundary)),
833 column: None,
834 }
835}
836#[derive(Serialize)]
837#[serde(rename_all = "camelCase")]
838struct NodeClaim<'a> {
839 id: &'a str,
840 at: Site<'a>,
841 role: &'a str,
842 meaning: &'a str,
843}
844#[derive(Serialize)]
845#[serde(rename_all = "camelCase")]
846struct Claim<'a> {
847 id: &'a str,
848 explanation: &'a str,
849 applies_to: &'a [TestSelector],
850 nodes: Vec<NodeClaim<'a>>,
851 edges: &'a [Edge],
852 counts_as_asserted: &'a [String],
853 watch: &'a [String],
854 questions: &'a [String],
855}
856fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
857 Claim {
858 id: &f.id,
859 explanation: &f.explanation,
860 applies_to: &f.applies_to,
861 nodes: f
862 .nodes
863 .iter()
864 .map(|n| NodeClaim {
865 id: &n.id,
866 at: site(&n.at, inputs),
867 role: &n.role,
868 meaning: &n.meaning,
869 })
870 .collect(),
871 edges: &f.edges,
872 counts_as_asserted: &f.counts_as_asserted,
873 watch: &f.watch,
874 questions: &f.questions,
875 }
876}
877#[derive(Serialize)]
879#[serde(rename_all = "camelCase")]
880enum Footing<'a> {
881 Absent,
883 Bytes(&'a str),
885 Semantic(&'a str),
887 Units {
891 structure: &'a str,
892 units: BTreeMap<&'a str, &'a str>,
893 },
894}
895fn footing<'a>(
896 a: &'a Assertion,
897 f: &'a Flow,
898 inputs: &'a InputManifest,
899) -> BTreeMap<&'a str, Footing<'a>> {
900 dependencies(a, f)
901 .into_iter()
902 .map(|file| {
903 let Some(fingerprint) = inputs.files.get(file) else {
904 return (file, Footing::Absent);
905 };
906 let Some(code) = &fingerprint.code else {
907 return (file, Footing::Bytes(&fingerprint.sha256));
908 };
909 if whole_file(a, f, file) {
910 return (file, Footing::Semantic(&code.semantic));
911 }
912 let units = f
913 .nodes
914 .iter()
915 .filter(|n| n.at.file == file)
916 .flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
917 .map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
918 .collect();
919 (
920 file,
921 Footing::Units {
922 structure: &code.structure,
923 units,
924 },
925 )
926 })
927 .collect()
928}
929
930fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
931 let key = flow_key(a, f);
932 let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
933 let impacts = ledger
934 .current
935 .iter()
936 .filter(|(_, (_, affected))| affected.contains(&key))
937 .map(|(id, (basis, _))| (*id, *basis))
938 .collect::<BTreeMap<_, _>>();
939 if impacts.is_empty() {
940 base.into()
941 } else {
942 digest(&("supercov-generation-v2", base, impacts))
943 }
944}
945pub fn expected_basis(
946 a: &Assertion,
947 f: &Flow,
948 map: &AssertionMap,
949 state: &State,
950 inputs: &InputManifest,
951) -> String {
952 expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
953}
954pub fn expected_basis_with(
957 a: &Assertion,
958 f: &Flow,
959 state: &State,
960 inputs: &InputManifest,
961 ledger: &Ledger<'_>,
962) -> String {
963 token(&(
964 "supercov-flow-v3",
965 &inputs.context_digest,
966 &a.id,
967 site(&a.at, inputs),
968 &a.observes,
969 claim(f, inputs),
970 footing(a, f, inputs),
971 generation(a, f, state, ledger),
972 ))
973}
974pub fn reasons(
975 a: &Assertion,
976 f: &Flow,
977 map: &AssertionMap,
978 state: &State,
979 inputs: &Inputs,
980) -> BTreeSet<String> {
981 reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
982}
983pub fn reasons_for_manifest(
984 a: &Assertion,
985 f: &Flow,
986 map: &AssertionMap,
987 state: &State,
988 inputs: &Inputs,
989 manifest: &InputManifest,
990) -> BTreeSet<String> {
991 reasons_with(
992 a,
993 f,
994 state,
995 inputs,
996 manifest,
997 &Ledger::new(map, state, manifest),
998 )
999}
1000pub fn reasons_with(
1001 a: &Assertion,
1002 f: &Flow,
1003 state: &State,
1004 inputs: &Inputs,
1005 manifest: &InputManifest,
1006 ledger: &Ledger<'_>,
1007) -> BTreeSet<String> {
1008 let mut reasons = BTreeSet::new();
1009 if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
1010 reasons.insert("state does not match run inputs".into());
1011 }
1012 if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
1013 reasons.insert(
1014 match f.basis.as_deref() {
1015 None => "draft: input acknowledgement not recorded",
1016 Some(basis) if superseded_basis(basis) => SUPERSEDED_BASIS,
1017 Some(_) => "claim or inputs changed; needs rechecking",
1018 }
1019 .into(),
1020 );
1021 if let Some(s) = state.flows.get(&flow_key(a, f)) {
1022 reasons.extend(s.reasons.iter().cloned());
1023 }
1024 }
1025 reasons.extend(validate_flow(f, &inputs.files));
1026 if a.at.offset(&inputs.files).is_none() {
1027 reasons.insert("invalid assertion anchor".into());
1028 }
1029 if !f.questions.is_empty() {
1030 reasons.insert("flow has unresolved questions".into());
1031 }
1032 reasons
1033}
1034pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
1036 use serde_json::json;
1037 let manifest = inputs.manifest();
1038 let ledger = Ledger::new(map, state, &manifest);
1039 let mut errors = validate(map, inputs);
1040 for r in &map.change_assessments {
1041 if !state.changes.iter().any(|c| c.id == r.id) {
1042 errors.push(format!("{}: unknown change assessment", r.id));
1043 }
1044 }
1045 let selectors = map
1048 .assertions
1049 .iter()
1050 .flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
1051 .collect::<BTreeMap<_, _>>();
1052 let changes = state.changes.iter().map(|c| {
1053 let response = map.change_assessments.iter().find(|r| r.id == c.id);
1054 let faults = response.map(|r| ledger.change_errors(c, r)).unwrap_or_default();
1055 errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
1056 let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
1057 json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,"knownFlows":c.known_flows,
1058 "exposed":{"flows":c.exposed.len(),"tests":tests,"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
1059 "current":ledger.current(&c.id),"assessment":response,"errors":faults,
1060 "expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
1061 }).collect::<Vec<_>>();
1062 let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
1063 json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
1064 }).collect::<Vec<_>>();
1065 json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
1066 "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
1067}
1068pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
1069 for a in &map.assertions {
1070 for f in &a.flows {
1071 let key = flow_key(a, f);
1072 let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
1073 state.flows.insert(
1074 key,
1075 FlowState {
1076 generation: digest(&(base, reason, &state.inputs_digest)),
1077 reasons: BTreeSet::from([reason.into()]),
1078 notices: BTreeSet::new(),
1079 },
1080 );
1081 }
1082 }
1083}
1084pub fn add_change(
1085 state: &mut State,
1086 file: Option<String>,
1087 before: Option<String>,
1088 after: Option<String>,
1089 reason: String,
1090 known_flows: BTreeSet<String>,
1091 exposed: BTreeSet<String>,
1092) {
1093 let id = format!(
1095 "c_{}",
1096 &digest(&(
1097 "supercov-change-id-v2",
1098 &state.changes,
1099 &file,
1100 &before,
1101 &after,
1102 &reason
1103 ))[..24]
1104 );
1105 state.changes.push(Change {
1106 id,
1107 file,
1108 before,
1109 after,
1110 reason,
1111 known_flows,
1112 exposed,
1113 });
1114}
1115
1116fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
1117 if snippet.is_empty() {
1118 return None;
1119 }
1120 let first = text.find(snippet)?;
1121 let next = first + text[first..].chars().next()?.len_utf8();
1123 text[next..]
1124 .contains(snippet)
1125 .then_some(())
1126 .map_or(Some(first), |_| None)
1127}
1128fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
1129 if new.contains_key(file) {
1130 return Some(file.into());
1131 }
1132 let hash = old.get(file)?;
1133 let mut matches = new
1134 .iter()
1135 .filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
1136 let first = matches.next()?.0;
1137 matches.next().is_none().then(|| first.clone())
1138}
1139pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
1140 let before = old.get(&at.file)?;
1141 let target = target_file(&at.file, old, new)?;
1142 let after = &new[&target];
1143 let mut candidate = at.clone();
1144 candidate.file.clone_from(&target);
1145 if FileFingerprint::of(after).same_bytes(before) && candidate.offset(new).is_some() {
1146 return Some(candidate);
1147 }
1148 if let Some(start) = candidate.offset(new)
1158 && after.get(start..start + at.text.len()) == Some(at.text.as_str())
1159 {
1160 return Some(candidate);
1161 }
1162 let position = unique_occurrence(after, &at.text)?;
1163 Some(Anchor::new(
1164 &target,
1165 after,
1166 position,
1167 position + at.text.len(),
1168 ))
1169}
1170
1171pub enum FileChange<'a> {
1174 Same,
1175 CommentsOnly,
1177 Added,
1179 Removed,
1180 Bytes,
1182 Code {
1183 before: &'a Code,
1184 after: &'a Code,
1185 diff: Diff,
1186 narrow: bool,
1189 },
1190}
1191pub fn file_change<'a>(
1192 before: Option<&'a FileFingerprint>,
1193 after: Option<&'a FileFingerprint>,
1194 probed: Option<&[usize]>,
1195) -> Option<FileChange<'a>> {
1196 let Some(before) = before else {
1197 return after.map(|_| FileChange::Added);
1198 };
1199 let Some(after) = after else {
1200 return Some(FileChange::Removed);
1201 };
1202 if before.same_bytes(after) {
1203 return Some(FileChange::Same);
1204 }
1205 let (Some(old), Some(new)) = (&before.code, &after.code) else {
1206 return Some(FileChange::Bytes);
1207 };
1208 if old.semantic == new.semantic {
1209 return Some(FileChange::CommentsOnly);
1210 }
1211 let diff = old.diff(new);
1212 let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
1213 Some(FileChange::Code {
1214 before: old,
1215 after: new,
1216 diff,
1217 narrow,
1218 })
1219}
1220pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
1222 let mut parts = Vec::new();
1223 if !diff.changed.is_empty() {
1224 parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
1225 }
1226 if !diff.added.is_empty() {
1227 parts.push(format!(
1228 "added {}",
1229 named(diff.added.iter().map(|i| &after.units[*i]))
1230 ));
1231 }
1232 if !diff.removed.is_empty() {
1233 parts.push(format!(
1234 "removed {}",
1235 named(diff.removed.iter().map(|i| &before.units[*i]))
1236 ));
1237 }
1238 if parts.is_empty() {
1239 "declarations".to_owned()
1240 } else {
1241 parts.join("; ")
1242 }
1243}
1244fn executed<'s>(
1248 records: &BTreeMap<&TestSelector, &'s Execution>,
1249 f: &Flow,
1250 manifest: &InputManifest,
1251) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
1252 let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
1253 for selector in &f.applies_to {
1254 let record = records.get(selector)?;
1255 for (file, units) in &record.files {
1256 let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
1257 let set = out.entry(file.as_str()).or_default();
1258 for &unit in units {
1259 match code {
1260 Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
1261 _ => {
1262 set.insert(unit);
1263 }
1264 }
1265 }
1266 }
1267 }
1268 Some(out)
1269}
1270
1271pub fn carry(
1287 map: &AssertionMap,
1288 state: &State,
1289 old: &InputManifest,
1290 new: &Inputs,
1291 evidence_digest: &str,
1292 context_changed: bool,
1293) -> Result<(AssertionMap, State), String> {
1294 if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
1295 return Err("unsupported map/input schema version".into());
1296 }
1297 if state.inputs_digest != digest(old) || state.schema_version != 3 {
1298 return Err("old map state does not match its run inputs".into());
1299 }
1300 let ledger = Ledger::new(map, state, old);
1301 let new_manifest = new.manifest();
1302 let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
1303 next.assertions.clear();
1304 next.retired_assertions = map.retired_assertions.clone();
1305 next_state.changes = state
1306 .changes
1307 .iter()
1308 .filter(|c| !ledger.current(&c.id))
1309 .cloned()
1310 .collect();
1311 next.change_assessments = map
1312 .change_assessments
1313 .iter()
1314 .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
1315 .cloned()
1316 .collect();
1317 let records = state
1318 .executions
1319 .iter()
1320 .flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
1321 .collect::<BTreeMap<_, _>>();
1322 let probed = |file: &str| {
1323 state
1324 .executions
1325 .as_ref()
1326 .and_then(|e| e.probed.get(file))
1327 .map(Vec::as_slice)
1328 };
1329 let changes = old
1330 .files
1331 .keys()
1332 .chain(new_manifest.files.keys())
1333 .collect::<BTreeSet<_>>()
1334 .into_iter()
1335 .filter_map(|file| {
1336 file_change(
1337 old.files.get(file),
1338 new_manifest.files.get(file),
1339 probed(file),
1340 )
1341 .map(|change| (file.as_str(), change))
1342 })
1343 .collect::<BTreeMap<_, _>>();
1344 let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1348 let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1349 let mut consumed = BTreeSet::new();
1350 let exact = map
1351 .assertions
1352 .iter()
1353 .map(|a| {
1354 relocate(&a.at, &old.files, &new.files).filter(|at| {
1355 new.assertions.iter().any(|s| &s.at == at)
1356 || !old.assertions.iter().any(|s| s.at == a.at)
1357 })
1358 })
1359 .collect::<Vec<_>>();
1360 let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
1361 for (index, a) in map.assertions.iter().enumerate() {
1362 let candidates = new
1365 .assertions
1366 .iter()
1367 .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
1368 .collect::<Vec<_>>();
1369 let unmatched = map
1370 .assertions
1371 .iter()
1372 .zip(&exact)
1373 .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
1374 .count();
1375 let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
1376 Some(&candidates[0].at)
1377 } else {
1378 None
1379 };
1380 let matched = exact[index]
1381 .as_ref()
1382 .or(replacement)
1383 .filter(|at| !consumed.contains(*at));
1384 let Some(at) = matched else {
1385 next.retired_assertions.push(Retired {
1386 assertion: a.clone(),
1387 reason:
1388 "assertion removed, changed or ambiguous; reuse its explanation after review"
1389 .into(),
1390 });
1391 continue;
1392 };
1393 consumed.insert(at.clone());
1394 let mut updated = a.clone();
1395 updated.at = at.clone();
1396 for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
1397 let key = flow_key(a, f);
1398 let base = generation(a, prior, state, &ledger);
1399 let mut dirty = BTreeSet::new();
1400 let mut notices = BTreeSet::new();
1401 match prior.basis.as_deref() {
1402 Some(basis) if superseded_basis(basis) => {
1403 dirty.insert(SUPERSEDED_BASIS.into());
1404 }
1405 Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
1406 dirty.insert("inherited claim still needs rechecking".into());
1407 }
1408 _ => {}
1409 }
1410 match executed(&records, prior, old) {
1414 Some(ran) => {
1415 for (file, units) in &ran {
1416 let reached = match changes.get(file) {
1417 None
1418 | Some(
1419 FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
1420 ) => false,
1421 Some(FileChange::Removed | FileChange::Bytes) => true,
1422 Some(FileChange::Code { diff, narrow, .. }) => {
1423 !*narrow || diff.changed.iter().any(|i| units.contains(i))
1424 }
1425 };
1426 if reached {
1427 exposed.entry(file).or_default().insert(key.clone());
1428 }
1429 }
1430 }
1431 None => {
1432 for (file, change) in &changes {
1433 if !matches!(
1434 change,
1435 FileChange::Same | FileChange::CommentsOnly | FileChange::Added
1436 ) {
1437 exposed.entry(file).or_default().insert(key.clone());
1438 }
1439 }
1440 }
1441 }
1442 for file in dependencies(a, prior) {
1446 let roles = roles(a, prior, file);
1447 let verdict = match changes.get(file) {
1448 None => Some(format!(
1449 "{file} is not among the run's inputs{}",
1450 in_role(&roles)
1451 )),
1452 Some(FileChange::Added) => Some(format!(
1453 "{file} is new since the previous run{}",
1454 in_role(&roles)
1455 )),
1456 Some(FileChange::Same | FileChange::CommentsOnly) => None,
1457 Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
1458 Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
1459 Some(FileChange::Code {
1460 before,
1461 after,
1462 diff,
1463 ..
1464 }) => {
1465 if whole_file(a, prior, file) {
1466 Some(format!(
1467 "{file}: {} changed{}",
1468 describe(before, after, diff),
1469 in_role(&roles)
1470 ))
1471 } else {
1472 let holders = prior
1473 .nodes
1474 .iter()
1475 .filter(|n| n.at.file == file)
1476 .flat_map(|n| {
1477 before.ancestors(before.unit_at(n.at.line, n.at.column))
1478 })
1479 .collect::<BTreeSet<_>>();
1480 let moved = holders
1481 .iter()
1482 .filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
1483 .map(|i| &before.units[*i])
1484 .collect::<Vec<_>>();
1485 if !moved.is_empty() {
1486 Some(format!(
1487 "{file}: {} changed{}",
1488 named(moved),
1489 in_role(&roles)
1490 ))
1491 } else if diff.structural {
1492 Some(format!(
1493 "{file}: declarations changed, {}{}",
1494 describe(before, after, diff),
1495 in_role(&roles)
1496 ))
1497 } else {
1498 notices.insert(format!(
1499 "{file} changed outside this flow's nodes: {}",
1500 describe(before, after, diff)
1501 ));
1502 None
1503 }
1504 }
1505 }
1506 };
1507 if let Some(reason) = verdict {
1508 dirty.insert(reason);
1509 marked.entry(file).or_default().insert(key.clone());
1510 }
1511 }
1512 if replacement.is_some() {
1513 dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
1514 }
1515 for node in &mut f.nodes {
1516 if let Some(at) = relocate(&node.at, &old.files, &new.files) {
1517 node.at = at;
1518 } else {
1519 dirty.insert(format!("node {} changed or ambiguous", node.id));
1520 }
1521 }
1522 for file in f
1523 .watch
1524 .iter_mut()
1525 .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
1526 {
1527 if let Some(target) = target_file(file, &old.files, &new.files) {
1528 *file = target;
1529 } else {
1530 dirty.insert(format!("dependency file removed: {file}"));
1531 }
1532 }
1533 if context_changed {
1534 dirty.insert("run configuration, dependencies or execution context changed".into());
1535 }
1536 next_state.flows.insert(
1537 key,
1538 FlowState {
1539 generation: if dirty.is_empty() {
1540 base
1541 } else {
1542 digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
1543 },
1544 reasons: dirty,
1545 notices,
1546 },
1547 );
1548 }
1549 next.assertions.push(updated);
1550 }
1551 let mut ids = map
1552 .assertions
1553 .iter()
1554 .map(|a| a.id.clone())
1555 .chain(
1556 map.retired_assertions
1557 .iter()
1558 .map(|r| r.assertion.id.clone()),
1559 )
1560 .collect::<BTreeSet<_>>();
1561 for a in seed(new, evidence_digest).0.assertions {
1562 if !consumed.contains(&a.at) {
1563 let mut a = a;
1564 while !ids.insert(a.id.clone()) {
1565 a.id.push('_');
1566 }
1567 next.assertions.push(a);
1568 }
1569 }
1570 for file in old
1571 .files
1572 .keys()
1573 .chain(new_manifest.files.keys())
1574 .collect::<BTreeSet<_>>()
1575 {
1576 if crate::integrity::tracked_manifest(file) {
1581 continue;
1582 }
1583 let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
1584 match changes.get(file.as_str()) {
1585 Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1587 Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
1591 _ => {}
1592 }
1593 add_change(
1594 &mut next_state,
1595 Some(file.clone()),
1596 old.files.get(file).map(|f| f.sha256.clone()),
1597 new_manifest.files.get(file).map(|f| f.sha256.clone()),
1598 "captured source file changed".into(),
1599 marked.get(file.as_str()).cloned().unwrap_or_default(),
1600 exposed_to,
1601 );
1602 }
1603 next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1604 Ok((next, next_state))
1605}
1606
1607#[path = "assertion_legacy.rs"]
1608mod legacy;
1609pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1610 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1611 match value
1612 .get("schemaVersion")
1613 .and_then(serde_json::Value::as_u64)
1614 {
1615 None | Some(1) => legacy::import(bytes),
1616 _ => parse(bytes).map_err(|e| e.to_string()),
1617 }
1618}
1619pub fn parse_state(
1620 bytes: &[u8],
1621 map: &AssertionMap,
1622 inputs: &InputManifest,
1623 evidence: &str,
1624 legacy_digest: Option<&str>,
1625) -> Result<State, String> {
1626 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1627 if value["schemaVersion"] == 3 {
1628 let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1629 if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1630 return Err("Assertion state belongs to different run evidence; rerun tests".into());
1631 }
1632 Ok(state)
1633 } else {
1634 legacy::state(bytes, map, inputs, evidence, legacy_digest)
1635 }
1636}