1use std::collections::BTreeMap;
10
11use crate::digest::Digest;
12use crate::error::RkError;
13use crate::landing::manifest::{self, Alignment, FileRecord, Manifest, Parameters};
14use crate::landing::{self, Entry, Kind, Placement};
15use crate::release::{PAYLOAD_SCHEMA, ReleaseManifest, ReleaseSource};
16
17use super::classify::{self, Finding, RecordState as ClassifyRecord, Verdict};
18use super::evidence::EvidenceKind;
19use super::gather::{ConfigRead, ForgeRead, Observation, RecordRead, Resolution};
20use super::operation::Operation;
21use super::readiness::{self, Evaluation, Precondition, Requirement};
22use super::{
23 BaselineState, BundleIdentity, Choice, Compatibility, Configuration, ConfigurationState,
24 Decision, DesiredState, Destination, ForgeState, Guidance, Host, Identity, Installation,
25 ObservedState, PLAN_SCHEMA, Plan, Planned, Postcondition, RecordState, Release, Repository,
26 ResolvedRelease, Verification, fingerprint,
27};
28
29pub struct Candidate<'a> {
31 pub source: &'a dyn ReleaseSource,
33 pub manifest: ReleaseManifest,
35 pub venue: &'a str,
37 pub verification: Verification,
39}
40
41pub enum Baseline<'a> {
43 NotNeeded,
45 Embedded(&'a dyn ReleaseSource),
47 Cached {
49 version: String,
51 source: &'a dyn ReleaseSource,
53 },
54 NotObserved {
56 reason: String,
58 },
59}
60
61pub struct Inputs<'a> {
63 pub clock: &'a str,
65 pub engine_version: &'a str,
67 pub selector: &'a str,
69 pub candidate: Candidate<'a>,
71 pub baseline: Baseline<'a>,
73 pub observation: Observation,
75 pub resolution: Resolution,
77 pub selected: &'a BTreeMap<String, String>,
79}
80
81struct Compared<'a> {
83 entry: &'a Entry,
84 before: Option<Digest>,
86 write: bool,
88 conflict: bool,
90 missing: bool,
92 record: FileRecord,
94}
95
96#[allow(
103 clippy::too_many_lines,
104 reason = "one plan is one linear derivation from observation to fingerprint, and cutting it would separate a section from the inputs it cites"
105)]
106pub fn plan(inputs: Inputs<'_>) -> Result<Planned, RkError> {
107 let Inputs {
108 clock,
109 engine_version,
110 selector,
111 candidate,
112 baseline,
113 mut observation,
114 resolution,
115 selected,
116 } = inputs;
117 let mut blobs: BTreeMap<Digest, Vec<u8>> = BTreeMap::new();
118 let mut findings: Vec<Finding> = Vec::new();
119 let mut preconditions: Vec<Precondition> = Vec::new();
120 let mut decisions: Vec<Decision> = Vec::new();
121
122 let candidate_ref = observation.ledger.observe(
124 "candidate-bundle",
125 EvidenceKind::Bundle,
126 candidate.venue,
127 clock,
128 Some(candidate.manifest.payload_sha256.clone()),
129 format!("manifest through the {} source", candidate.venue),
130 );
131 let baseline_state = match &baseline {
132 Baseline::NotNeeded => BaselineState::NotNeeded,
133 Baseline::Embedded(_) => BaselineState::Embedded,
134 Baseline::Cached { version, .. } => BaselineState::Cached {
135 version: version.clone(),
136 },
137 Baseline::NotObserved { reason } => BaselineState::NotObserved {
138 reason: reason.clone(),
139 },
140 };
141 let baseline_source: Option<&dyn ReleaseSource> = match &baseline {
142 Baseline::Embedded(source) | Baseline::Cached { source, .. } => Some(*source),
143 Baseline::NotNeeded | Baseline::NotObserved { .. } => None,
144 };
145 let baseline_ref = baseline_source.map(|source| {
146 let digest = source
147 .manifest()
148 .ok()
149 .map(|manifest| manifest.payload_sha256);
150 observation.ledger.observe(
151 "baseline-bundle",
152 EvidenceKind::Bundle,
153 "recorded release",
154 clock,
155 digest,
156 "manifest through the seam",
157 )
158 });
159
160 let verdict = classify::verdict(&observation.facts);
162 let record_state = match &observation.record {
163 RecordRead::Absent => ClassifyRecord::Absent,
164 RecordRead::Present { .. } => ClassifyRecord::Present,
165 RecordRead::Invalid { .. } => ClassifyRecord::Invalid,
166 };
167 let recorded: Option<&Manifest> = match &observation.record {
168 RecordRead::Present { manifest, .. } => Some(manifest),
169 RecordRead::Absent | RecordRead::Invalid { .. } => None,
170 };
171 if recorded.is_none() {
172 for marker in &observation.facts.release_markers {
173 findings.push(Finding {
174 code: "release-marker",
175 detail: marker.clone(),
176 });
177 }
178 for collision in &observation.facts.collisions {
179 findings.push(Finding {
180 code: "payload-collision",
181 detail: collision.clone(),
182 });
183 }
184 if observation.facts.tags > 0 {
185 findings.push(Finding {
186 code: "tag",
187 detail: format!(
188 "{} tags with no mechanism behind them",
189 observation.facts.tags
190 ),
191 });
192 }
193 for branch in &observation.facts.long_lived_branches {
194 findings.push(Finding {
195 code: "long-lived-branch",
196 detail: branch.clone(),
197 });
198 }
199 }
200 if let RecordRead::Invalid { reason } = &observation.record {
201 findings.push(Finding {
202 code: "record-invalid",
203 detail: reason.clone(),
204 });
205 }
206
207 let mut entries: Vec<Entry> = Vec::new();
209 if let Some(params) = &resolution.params {
210 entries = landing::projection(candidate.source, params)?;
211 if let Some((set, _)) = &resolution.nix_withheld {
212 entries.retain(|entry| !set.iter().any(|path| path == &entry.destination));
213 }
214 }
215
216 let mut compared: Vec<Compared<'_>> = Vec::new();
219 for entry in &entries {
220 let disk = observation.files.get(&entry.destination).map(Vec::as_slice);
221 let before = disk.map(Digest::of);
222 let comparison = recorded.map_or_else(
223 || compare_fresh(entry, disk),
224 |record| compare_recorded(entry, record.file(&entry.destination), disk),
225 );
226 if comparison.write {
227 blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
228 if let Some(bytes) = disk {
229 blobs.insert(Digest::of(bytes), bytes.to_vec());
230 }
231 }
232 if comparison.conflict {
233 if let Some(bytes) = disk {
234 blobs.insert(Digest::of(bytes), bytes.to_vec());
235 }
236 blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
237 if let (Some(source), Some(record)) = (baseline_source, recorded) {
238 if let Some(baseline_bytes) = baseline_bytes(source, record, entry) {
239 blobs.insert(Digest::of(&baseline_bytes), baseline_bytes);
240 }
241 }
242 }
243 compared.push(Compared {
244 entry,
245 before,
246 write: comparison.write,
247 conflict: comparison.conflict,
248 missing: comparison.missing,
249 record: comparison.record,
250 });
251 }
252 let owned_drift = compared.iter().any(|c| c.conflict) || observation.hooks_defect.is_some();
253 for c in compared.iter().filter(|c| c.conflict) {
254 findings.push(Finding {
255 code: if c.missing {
256 "owned-missing"
257 } else {
258 "owned-drift"
259 },
260 detail: c.entry.destination.clone(),
261 });
262 }
263 if let Some(defect) = &observation.hooks_defect {
264 findings.push(Finding {
265 code: "owned-drift",
266 detail: defect.clone(),
267 });
268 }
269 let classification = classify::classify(record_state, verdict, owned_drift);
270
271 let mut operations: Vec<Operation> = Vec::new();
274 for c in compared.iter().filter(|c| c.write && !c.conflict) {
275 let after = Digest::of(&c.entry.rendered);
276 operations.push(match c.entry.placement {
277 Placement::Whole => Operation::WriteFile {
278 path: c.entry.destination.clone(),
279 kind: c.entry.kind,
280 before: c.before.clone(),
281 after,
282 },
283 Placement::Block => Operation::SpliceBlock {
284 path: c.entry.destination.clone(),
285 marker: landing::block_markers(&c.entry.destination)
286 .map_or("", |(begin, _)| begin)
287 .to_owned(),
288 before: c.before.clone(),
289 after,
290 },
291 });
292 }
293 let candidate_version = candidate.manifest.release_kit_version.clone();
294 if let Some(pin) = &observation.pin {
295 if trim_v(&pin.version) != trim_v(&candidate_version) {
296 operations.push(Operation::UpdatePin {
297 manager: pin.manager.clone(),
298 before: pin.version.clone(),
299 after: candidate_version.clone(),
300 });
301 }
302 }
303 let planned_record = match (&resolution.params, record_state) {
304 (Some(params), ClassifyRecord::Absent | ClassifyRecord::Present) if !owned_drift => {
305 let record = planned_manifest(
306 &candidate,
307 params,
308 recorded,
309 clock,
310 compared.iter().map(|c| &c.record),
311 );
312 let bytes = manifest::render(&record)?;
313 let after = Digest::of(&bytes);
314 let before = match &observation.record {
315 RecordRead::Present { bytes, .. } => Some(Digest::of(bytes)),
316 RecordRead::Absent | RecordRead::Invalid { .. } => None,
317 };
318 if before.as_ref() == Some(&after) {
319 None
320 } else {
321 blobs.insert(after.clone(), bytes);
322 if let RecordRead::Present { bytes, .. } = &observation.record {
323 blobs.insert(Digest::of(bytes), bytes.clone());
324 }
325 Some(Operation::WriteRecord { before, after })
326 }
327 }
328 _ => None,
329 };
330 if let Some(operation) = planned_record {
331 operations.push(operation);
332 }
333
334 let record_ref = observation.refs.record.clone();
336 let config_ref = observation.refs.configuration.clone();
337 let resolution_refs: Vec<String> = record_ref
338 .iter()
339 .chain(config_ref.iter())
340 .chain(observation.refs.repository.iter())
341 .cloned()
342 .collect();
343 preconditions.push(Precondition {
344 id: "technology-resolved".into(),
345 requirement: Requirement::Required,
346 evaluation: resolution
347 .unresolved
348 .as_ref()
349 .map_or(Evaluation::Satisfied, |reason| Evaluation::Unsatisfied {
350 reason: reason.clone(),
351 }),
352 decision: None,
353 evidence_refs: resolution_refs.clone(),
354 });
355 if let RecordRead::Invalid { reason } = &observation.record {
356 preconditions.push(Precondition {
357 id: "record-readable".into(),
358 requirement: Requirement::Required,
359 evaluation: Evaluation::Unsatisfied {
360 reason: reason.clone(),
361 },
362 decision: None,
363 evidence_refs: record_ref.iter().cloned().collect(),
364 });
365 } else if recorded.is_some() {
366 preconditions.push(Precondition {
367 id: "record-readable".into(),
368 requirement: Requirement::Required,
369 evaluation: Evaluation::Satisfied,
370 decision: None,
371 evidence_refs: record_ref.iter().cloned().collect(),
372 });
373 }
374 if let ConfigRead::Invalid { reason, .. } = &observation.config {
375 preconditions.push(Precondition {
376 id: "configuration-readable".into(),
377 requirement: Requirement::Required,
378 evaluation: Evaluation::Unsatisfied {
379 reason: reason.clone(),
380 },
381 decision: None,
382 evidence_refs: config_ref.iter().cloned().collect(),
383 });
384 }
385 if let Some(record) = recorded {
386 let newer =
387 manifest::alignment(&record.rk_version, engine_version) == Alignment::TargetNewer;
388 if newer {
389 findings.push(Finding {
390 code: "record-newer",
391 detail: record.rk_version.clone(),
392 });
393 }
394 preconditions.push(Precondition {
395 id: "engine-not-older-than-record".into(),
396 requirement: Requirement::Required,
397 evaluation: if newer {
398 Evaluation::Unsatisfied {
399 reason: format!(
400 "the record came from rk {}, newer than this engine's {engine_version}; install release-kit {} or newer",
401 record.rk_version, record.rk_version
402 ),
403 }
404 } else {
405 Evaluation::Satisfied
406 },
407 decision: None,
408 evidence_refs: record_ref.iter().cloned().collect(),
409 });
410 }
411 let bundle_schema = candidate.manifest.payload_schema;
412 preconditions.push(Precondition {
413 id: "bundle-schema-readable".into(),
414 requirement: Requirement::Required,
415 evaluation: if bundle_schema <= PAYLOAD_SCHEMA {
416 Evaluation::Satisfied
417 } else {
418 Evaluation::Unsatisfied {
419 reason: format!(
420 "the bundle declares payload schema {bundle_schema}, and this engine reads schema {PAYLOAD_SCHEMA} at most; install release-kit {candidate_version} or newer"
421 ),
422 }
423 },
424 decision: None,
425 evidence_refs: vec![candidate_ref.clone()],
426 });
427 if let Some(hooks_ref) = observation
428 .refs
429 .destinations
430 .get(landing::HOOKS_DESTINATION)
431 .cloned()
432 {
433 preconditions.push(Precondition {
434 id: "hooks-file-spliceable".into(),
435 requirement: Requirement::Required,
436 evaluation: observation
437 .hooks_defect
438 .as_ref()
439 .map_or(Evaluation::Satisfied, |defect| Evaluation::Unsatisfied {
440 reason: defect.clone(),
441 }),
442 decision: None,
443 evidence_refs: vec![hooks_ref],
444 });
445 }
446 for c in compared.iter().filter(|c| c.conflict) {
447 let path = &c.entry.destination;
448 let mut refs: Vec<String> = observation
449 .refs
450 .destinations
451 .get(path)
452 .cloned()
453 .into_iter()
454 .collect();
455 refs.extend(record_ref.iter().cloned());
456 refs.extend(baseline_ref.iter().cloned());
457 preconditions.push(Precondition {
458 id: format!("owned-file-unedited:{path}"),
459 requirement: Requirement::Required,
460 evaluation: Evaluation::Unsatisfied {
461 reason: if c.missing {
462 "the record names it and the disk does not hold it".to_owned()
463 } else if recorded.is_some() {
464 "the target edited a file release-kit owns".to_owned()
465 } else {
466 "the destination exists with different content".to_owned()
467 },
468 },
469 decision: None,
470 evidence_refs: refs,
471 });
472 }
473 let file_operations = operations
474 .iter()
475 .any(|operation| operation.path().is_some());
476 if recorded.is_some() {
477 let (requirement, evaluation, decision) = match &baseline_state {
478 BaselineState::NotObserved { reason } => {
479 let answered = selected.get("partial-baseline").map(String::as_str);
480 decisions.push(Decision {
481 id: "partial-baseline".into(),
482 question: "plan against the record's digests alone, with the recorded release's bytes unread?".into(),
483 choices: vec![
484 Choice {
485 answer: "accept".into(),
486 consequence: "the three-way comparison shows what diverged and cannot show the baseline it diverged from".into(),
487 },
488 Choice {
489 answer: "fetch".into(),
490 consequence: "re-plan with --fetch so the recorded release's bundle is read through the crates venue".into(),
491 },
492 ],
493 selected: answered.map(str::to_owned),
494 });
495 (
496 if file_operations {
497 Requirement::DecisionRequired
498 } else {
499 Requirement::Advisory
500 },
501 if answered == Some("accept") {
502 Evaluation::Satisfied
503 } else {
504 Evaluation::NotObserved {
505 reason: reason.clone(),
506 }
507 },
508 Some("partial-baseline".to_owned()),
509 )
510 }
511 _ => (Requirement::Advisory, Evaluation::Satisfied, None),
512 };
513 preconditions.push(Precondition {
514 id: "baseline-observed".into(),
515 requirement,
516 evaluation,
517 decision,
518 evidence_refs: baseline_ref.iter().cloned().collect(),
519 });
520 }
521 if recorded.is_none() && record_state == ClassifyRecord::Absent {
522 let source = resolution
523 .sources
524 .get("workflow")
525 .map_or("default", String::as_str);
526 let answered = (source != "default").then(|| {
527 resolution.params.as_ref().map_or_else(
528 || "worktree".to_owned(),
529 |p| p.workflow().as_str().to_owned(),
530 )
531 });
532 decisions.push(Decision {
533 id: "workflow-mode".into(),
534 question: "which working-copy mode does this project choose?".into(),
535 choices: vec![
536 Choice {
537 answer: "worktree".into(),
538 consequence: "every code-changing branch lives in a linked worktree and the main checkout commits nothing".into(),
539 },
540 Choice {
541 answer: "branches".into(),
542 consequence: "branches are worked in the main checkout, with worktrees optional beside it".into(),
543 },
544 ],
545 selected: answered.clone(),
546 });
547 preconditions.push(Precondition {
548 id: "workflow-mode-answered".into(),
549 requirement: Requirement::DecisionRequired,
550 evaluation: if answered.is_some() {
551 Evaluation::Satisfied
552 } else {
553 Evaluation::NotObserved {
554 reason: "no flag, configuration, or decision names the mode".into(),
555 }
556 },
557 decision: Some("workflow-mode".into()),
558 evidence_refs: resolution_refs.clone(),
559 });
560 }
561 if let Some(record) = recorded {
562 if record.parameters.style.is_none() {
563 let source = resolution
564 .sources
565 .get("style")
566 .map_or("default", String::as_str);
567 let answered = (source != "default").then(|| {
568 resolution
569 .params
570 .as_ref()
571 .and_then(landing::Params::style)
572 .map_or_else(|| "trunk".to_owned(), |s| s.as_str().to_owned())
573 });
574 decisions.push(Decision {
575 id: "release-style".into(),
576 question: "the record predates the release style; which one does this project run?".into(),
577 choices: vec![
578 Choice {
579 answer: "trunk".into(),
580 consequence: "the bot's release request is armed to merge itself".into(),
581 },
582 Choice {
583 answer: "lines".into(),
584 consequence: "every merge is a human's, and release lines carry the maintained versions".into(),
585 },
586 ],
587 selected: answered.clone(),
588 });
589 preconditions.push(Precondition {
590 id: "release-style-answered".into(),
591 requirement: Requirement::DecisionRequired,
592 evaluation: if answered.is_some() {
593 Evaluation::Satisfied
594 } else {
595 Evaluation::NotObserved {
596 reason: "neither the configuration nor a decision names the style".into(),
597 }
598 },
599 decision: Some("release-style".into()),
600 evidence_refs: resolution_refs.clone(),
601 });
602 }
603 }
604 if recorded.is_none() && verdict == Verdict::NeedsDecision {
605 let answered = selected.get("release-activity").cloned();
606 decisions.push(Decision {
607 id: "release-activity".into(),
608 question: "tags or a second long-lived branch exist with no mechanism behind them; what are they?".into(),
609 choices: vec![
610 Choice {
611 answer: "history".into(),
612 consequence: "the activity is history the landing leaves in place, and the plan proceeds as a setup".into(),
613 },
614 Choice {
615 answer: "migrate".into(),
616 consequence: "another mechanism made them; follow the migration procedure and retire it first".into(),
617 },
618 ],
619 selected: answered.clone(),
620 });
621 preconditions.push(Precondition {
622 id: "release-activity-explained".into(),
623 requirement: Requirement::DecisionRequired,
624 evaluation: if answered.is_some() {
625 Evaluation::Satisfied
626 } else {
627 Evaluation::NotObserved {
628 reason: "the operator has not said what the release activity is".into(),
629 }
630 },
631 decision: Some("release-activity".into()),
632 evidence_refs: observation.refs.repository.clone(),
633 });
634 }
635 preconditions.push(Precondition {
636 id: "forge-observed".into(),
637 requirement: Requirement::Advisory,
638 evaluation: match &observation.forge {
639 ForgeRead::Observed { .. } => Evaluation::Satisfied,
640 ForgeRead::NotObserved { reason } => Evaluation::NotObserved {
641 reason: reason.clone(),
642 },
643 },
644 decision: None,
645 evidence_refs: observation.refs.forge.clone(),
646 });
647 preconditions.push(Precondition {
648 id: "pin-wired".into(),
649 requirement: Requirement::Advisory,
650 evaluation: if observation.pin.is_some() {
651 Evaluation::Satisfied
652 } else {
653 Evaluation::NotObserved {
654 reason: "no manager file names release-kit".into(),
655 }
656 },
657 decision: None,
658 evidence_refs: observation.refs.pin.iter().cloned().collect(),
659 });
660 let readiness = readiness::derive(&preconditions);
661
662 let mut postconditions: Vec<Postcondition> = Vec::new();
664 for operation in &operations {
665 match operation {
666 Operation::WriteFile { path, after, .. }
667 | Operation::SpliceBlock { path, after, .. } => {
668 postconditions.push(Postcondition::DestinationHolds {
669 path: path.clone(),
670 sha256: after.clone(),
671 });
672 }
673 Operation::WriteRecord { after, .. } => {
674 postconditions.push(Postcondition::RecordReadsBack {
675 sha256: after.clone(),
676 });
677 }
678 Operation::UpdatePin { manager, after, .. } => {
679 postconditions.push(Postcondition::PinReads {
680 manager: manager.clone(),
681 version: after.clone(),
682 });
683 }
684 Operation::RemoveOwnedFile { .. } => {}
685 }
686 }
687 if !operations.is_empty() {
688 postconditions.push(Postcondition::StatusCheckClean);
689 }
690
691 let configuration = resolution.params.as_ref().map(|params| Configuration {
693 tech: params.tech().to_owned(),
694 forge: params.forge().to_owned(),
695 repo: params.repo().to_owned(),
696 workflow: params.workflow().as_str().to_owned(),
697 style: params.style().map(|style| style.as_str().to_owned()),
698 nix: params.nix(),
699 trunk: params.trunk().to_owned(),
700 line_prefix: params.line_prefix().to_owned(),
701 security_contact: params.security_contact().to_owned(),
702 security_response: params.security_response().to_owned(),
703 sources: resolution.sources.clone(),
704 evidence_refs: resolution_refs.clone(),
705 });
706 let record_view = match &observation.record {
707 RecordRead::Absent => RecordState::Absent,
708 RecordRead::Present { manifest, bytes } => RecordState::Present {
709 rk_version: manifest.rk_version.clone(),
710 payload_sha256: manifest.payload_sha256.clone(),
711 schema_version: manifest.schema_version,
712 origin: manifest.origin.clone(),
713 sha256: Digest::of(bytes),
714 },
715 RecordRead::Invalid { reason } => RecordState::Invalid {
716 reason: reason.clone(),
717 },
718 };
719 let configuration_view = match &observation.config {
720 ConfigRead::Absent => ConfigurationState {
721 present: false,
722 sha256: None,
723 invalid: None,
724 pending: Vec::new(),
725 },
726 ConfigRead::Present { config, bytes } => ConfigurationState {
727 present: true,
728 sha256: Some(Digest::of(bytes)),
729 invalid: None,
730 pending: recorded
731 .map_or_else(Vec::new, |record| crate::config::pending(config, record)),
732 },
733 ConfigRead::Invalid { reason, bytes } => ConfigurationState {
734 present: true,
735 sha256: Some(Digest::of(bytes)),
736 invalid: Some(reason.clone()),
737 pending: Vec::new(),
738 },
739 };
740 let mut destination_paths: Vec<String> = entries
741 .iter()
742 .map(|entry| entry.destination.clone())
743 .chain(
744 recorded
745 .into_iter()
746 .flat_map(|record| record.files.iter().map(|file| file.destination.clone())),
747 )
748 .collect();
749 destination_paths.sort();
750 destination_paths.dedup();
751 let destinations: Vec<Destination> = destination_paths
752 .into_iter()
753 .map(|path| {
754 let bytes = observation.files.get(&path);
755 Destination {
756 present: bytes.is_some(),
757 sha256: bytes.map(|bytes| Digest::of(bytes)),
758 recorded_kind: recorded
759 .and_then(|record| record.file(&path))
760 .map(|file| file.kind),
761 path,
762 }
763 })
764 .collect();
765 let installation_refs: Vec<String> = record_ref
766 .iter()
767 .chain(config_ref.iter())
768 .cloned()
769 .chain(observation.refs.destinations.values().cloned())
770 .collect();
771 let forge_view = match &observation.forge {
772 ForgeRead::NotObserved { reason } => ForgeState::NotObserved {
773 reason: reason.clone(),
774 },
775 ForgeRead::Observed { trunk, remote_tip } => ForgeState::Observed {
776 trunk: trunk.clone(),
777 remote_tip: remote_tip.clone(),
778 evidence_refs: observation.refs.forge.clone(),
779 },
780 };
781 let mut plan = Plan {
782 schema: PLAN_SCHEMA,
783 identity: Identity {
784 plan_id: String::new(),
785 created_at: clock.to_owned(),
786 engine_version: engine_version.to_owned(),
787 },
788 classification,
789 findings,
790 desired_state: DesiredState {
791 selector: selector.to_owned(),
792 release: ResolvedRelease {
793 version: candidate_version.clone(),
794 venue: candidate.venue.to_owned(),
795 payload_sha256: candidate.manifest.payload_sha256.clone(),
796 payload_schema: bundle_schema,
797 },
798 configuration,
799 unresolved: resolution.unresolved.clone(),
800 },
801 observed_state: ObservedState {
802 repository: Repository {
803 target: observation.target.clone(),
804 git: observation.git,
805 tags: observation.facts.tags,
806 long_lived_branches: observation.facts.long_lived_branches.clone(),
807 release_markers: observation.facts.release_markers.clone(),
808 collisions: observation.facts.collisions.clone(),
809 tech: observation.tech.clone(),
810 forge: observation.forge_name.clone(),
811 repo: observation.repo.clone(),
812 verdict,
813 evidence_refs: observation.refs.repository.clone(),
814 },
815 installation: Installation {
816 record: record_view,
817 configuration: configuration_view,
818 destinations,
819 evidence_refs: installation_refs,
820 },
821 host: Host {
822 engine_version: engine_version.to_owned(),
823 pin: observation.pin.clone(),
824 evidence_refs: observation
825 .refs
826 .host
827 .iter()
828 .chain(observation.refs.pin.iter())
829 .cloned()
830 .collect(),
831 },
832 forge: forge_view,
833 },
834 release: Release {
835 candidate: BundleIdentity {
836 version: candidate_version,
837 payload_sha256: candidate.manifest.payload_sha256.clone(),
838 payload_schema: bundle_schema,
839 artifacts: candidate.manifest.artifacts.len(),
840 evidence_refs: vec![candidate_ref],
841 },
842 verification: candidate.verification,
843 baseline: baseline_state,
844 compatibility: Compatibility {
845 engine_schema: PAYLOAD_SCHEMA,
846 bundle_schema,
847 readable: bundle_schema <= PAYLOAD_SCHEMA,
848 },
849 guidance: Guidance {
850 coverage: "not-shipped",
851 },
852 },
853 operations,
854 preconditions,
855 decisions,
856 postconditions,
857 evidence: observation.ledger.into_items(),
858 readiness,
859 input_fingerprint: Digest::of(b""),
860 };
861 plan.input_fingerprint = fingerprint::compute(&plan);
862 plan.identity.plan_id = fingerprint::plan_id(&plan.input_fingerprint, clock);
863 Ok(Planned { plan, blobs })
864}
865
866struct Outcome {
868 write: bool,
869 conflict: bool,
870 missing: bool,
871 record: FileRecord,
872}
873
874fn compare_fresh(entry: &Entry, disk: Option<&[u8]>) -> Outcome {
878 let (write, conflict, landed) = match disk {
879 None => (true, false, entry.rendered.clone()),
880 Some(bytes) if bytes == entry.rendered => (false, false, bytes.to_vec()),
881 Some(bytes) if entry.kind != Kind::Rendered => (false, false, bytes.to_vec()),
882 Some(_) => (false, true, entry.rendered.clone()),
883 };
884 Outcome {
885 write,
886 conflict,
887 missing: false,
888 record: FileRecord {
889 destination: entry.destination.clone(),
890 kind: entry.kind,
891 sha256: Digest::of(&landed),
892 baseline_sha256: baseline_digest(entry),
893 },
894 }
895}
896
897fn compare_recorded(entry: &Entry, recorded: Option<&FileRecord>, disk: Option<&[u8]>) -> Outcome {
900 let Some(recorded) = recorded else {
901 return compare_fresh(entry, disk);
902 };
903 let candidate_record = |sha256: Digest| FileRecord {
904 destination: entry.destination.clone(),
905 kind: entry.kind,
906 sha256,
907 baseline_sha256: baseline_digest(entry),
908 };
909 if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
912 let untouched =
913 disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
914 return Outcome {
915 write: untouched,
916 conflict: !untouched,
917 missing: disk.is_none(),
918 record: candidate_record(Digest::of(&entry.rendered)),
919 };
920 }
921 match entry.kind {
922 Kind::Rendered => match disk {
923 Some(bytes) if Digest::of(bytes) == recorded.sha256 => Outcome {
924 write: bytes != entry.rendered,
925 conflict: false,
926 missing: false,
927 record: candidate_record(Digest::of(&entry.rendered)),
928 },
929 Some(bytes) if bytes == entry.rendered => Outcome {
930 write: false,
931 conflict: false,
932 missing: false,
933 record: candidate_record(Digest::of(&entry.rendered)),
934 },
935 other => Outcome {
936 write: false,
937 conflict: true,
938 missing: other.is_none(),
939 record: candidate_record(Digest::of(&entry.rendered)),
940 },
941 },
942 Kind::Seeded => {
943 let baseline = if recorded.kind == Kind::Rendered {
946 Some(recorded.sha256.clone())
947 } else {
948 recorded.baseline_sha256.clone()
949 };
950 let sha256 = disk.map_or_else(|| recorded.sha256.clone(), Digest::of);
951 Outcome {
952 write: false,
953 conflict: false,
954 missing: false,
955 record: FileRecord {
956 destination: entry.destination.clone(),
957 kind: entry.kind,
958 sha256,
959 baseline_sha256: baseline,
960 },
961 }
962 }
963 Kind::State => Outcome {
964 write: false,
965 conflict: false,
966 missing: false,
967 record: FileRecord {
968 destination: entry.destination.clone(),
969 kind: entry.kind,
970 sha256: recorded.sha256.clone(),
971 baseline_sha256: None,
972 },
973 },
974 }
975}
976
977fn baseline_digest(entry: &Entry) -> Option<Digest> {
979 match entry.kind {
980 Kind::State => None,
981 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
982 }
983}
984
985fn baseline_bytes(source: &dyn ReleaseSource, record: &Manifest, entry: &Entry) -> Option<Vec<u8>> {
988 let digest = record.file(&entry.destination)?.baseline_sha256.as_ref()?;
989 source.blob(digest).ok()
990}
991
992fn planned_manifest<'a>(
997 candidate: &Candidate<'_>,
998 params: &landing::Params,
999 recorded: Option<&Manifest>,
1000 clock: &str,
1001 files: impl Iterator<Item = &'a FileRecord>,
1002) -> Manifest {
1003 let pins = crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
1004 .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
1005 .unwrap_or_default()
1006 .into_iter()
1007 .filter(|pin| pin.used_by.iter().any(|user| user == params.tech()))
1008 .map(|pin| (pin.name, pin.version))
1009 .collect();
1010 Manifest {
1011 schema_version: manifest::SCHEMA_VERSION,
1012 rk_version: candidate.manifest.release_kit_version.clone(),
1013 payload_sha256: candidate.manifest.payload_sha256.clone(),
1014 origin: recorded.map_or_else(|| "init".to_owned(), |record| record.origin.clone()),
1015 tech: params.tech().to_owned(),
1016 forge: params.forge().to_owned(),
1017 landed_at: recorded.map_or_else(|| clock.to_owned(), |record| record.landed_at.clone()),
1018 parameters: Parameters {
1019 repo: params.repo().to_owned(),
1020 workflow: params.workflow(),
1021 style: params.style(),
1022 nix: params.nix(),
1023 trunk: params.trunk().to_owned(),
1024 line_prefix: params.line_prefix().to_owned(),
1025 security_contact: params.security_contact().to_owned(),
1026 security_response: params.security_response().to_owned(),
1027 },
1028 files: files
1029 .map(|file| FileRecord {
1030 destination: file.destination.clone(),
1031 kind: file.kind,
1032 sha256: file.sha256.clone(),
1033 baseline_sha256: file.baseline_sha256.clone(),
1034 })
1035 .collect(),
1036 pins,
1037 }
1038}
1039
1040fn trim_v(version: &str) -> &str {
1043 version.strip_prefix('v').unwrap_or(version)
1044}