1use crate::plan::{FilesystemAccessV1, PermissionSetV1, PermissionV1};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12use std::fmt;
13use std::path::{Path, PathBuf};
14
15pub const ACTION_IR_SCHEMA_VERSION: u32 = 1;
16
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct ActionIrV1 {
20 pub schema_version: u32,
21 pub operation_id: String,
22 pub actions: Vec<DeclarativeActionV1>,
23}
24
25impl ActionIrV1 {
26 pub fn new(operation_id: impl Into<String>, actions: Vec<DeclarativeActionV1>) -> Self {
27 Self {
28 schema_version: ACTION_IR_SCHEMA_VERSION,
29 operation_id: operation_id.into(),
30 actions,
31 }
32 }
33
34 pub fn validate(&self) -> Result<(), ActionIrError> {
35 if self.schema_version != ACTION_IR_SCHEMA_VERSION {
36 return Err(ActionIrError::UnsupportedSchema(self.schema_version));
37 }
38 validate_identity("operation", &self.operation_id)?;
39 if self.actions.is_empty() {
40 return Err(ActionIrError::Invalid(
41 "an action IR must contain at least one action".to_string(),
42 ));
43 }
44 let mut ids = BTreeSet::new();
45 for action in &self.actions {
46 action.validate()?;
47 if !ids.insert(&action.action_id) {
48 return Err(ActionIrError::DuplicateAction(action.action_id.clone()));
49 }
50 }
51 Ok(())
52 }
53
54 pub fn permission_requirements(
58 &self,
59 path_identity: impl Fn(&Path) -> String,
60 ) -> ActionPermissionRequirementsV1 {
61 let mut required = PermissionSetV1::default();
62 let mut uncomputable_codes = BTreeSet::new();
63 for action in &self.actions {
64 match &action.kind {
65 ActionKindV1::CreateManagedFile {
66 destination,
67 requires_admin,
68 ..
69 } => {
70 required.insert(PermissionV1::Filesystem {
71 access: FilesystemAccessV1::Write,
72 path: path_identity(destination),
73 });
74 if *requires_admin {
75 required.insert(PermissionV1::Administrator);
76 }
77 }
78 ActionKindV1::CreateManagedFileWithBackup {
79 destination,
80 backup,
81 requires_admin,
82 ..
83 } => {
84 required.insert(PermissionV1::Filesystem {
85 access: FilesystemAccessV1::Write,
86 path: path_identity(destination),
87 });
88 required.insert(PermissionV1::Filesystem {
89 access: FilesystemAccessV1::Remove,
90 path: path_identity(destination),
91 });
92 required.insert(PermissionV1::Filesystem {
93 access: FilesystemAccessV1::Write,
94 path: path_identity(backup),
95 });
96 if *requires_admin {
97 required.insert(PermissionV1::Administrator);
98 }
99 }
100 ActionKindV1::UpdateManagedFile {
101 destination,
102 rollback,
103 requires_admin,
104 ..
105 } => {
106 for (access, path) in [
107 (FilesystemAccessV1::Write, destination.as_path()),
108 (FilesystemAccessV1::Remove, destination.as_path()),
109 (FilesystemAccessV1::Write, rollback.as_path()),
110 (FilesystemAccessV1::Remove, rollback.as_path()),
111 ] {
112 required.insert(PermissionV1::Filesystem {
113 access,
114 path: path_identity(path),
115 });
116 }
117 if *requires_admin {
118 required.insert(PermissionV1::Administrator);
119 }
120 }
121 ActionKindV1::RelocateManagedFile {
122 previous_destination,
123 previous_backup,
124 previous_rollback,
125 desired_destination,
126 previous_present,
127 previous_requires_admin,
128 desired_requires_admin,
129 ..
130 } => {
131 required.insert(PermissionV1::Filesystem {
132 access: FilesystemAccessV1::Write,
133 path: path_identity(desired_destination),
134 });
135 if *previous_present {
136 required.insert(PermissionV1::Filesystem {
137 access: FilesystemAccessV1::Remove,
138 path: path_identity(previous_destination),
139 });
140 required.insert(PermissionV1::Filesystem {
141 access: FilesystemAccessV1::Write,
142 path: path_identity(previous_rollback),
143 });
144 required.insert(PermissionV1::Filesystem {
145 access: FilesystemAccessV1::Remove,
146 path: path_identity(previous_rollback),
147 });
148 }
149 if let Some(backup) = previous_backup {
150 required.insert(PermissionV1::Filesystem {
151 access: FilesystemAccessV1::Write,
152 path: path_identity(previous_destination),
153 });
154 required.insert(PermissionV1::Filesystem {
155 access: FilesystemAccessV1::Remove,
156 path: path_identity(&backup.path),
157 });
158 }
159 if (*previous_present && *previous_requires_admin) || *desired_requires_admin {
160 required.insert(PermissionV1::Administrator);
161 }
162 }
163 ActionKindV1::RemoveManagedFile {
164 destination,
165 rollback,
166 requires_admin,
167 ..
168 } => {
169 for (access, path) in [
170 (FilesystemAccessV1::Remove, destination.as_path()),
171 (FilesystemAccessV1::Write, rollback.as_path()),
172 (FilesystemAccessV1::Remove, rollback.as_path()),
173 ] {
174 required.insert(PermissionV1::Filesystem {
175 access,
176 path: path_identity(path),
177 });
178 }
179 if *requires_admin {
180 required.insert(PermissionV1::Administrator);
181 }
182 }
183 ActionKindV1::RemoveManagedFileWithBackup {
184 destination,
185 backup,
186 rollback,
187 requires_admin,
188 ..
189 } => {
190 for (access, path) in [
191 (FilesystemAccessV1::Write, destination.as_path()),
192 (FilesystemAccessV1::Remove, destination.as_path()),
193 (FilesystemAccessV1::Remove, backup.as_path()),
194 (FilesystemAccessV1::Write, rollback.as_path()),
195 (FilesystemAccessV1::Remove, rollback.as_path()),
196 ] {
197 required.insert(PermissionV1::Filesystem {
198 access,
199 path: path_identity(path),
200 });
201 }
202 if *requires_admin {
203 required.insert(PermissionV1::Administrator);
204 }
205 }
206 ActionKindV1::ForceRemoveManagedFile {
207 destination,
208 persistent_backup,
209 rollback,
210 requires_admin,
211 ..
212 } => {
213 for (access, path) in [
214 (FilesystemAccessV1::Remove, destination.as_path()),
215 (FilesystemAccessV1::Write, rollback.as_path()),
216 (FilesystemAccessV1::Remove, rollback.as_path()),
217 ] {
218 required.insert(PermissionV1::Filesystem {
219 access,
220 path: path_identity(path),
221 });
222 }
223 if let Some(backup) = persistent_backup {
224 required.insert(PermissionV1::Filesystem {
225 access: FilesystemAccessV1::Write,
226 path: path_identity(destination),
227 });
228 required.insert(PermissionV1::Filesystem {
229 access: FilesystemAccessV1::Remove,
230 path: path_identity(&backup.path),
231 });
232 }
233 if *requires_admin {
234 required.insert(PermissionV1::Administrator);
235 }
236 }
237 ActionKindV1::MergeManagedJson {
238 destination,
239 rollback,
240 original_hash,
241 ..
242 } => {
243 for (access, path) in [
244 (FilesystemAccessV1::Write, destination.as_path()),
245 (FilesystemAccessV1::Remove, destination.as_path()),
246 ] {
247 required.insert(PermissionV1::Filesystem {
248 access,
249 path: path_identity(path),
250 });
251 }
252 if original_hash.is_some() {
253 for (access, path) in [
254 (FilesystemAccessV1::Write, rollback.as_path()),
255 (FilesystemAccessV1::Remove, rollback.as_path()),
256 ] {
257 required.insert(PermissionV1::Filesystem {
258 access,
259 path: path_identity(path),
260 });
261 }
262 }
263 }
264 ActionKindV1::RelocateManagedJson {
265 previous_destination,
266 previous_rollback,
267 desired_destination,
268 previous_present,
269 ..
270 } => {
271 required.insert(PermissionV1::Filesystem {
272 access: FilesystemAccessV1::Write,
273 path: path_identity(desired_destination),
274 });
275 if *previous_present {
276 for (access, path) in [
277 (FilesystemAccessV1::Write, previous_destination.as_path()),
278 (FilesystemAccessV1::Remove, previous_destination.as_path()),
279 (FilesystemAccessV1::Write, previous_rollback.as_path()),
280 (FilesystemAccessV1::Remove, previous_rollback.as_path()),
281 ] {
282 required.insert(PermissionV1::Filesystem {
283 access,
284 path: path_identity(path),
285 });
286 }
287 }
288 }
289 ActionKindV1::RemoveManagedJson {
290 destination,
291 rollback,
292 ..
293 } => {
294 for (access, path) in [
295 (FilesystemAccessV1::Write, destination.as_path()),
296 (FilesystemAccessV1::Remove, destination.as_path()),
297 (FilesystemAccessV1::Write, rollback.as_path()),
298 (FilesystemAccessV1::Remove, rollback.as_path()),
299 ] {
300 required.insert(PermissionV1::Filesystem {
301 access,
302 path: path_identity(path),
303 });
304 }
305 }
306 ActionKindV1::CreateShellLauncher { resources, .. } => {
307 for resource in resources {
308 required.insert(PermissionV1::Filesystem {
309 access: FilesystemAccessV1::Write,
310 path: path_identity(resource.destination()),
311 });
312 }
313 }
314 ActionKindV1::UpdateShellLauncher { resources, .. } => {
315 for resource in resources {
316 for (access, path) in [
317 (FilesystemAccessV1::Write, resource.previous.destination()),
318 (FilesystemAccessV1::Remove, resource.previous.destination()),
319 (FilesystemAccessV1::Write, resource.rollback.as_path()),
320 (FilesystemAccessV1::Remove, resource.rollback.as_path()),
321 ] {
322 required.insert(PermissionV1::Filesystem {
323 access,
324 path: path_identity(path),
325 });
326 }
327 }
328 }
329 ActionKindV1::RemoveShellLauncher { resources, .. }
330 | ActionKindV1::RemoveLegacyShellLauncher { resources } => {
331 for resource in resources {
332 for (access, path) in [
333 (FilesystemAccessV1::Remove, resource.previous.destination()),
334 (FilesystemAccessV1::Write, resource.rollback.as_path()),
335 (FilesystemAccessV1::Remove, resource.rollback.as_path()),
336 ] {
337 required.insert(PermissionV1::Filesystem {
338 access,
339 path: path_identity(path),
340 });
341 }
342 }
343 }
344 ActionKindV1::ReplaceShellSnapshot {
345 destination,
346 stage,
347 rollback,
348 ..
349 } => {
350 for (access, path) in [
351 (FilesystemAccessV1::Write, destination.as_path()),
352 (FilesystemAccessV1::Remove, destination.as_path()),
353 (FilesystemAccessV1::Write, stage.as_path()),
354 (FilesystemAccessV1::Remove, stage.as_path()),
355 (FilesystemAccessV1::Write, rollback.as_path()),
356 (FilesystemAccessV1::Remove, rollback.as_path()),
357 ] {
358 required.insert(PermissionV1::Filesystem {
359 access,
360 path: path_identity(path),
361 });
362 }
363 }
364 ActionKindV1::ReplaceShellCache { files, .. } => {
365 for file in files {
366 for (access, path) in [
367 (FilesystemAccessV1::Write, file.destination.as_path()),
368 (FilesystemAccessV1::Remove, file.destination.as_path()),
369 (FilesystemAccessV1::Write, file.rollback.as_path()),
370 (FilesystemAccessV1::Remove, file.rollback.as_path()),
371 ] {
372 required.insert(PermissionV1::Filesystem {
373 access,
374 path: path_identity(path),
375 });
376 }
377 }
378 }
379 ActionKindV1::RemoveShellCache { files, .. } => {
380 for file in files {
381 for (access, path) in [
382 (FilesystemAccessV1::Remove, file.destination.as_path()),
383 (FilesystemAccessV1::Write, file.rollback.as_path()),
384 (FilesystemAccessV1::Remove, file.rollback.as_path()),
385 ] {
386 required.insert(PermissionV1::Filesystem {
387 access,
388 path: path_identity(path),
389 });
390 }
391 }
392 }
393 ActionKindV1::RemoveShellSnapshot {
394 destination,
395 rollback,
396 ..
397 } => {
398 for (access, path) in [
399 (FilesystemAccessV1::Remove, destination.as_path()),
400 (FilesystemAccessV1::Write, rollback.as_path()),
401 (FilesystemAccessV1::Remove, rollback.as_path()),
402 ] {
403 required.insert(PermissionV1::Filesystem {
404 access,
405 path: path_identity(path),
406 });
407 }
408 }
409 ActionKindV1::ReconcileShellProfile { files, .. } => {
410 for file in files {
411 for (access, path) in [
412 (FilesystemAccessV1::Write, file.destination.as_path()),
413 (FilesystemAccessV1::Remove, file.destination.as_path()),
414 (FilesystemAccessV1::Write, file.rollback.as_path()),
415 (FilesystemAccessV1::Remove, file.rollback.as_path()),
416 ] {
417 required.insert(PermissionV1::Filesystem {
418 access,
419 path: path_identity(path),
420 });
421 }
422 }
423 }
424 ActionKindV1::ReconcileSysSplitDns { .. } => {
425 required.insert(PermissionV1::Administrator);
426 }
427 ActionKindV1::ReconcileSysProfileBlocks { files, .. } => {
428 for file in files {
429 for (access, path) in [
430 (FilesystemAccessV1::Write, file.destination.as_path()),
431 (FilesystemAccessV1::Remove, file.destination.as_path()),
432 (FilesystemAccessV1::Write, file.rollback.as_path()),
433 (FilesystemAccessV1::Remove, file.rollback.as_path()),
434 ] {
435 required.insert(PermissionV1::Filesystem {
436 access,
437 path: path_identity(path),
438 });
439 }
440 }
441 }
442 ActionKindV1::ReplaceShellRenderedFile {
443 destination,
444 rollback,
445 ..
446 } => {
447 for (access, path) in [
448 (FilesystemAccessV1::Write, destination.as_path()),
449 (FilesystemAccessV1::Remove, destination.as_path()),
450 (FilesystemAccessV1::Write, rollback.as_path()),
451 (FilesystemAccessV1::Remove, rollback.as_path()),
452 ] {
453 required.insert(PermissionV1::Filesystem {
454 access,
455 path: path_identity(path),
456 });
457 }
458 }
459 ActionKindV1::RemoveShellRenderedFile {
460 destination,
461 rollback,
462 ..
463 } => {
464 for (access, path) in [
465 (FilesystemAccessV1::Remove, destination.as_path()),
466 (FilesystemAccessV1::Write, rollback.as_path()),
467 (FilesystemAccessV1::Remove, rollback.as_path()),
468 ] {
469 required.insert(PermissionV1::Filesystem {
470 access,
471 path: path_identity(path),
472 });
473 }
474 }
475 ActionKindV1::OpaqueExecution { .. } => {
476 uncomputable_codes.insert("opaque_action_permissions_uncomputable".to_string());
477 }
478 }
479 }
480 ActionPermissionRequirementsV1 {
481 required,
482 uncomputable_codes,
483 }
484 }
485}
486
487#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
488#[serde(deny_unknown_fields)]
489pub struct DeclarativeActionV1 {
490 pub action_id: String,
491 pub target: String,
492 pub resource: String,
493 pub kind: ActionKindV1,
494 pub rollback: RollbackSupportV1,
495}
496
497impl DeclarativeActionV1 {
498 pub fn create_managed_file(
499 action_id: impl Into<String>,
500 target: impl Into<String>,
501 resource: impl Into<String>,
502 destination: PathBuf,
503 desired_hash: u64,
504 requires_admin: bool,
505 ) -> Self {
506 Self {
507 action_id: action_id.into(),
508 target: target.into(),
509 resource: resource.into(),
510 kind: ActionKindV1::CreateManagedFile {
511 destination,
512 desired_hash,
513 requires_admin,
514 },
515 rollback: RollbackSupportV1::RemoveCreatedIfUnchanged,
516 }
517 }
518
519 pub fn create_managed_file_with_backup(
520 action_id: impl Into<String>,
521 target: impl Into<String>,
522 resource: impl Into<String>,
523 spec: ManagedFileCreationWithBackupSpecV1,
524 ) -> Self {
525 Self {
526 action_id: action_id.into(),
527 target: target.into(),
528 resource: resource.into(),
529 kind: ActionKindV1::CreateManagedFileWithBackup {
530 destination: spec.destination,
531 backup: spec.backup,
532 original_hash: spec.original_hash,
533 desired_hash: spec.desired_hash,
534 requires_admin: spec.requires_admin,
535 },
536 rollback: RollbackSupportV1::RestoreBackupIfUnchanged,
537 }
538 }
539
540 pub fn update_managed_file(
541 action_id: impl Into<String>,
542 target: impl Into<String>,
543 resource: impl Into<String>,
544 spec: ManagedFileUpdateSpecV1,
545 ) -> Self {
546 let rollback = managed_file_rollback_path(&spec.destination);
547 Self {
548 action_id: action_id.into(),
549 target: target.into(),
550 resource: resource.into(),
551 kind: ActionKindV1::UpdateManagedFile {
552 destination: spec.destination,
553 rollback,
554 previous_backup: spec.previous_backup,
555 original_mode: spec.original_mode,
556 original_hash: spec.original_hash,
557 desired_hash: spec.desired_hash,
558 requires_admin: spec.requires_admin,
559 },
560 rollback: RollbackSupportV1::RestorePreviousIfUnchanged,
561 }
562 }
563
564 pub fn relocate_managed_file(
565 action_id: impl Into<String>,
566 target: impl Into<String>,
567 resource: impl Into<String>,
568 spec: ManagedFileRelocationSpecV1,
569 ) -> Self {
570 let previous_rollback = managed_file_rollback_path(&spec.previous_destination);
571 Self {
572 action_id: action_id.into(),
573 target: target.into(),
574 resource: resource.into(),
575 kind: ActionKindV1::RelocateManagedFile {
576 previous_destination: spec.previous_destination,
577 previous_backup: spec.previous_backup,
578 previous_rollback,
579 desired_destination: spec.desired_destination,
580 previous_present: spec.previous_present,
581 previous_mode: spec.previous_mode,
582 previous_hash: spec.previous_hash,
583 desired_hash: spec.desired_hash,
584 previous_uses_env: spec.previous_uses_env,
585 desired_uses_env: spec.desired_uses_env,
586 previous_requires_admin: spec.previous_requires_admin,
587 desired_requires_admin: spec.desired_requires_admin,
588 },
589 rollback: RollbackSupportV1::RestoreRelocatedPreviousIfUnchanged,
590 }
591 }
592
593 pub fn remove_managed_file(
594 action_id: impl Into<String>,
595 target: impl Into<String>,
596 resource: impl Into<String>,
597 spec: ManagedFileRemoveSpecV1,
598 ) -> Self {
599 let rollback = managed_file_rollback_path(&spec.destination);
600 Self {
601 action_id: action_id.into(),
602 target: target.into(),
603 resource: resource.into(),
604 kind: ActionKindV1::RemoveManagedFile {
605 destination: spec.destination,
606 rollback,
607 original_mode: spec.original_mode,
608 original_hash: spec.original_hash,
609 uses_env: spec.uses_env,
610 requires_admin: spec.requires_admin,
611 },
612 rollback: RollbackSupportV1::RestorePreviousIfUnchanged,
613 }
614 }
615
616 pub fn remove_managed_file_with_backup(
617 action_id: impl Into<String>,
618 target: impl Into<String>,
619 resource: impl Into<String>,
620 spec: ManagedFileRemoveWithBackupSpecV1,
621 ) -> Self {
622 let rollback = managed_file_rollback_path(&spec.destination);
623 Self {
624 action_id: action_id.into(),
625 target: target.into(),
626 resource: resource.into(),
627 kind: ActionKindV1::RemoveManagedFileWithBackup {
628 destination: spec.destination,
629 backup: spec.backup,
630 rollback,
631 managed_mode: spec.managed_mode,
632 managed_hash: spec.managed_hash,
633 backup_mode: spec.backup_mode,
634 backup_hash: spec.backup_hash,
635 uses_env: spec.uses_env,
636 requires_admin: spec.requires_admin,
637 },
638 rollback: RollbackSupportV1::RestorePreviousWithBackupIfUnchanged,
639 }
640 }
641
642 pub fn force_remove_managed_file(
643 action_id: impl Into<String>,
644 target: impl Into<String>,
645 resource: impl Into<String>,
646 spec: ForcedManagedFileRemoveSpecV1,
647 ) -> Self {
648 let rollback = managed_file_rollback_path(&spec.destination);
649 Self {
650 action_id: action_id.into(),
651 target: target.into(),
652 resource: resource.into(),
653 kind: ActionKindV1::ForceRemoveManagedFile {
654 destination: spec.destination,
655 persistent_backup: spec.persistent_backup,
656 rollback,
657 receipt_hash: spec.receipt_hash,
658 current_mode: spec.current_mode,
659 current_hash: spec.current_hash,
660 uses_env: spec.uses_env,
661 requires_admin: spec.requires_admin,
662 },
663 rollback: RollbackSupportV1::RestoreForcedPreviousIfUnchanged,
664 }
665 }
666
667 pub fn merge_managed_json(
668 action_id: impl Into<String>,
669 target: impl Into<String>,
670 resource: impl Into<String>,
671 spec: ManagedJsonMergeSpecV1,
672 ) -> Self {
673 let rollback = managed_file_rollback_path(&spec.destination);
674 Self {
675 action_id: action_id.into(),
676 target: target.into(),
677 resource: resource.into(),
678 kind: ActionKindV1::MergeManagedJson {
679 destination: spec.destination,
680 rollback,
681 original_mode: spec.original_mode,
682 original_hash: spec.original_hash,
683 previous_receipt_hash: spec.previous_receipt_hash,
684 desired_managed_hash: spec.desired_managed_hash,
685 managed_keys: spec.managed_keys,
686 },
687 rollback: RollbackSupportV1::RestoreJsonKeysIfUnchanged,
688 }
689 }
690
691 pub fn remove_managed_json(
692 action_id: impl Into<String>,
693 target: impl Into<String>,
694 resource: impl Into<String>,
695 spec: ManagedJsonRemoveSpecV1,
696 ) -> Self {
697 let rollback = managed_file_rollback_path(&spec.destination);
698 Self {
699 action_id: action_id.into(),
700 target: target.into(),
701 resource: resource.into(),
702 kind: ActionKindV1::RemoveManagedJson {
703 destination: spec.destination,
704 rollback,
705 original_mode: spec.original_mode,
706 original_hash: spec.original_hash,
707 receipt_managed_hash: spec.receipt_managed_hash,
708 current_managed_hash: spec.current_managed_hash,
709 managed_keys: spec.managed_keys,
710 uses_env: spec.uses_env,
711 },
712 rollback: RollbackSupportV1::RestoreRemovedJsonKeysIfUnchanged,
713 }
714 }
715
716 pub fn relocate_managed_json(
717 action_id: impl Into<String>,
718 target: impl Into<String>,
719 resource: impl Into<String>,
720 spec: ManagedJsonRelocationSpecV1,
721 ) -> Self {
722 let previous_rollback = managed_file_rollback_path(&spec.previous_destination);
723 Self {
724 action_id: action_id.into(),
725 target: target.into(),
726 resource: resource.into(),
727 kind: ActionKindV1::RelocateManagedJson {
728 previous_destination: spec.previous_destination,
729 previous_rollback,
730 desired_destination: spec.desired_destination,
731 previous_present: spec.previous_present,
732 previous_mode: spec.previous_mode,
733 previous_original_hash: spec.previous_original_hash,
734 previous_receipt_hash: spec.previous_receipt_hash,
735 previous_managed_keys: spec.previous_managed_keys,
736 desired_managed_hash: spec.desired_managed_hash,
737 desired_managed_keys: spec.desired_managed_keys,
738 previous_uses_env: spec.previous_uses_env,
739 desired_uses_env: spec.desired_uses_env,
740 },
741 rollback: RollbackSupportV1::RestoreRelocatedJsonKeysIfUnchanged,
742 }
743 }
744
745 pub fn create_shell_launcher(
746 action_id: impl Into<String>,
747 target: impl Into<String>,
748 resource: impl Into<String>,
749 receipt: ShellLauncherReceiptV1,
750 resources: Vec<ShellLauncherResourceV1>,
751 ) -> Self {
752 Self {
753 action_id: action_id.into(),
754 target: target.into(),
755 resource: resource.into(),
756 kind: ActionKindV1::CreateShellLauncher { receipt, resources },
757 rollback: RollbackSupportV1::RemoveCreatedLauncherIfUnchanged,
758 }
759 }
760
761 pub fn update_shell_launcher(
762 action_id: impl Into<String>,
763 target: impl Into<String>,
764 resource: impl Into<String>,
765 previous_receipt: ShellLauncherReceiptV1,
766 desired_receipt: ShellLauncherReceiptV1,
767 resources: Vec<ShellLauncherUpdateResourceV1>,
768 ) -> Self {
769 Self {
770 action_id: action_id.into(),
771 target: target.into(),
772 resource: resource.into(),
773 kind: ActionKindV1::UpdateShellLauncher {
774 previous_receipt: Box::new(previous_receipt),
775 desired_receipt: Box::new(desired_receipt),
776 resources,
777 },
778 rollback: RollbackSupportV1::RestorePreviousLauncherIfUnchanged,
779 }
780 }
781
782 pub fn remove_shell_launcher(
783 action_id: impl Into<String>,
784 target: impl Into<String>,
785 resource: impl Into<String>,
786 previous_receipt: ShellLauncherReceiptV1,
787 resources: Vec<ShellLauncherRemovalResourceV1>,
788 ) -> Self {
789 Self {
790 action_id: action_id.into(),
791 target: target.into(),
792 resource: resource.into(),
793 kind: ActionKindV1::RemoveShellLauncher {
794 previous_receipt: Box::new(previous_receipt),
795 resources,
796 },
797 rollback: RollbackSupportV1::RestoreRemovedLauncherIfUnchanged,
798 }
799 }
800
801 pub fn remove_legacy_shell_launcher(
802 action_id: impl Into<String>,
803 target: impl Into<String>,
804 resource: impl Into<String>,
805 resources: Vec<ShellLauncherRemovalResourceV1>,
806 ) -> Self {
807 Self {
808 action_id: action_id.into(),
809 target: target.into(),
810 resource: resource.into(),
811 kind: ActionKindV1::RemoveLegacyShellLauncher { resources },
812 rollback: RollbackSupportV1::RestoreRemovedLauncherIfUnchanged,
813 }
814 }
815
816 pub fn replace_shell_snapshot(
817 action_id: impl Into<String>,
818 target: impl Into<String>,
819 resource: impl Into<String>,
820 spec: ShellSnapshotReplacementSpecV1,
821 ) -> Self {
822 let stage = shell_snapshot_stage_path(&spec.destination);
823 let rollback = shell_snapshot_rollback_path(&spec.destination);
824 Self {
825 action_id: action_id.into(),
826 target: target.into(),
827 resource: resource.into(),
828 kind: ActionKindV1::ReplaceShellSnapshot {
829 destination: spec.destination,
830 stage,
831 rollback,
832 previous_present: spec.previous_present,
833 previous_files: spec.previous_files,
834 desired_files: spec.desired_files,
835 receipts: spec.receipts,
836 },
837 rollback: RollbackSupportV1::RestorePreviousShellSnapshotIfUnchanged,
838 }
839 }
840
841 pub fn replace_shell_cache(
842 action_id: impl Into<String>,
843 target: impl Into<String>,
844 resource: impl Into<String>,
845 spec: ShellCacheReplacementSpecV1,
846 ) -> Self {
847 Self {
848 action_id: action_id.into(),
849 target: target.into(),
850 resource: resource.into(),
851 kind: ActionKindV1::ReplaceShellCache {
852 files: spec.files,
853 receipts: spec.receipts,
854 },
855 rollback: RollbackSupportV1::RestorePreviousShellCacheIfUnchanged,
856 }
857 }
858
859 pub fn remove_shell_cache(
860 action_id: impl Into<String>,
861 target: impl Into<String>,
862 resource: impl Into<String>,
863 spec: ShellCacheRemovalSpecV1,
864 ) -> Self {
865 Self {
866 action_id: action_id.into(),
867 target: target.into(),
868 resource: resource.into(),
869 kind: ActionKindV1::RemoveShellCache {
870 files: spec.files,
871 receipts: spec.receipts,
872 },
873 rollback: RollbackSupportV1::RestoreRemovedShellCacheIfUnchanged,
874 }
875 }
876
877 pub fn remove_shell_snapshot(
878 action_id: impl Into<String>,
879 target: impl Into<String>,
880 resource: impl Into<String>,
881 spec: ShellSnapshotRemovalSpecV1,
882 ) -> Self {
883 let rollback = shell_snapshot_rollback_path(&spec.destination);
884 Self {
885 action_id: action_id.into(),
886 target: target.into(),
887 resource: resource.into(),
888 kind: ActionKindV1::RemoveShellSnapshot {
889 destination: spec.destination,
890 rollback,
891 previous_files: spec.previous_files,
892 receipts: spec.receipts,
893 },
894 rollback: RollbackSupportV1::RestoreRemovedShellSnapshotIfUnchanged,
895 }
896 }
897
898 pub fn reconcile_shell_profile(
899 action_id: impl Into<String>,
900 target: impl Into<String>,
901 resource: impl Into<String>,
902 spec: ShellProfileReconciliationSpecV1,
903 ) -> Self {
904 Self {
905 action_id: action_id.into(),
906 target: target.into(),
907 resource: resource.into(),
908 kind: ActionKindV1::ReconcileShellProfile {
909 files: spec.files,
910 receipt_transitions: spec.receipt_transitions,
911 receipt_removals: spec.receipt_removals,
912 legacy_targets: spec.legacy_targets,
913 },
914 rollback: RollbackSupportV1::RestorePreviousShellProfileIfUnchanged,
915 }
916 }
917
918 pub fn reconcile_sys_split_dns(
919 action_id: impl Into<String>,
920 target: impl Into<String>,
921 resource: impl Into<String>,
922 previous: Option<SysSplitDnsStateV1>,
923 desired: Option<SysSplitDnsStateV1>,
924 ) -> Self {
925 Self {
926 action_id: action_id.into(),
927 target: target.into(),
928 resource: resource.into(),
929 kind: ActionKindV1::ReconcileSysSplitDns { previous, desired },
930 rollback: RollbackSupportV1::RestorePreviousSysSplitDnsIfUnchanged,
931 }
932 }
933
934 pub fn reconcile_sys_profile_blocks(
935 action_id: impl Into<String>,
936 target: impl Into<String>,
937 resource: impl Into<String>,
938 os_id: impl Into<String>,
939 files: Vec<SysProfileBlockFileV1>,
940 ) -> Self {
941 Self {
942 action_id: action_id.into(),
943 target: target.into(),
944 resource: resource.into(),
945 kind: ActionKindV1::ReconcileSysProfileBlocks {
946 os_id: os_id.into(),
947 files,
948 },
949 rollback: RollbackSupportV1::RestorePreviousSysProfileBlocksIfUnchanged,
950 }
951 }
952
953 pub fn replace_shell_rendered_file(
954 action_id: impl Into<String>,
955 target: impl Into<String>,
956 resource: impl Into<String>,
957 spec: ShellRenderedFileReplacementSpecV1,
958 ) -> Self {
959 let rollback = managed_file_rollback_path(&spec.destination);
960 Self {
961 action_id: action_id.into(),
962 target: target.into(),
963 resource: resource.into(),
964 kind: ActionKindV1::ReplaceShellRenderedFile {
965 destination: spec.destination,
966 rollback,
967 previous: spec.previous,
968 desired: spec.desired,
969 receipts: spec.receipts,
970 },
971 rollback: RollbackSupportV1::RestorePreviousShellRenderedFileIfUnchanged,
972 }
973 }
974
975 pub fn remove_shell_rendered_file(
976 action_id: impl Into<String>,
977 target: impl Into<String>,
978 resource: impl Into<String>,
979 spec: ShellRenderedFileRemovalSpecV1,
980 ) -> Self {
981 let rollback = managed_file_rollback_path(&spec.destination);
982 Self {
983 action_id: action_id.into(),
984 target: target.into(),
985 resource: resource.into(),
986 kind: ActionKindV1::RemoveShellRenderedFile {
987 destination: spec.destination,
988 rollback,
989 previous: spec.previous,
990 receipts: spec.receipts,
991 },
992 rollback: RollbackSupportV1::RestoreRemovedShellRenderedFileIfUnchanged,
993 }
994 }
995
996 fn validate(&self) -> Result<(), ActionIrError> {
997 validate_identity("action", &self.action_id)?;
998 validate_identity("target", &self.target)?;
999 validate_identity("resource", &self.resource)?;
1000 match (&self.kind, &self.rollback) {
1001 (
1002 ActionKindV1::CreateManagedFile { destination, .. },
1003 RollbackSupportV1::RemoveCreatedIfUnchanged,
1004 ) if !destination.as_os_str().is_empty() => Ok(()),
1005 (ActionKindV1::CreateManagedFile { destination, .. }, _)
1006 if destination.as_os_str().is_empty() =>
1007 {
1008 Err(ActionIrError::Invalid(
1009 "managed-file destination must not be empty".to_string(),
1010 ))
1011 }
1012 (ActionKindV1::CreateManagedFile { .. }, _) => Err(ActionIrError::Invalid(
1013 "managed-file creation must use remove-created-if-unchanged rollback".to_string(),
1014 )),
1015 (
1016 ActionKindV1::CreateManagedFileWithBackup {
1017 destination,
1018 backup,
1019 ..
1020 },
1021 RollbackSupportV1::RestoreBackupIfUnchanged,
1022 ) if !destination.as_os_str().is_empty()
1023 && !backup.as_os_str().is_empty()
1024 && destination != backup =>
1025 {
1026 Ok(())
1027 }
1028 (ActionKindV1::CreateManagedFileWithBackup { .. }, _) => Err(
1029 ActionIrError::Invalid(
1030 "backup-aware managed-file creation requires distinct non-empty paths and restore-backup-if-unchanged rollback"
1031 .to_string(),
1032 ),
1033 ),
1034 (
1035 ActionKindV1::UpdateManagedFile {
1036 destination,
1037 rollback,
1038 previous_backup,
1039 ..
1040 },
1041 RollbackSupportV1::RestorePreviousIfUnchanged,
1042 ) if !destination.as_os_str().is_empty()
1043 && *rollback == managed_file_rollback_path(destination)
1044 && previous_backup.as_ref() != Some(rollback) =>
1045 {
1046 Ok(())
1047 }
1048 (ActionKindV1::UpdateManagedFile { .. }, _) => Err(ActionIrError::Invalid(
1049 "managed-file update requires its canonical rollback path and restore-previous-if-unchanged rollback"
1050 .to_string(),
1051 )),
1052 (
1053 ActionKindV1::RelocateManagedFile {
1054 previous_destination,
1055 previous_backup,
1056 previous_rollback,
1057 desired_destination,
1058 previous_present,
1059 ..
1060 },
1061 RollbackSupportV1::RestoreRelocatedPreviousIfUnchanged,
1062 ) if !previous_destination.as_os_str().is_empty()
1063 && !desired_destination.as_os_str().is_empty()
1064 && previous_destination != desired_destination
1065 && *previous_rollback == managed_file_rollback_path(previous_destination)
1066 && previous_rollback != desired_destination
1067 && previous_backup.as_ref().is_none_or(|backup| {
1068 *previous_present
1069 && backup.path == crate::install::backup_path(previous_destination)
1070 && backup.path != *previous_rollback
1071 && backup.path != *desired_destination
1072 }) =>
1073 {
1074 Ok(())
1075 }
1076 (ActionKindV1::RelocateManagedFile { .. }, _) => Err(ActionIrError::Invalid(
1077 "managed-file relocation requires distinct old/new destinations, canonical rollback and optional backup paths, and restore-relocated-previous-if-unchanged rollback"
1078 .to_string(),
1079 )),
1080 (
1081 ActionKindV1::RemoveManagedFile {
1082 destination,
1083 rollback,
1084 ..
1085 },
1086 RollbackSupportV1::RestorePreviousIfUnchanged,
1087 ) if !destination.as_os_str().is_empty()
1088 && *rollback == managed_file_rollback_path(destination) =>
1089 {
1090 Ok(())
1091 }
1092 (ActionKindV1::RemoveManagedFile { .. }, _) => Err(ActionIrError::Invalid(
1093 "managed-file removal requires its canonical rollback path and restore-previous-if-unchanged rollback"
1094 .to_string(),
1095 )),
1096 (
1097 ActionKindV1::RemoveManagedFileWithBackup {
1098 destination,
1099 backup,
1100 rollback,
1101 ..
1102 },
1103 RollbackSupportV1::RestorePreviousWithBackupIfUnchanged,
1104 ) if !destination.as_os_str().is_empty()
1105 && *backup == crate::install::backup_path(destination)
1106 && *rollback == managed_file_rollback_path(destination)
1107 && backup != rollback =>
1108 {
1109 Ok(())
1110 }
1111 (ActionKindV1::RemoveManagedFileWithBackup { .. }, _) => Err(
1112 ActionIrError::Invalid(
1113 "backup-restoring managed-file removal requires canonical distinct backup and rollback paths and restore-previous-with-backup-if-unchanged rollback"
1114 .to_string(),
1115 ),
1116 ),
1117 (
1118 ActionKindV1::ForceRemoveManagedFile {
1119 destination,
1120 persistent_backup,
1121 rollback,
1122 receipt_hash,
1123 current_hash,
1124 ..
1125 },
1126 RollbackSupportV1::RestoreForcedPreviousIfUnchanged,
1127 ) if !destination.as_os_str().is_empty()
1128 && *rollback == managed_file_rollback_path(destination)
1129 && receipt_hash != current_hash
1130 && persistent_backup.as_ref().is_none_or(|backup| {
1131 backup.path == crate::install::backup_path(destination)
1132 && backup.path != *rollback
1133 }) =>
1134 {
1135 Ok(())
1136 }
1137 (ActionKindV1::ForceRemoveManagedFile { .. }, _) => Err(ActionIrError::Invalid(
1138 "forced managed-file removal requires changed current content, its canonical rollback path, an optional canonical persistent backup, and restore-forced-previous-if-unchanged rollback"
1139 .to_string(),
1140 )),
1141 (
1142 ActionKindV1::MergeManagedJson {
1143 destination,
1144 rollback,
1145 original_mode,
1146 original_hash,
1147 previous_receipt_hash,
1148 managed_keys,
1149 ..
1150 },
1151 RollbackSupportV1::RestoreJsonKeysIfUnchanged,
1152 ) if !destination.as_os_str().is_empty()
1153 && *rollback == managed_file_rollback_path(destination)
1154 && (original_hash.is_some() || original_mode.is_none())
1155 && (previous_receipt_hash.is_none() || original_hash.is_some())
1156 && valid_managed_json_keys(managed_keys) =>
1157 {
1158 Ok(())
1159 }
1160 (ActionKindV1::MergeManagedJson { .. }, _) => Err(ActionIrError::Invalid(
1161 "managed JSON merge requires canonical rollback, paired original identity, non-empty unique top-level keys, and restore-json-keys-if-unchanged rollback"
1162 .to_string(),
1163 )),
1164 (
1165 ActionKindV1::RelocateManagedJson {
1166 previous_destination,
1167 previous_rollback,
1168 desired_destination,
1169 previous_present,
1170 previous_mode,
1171 previous_original_hash,
1172 previous_managed_keys,
1173 desired_managed_keys,
1174 ..
1175 },
1176 RollbackSupportV1::RestoreRelocatedJsonKeysIfUnchanged,
1177 ) if !previous_destination.as_os_str().is_empty()
1178 && !desired_destination.as_os_str().is_empty()
1179 && previous_destination != desired_destination
1180 && *previous_rollback == managed_file_rollback_path(previous_destination)
1181 && previous_rollback != desired_destination
1182 && *previous_present == previous_original_hash.is_some()
1183 && (*previous_present || previous_mode.is_none())
1184 && valid_managed_json_keys(previous_managed_keys)
1185 && valid_managed_json_keys(desired_managed_keys) =>
1186 {
1187 Ok(())
1188 }
1189 (ActionKindV1::RelocateManagedJson { .. }, _) => Err(ActionIrError::Invalid(
1190 "managed JSON relocation requires distinct old/new destinations, canonical rollback, paired previous whole-file identity, non-empty unique key sets, and restore-relocated-json-keys-if-unchanged rollback"
1191 .to_string(),
1192 )),
1193 (
1194 ActionKindV1::RemoveManagedJson {
1195 destination,
1196 rollback,
1197 managed_keys,
1198 ..
1199 },
1200 RollbackSupportV1::RestoreRemovedJsonKeysIfUnchanged,
1201 ) if !destination.as_os_str().is_empty()
1202 && *rollback == managed_file_rollback_path(destination)
1203 && valid_managed_json_keys(managed_keys) =>
1204 {
1205 Ok(())
1206 }
1207 (ActionKindV1::RemoveManagedJson { .. }, _) => Err(ActionIrError::Invalid(
1208 "managed JSON removal requires its canonical rollback path, non-empty unique top-level keys, and restore-removed-json-keys-if-unchanged rollback"
1209 .to_string(),
1210 )),
1211 (
1212 ActionKindV1::CreateShellLauncher { receipt, resources },
1213 RollbackSupportV1::RemoveCreatedLauncherIfUnchanged,
1214 ) if receipt.is_valid() && valid_shell_launcher_resources(resources) => Ok(()),
1215 (ActionKindV1::CreateShellLauncher { .. }, _) => Err(ActionIrError::Invalid(
1216 "Shell launcher creation requires a valid receipt, non-empty unique resources, and remove-created-launcher-if-unchanged rollback"
1217 .to_string(),
1218 )),
1219 (
1220 ActionKindV1::UpdateShellLauncher {
1221 previous_receipt,
1222 desired_receipt,
1223 resources,
1224 },
1225 RollbackSupportV1::RestorePreviousLauncherIfUnchanged,
1226 ) if previous_receipt.is_valid()
1227 && desired_receipt.is_valid()
1228 && previous_receipt.category == desired_receipt.category
1229 && previous_receipt.command == desired_receipt.command
1230 && previous_receipt != desired_receipt
1231 && valid_shell_launcher_update_resources(resources) =>
1232 {
1233 Ok(())
1234 }
1235 (ActionKindV1::UpdateShellLauncher { .. }, _) => Err(ActionIrError::Invalid(
1236 "Shell launcher update requires distinct valid receipts for one command, exact previous/desired resource pairs, canonical rollback paths, and restore-previous-launcher-if-unchanged rollback"
1237 .to_string(),
1238 )),
1239 (
1240 ActionKindV1::RemoveShellLauncher {
1241 previous_receipt,
1242 resources,
1243 },
1244 RollbackSupportV1::RestoreRemovedLauncherIfUnchanged,
1245 ) if previous_receipt.is_valid()
1246 && valid_shell_launcher_removal_resources(resources) =>
1247 {
1248 Ok(())
1249 }
1250 (ActionKindV1::RemoveShellLauncher { .. }, _) => Err(ActionIrError::Invalid(
1251 "Shell launcher removal requires a valid previous receipt, exact previous resources, canonical rollback paths, and restore-removed-launcher-if-unchanged rollback"
1252 .to_string(),
1253 )),
1254 (
1255 ActionKindV1::RemoveLegacyShellLauncher { resources },
1256 RollbackSupportV1::RestoreRemovedLauncherIfUnchanged,
1257 ) if valid_shell_launcher_removal_resources(resources) => Ok(()),
1258 (ActionKindV1::RemoveLegacyShellLauncher { .. }, _) => {
1259 Err(ActionIrError::Invalid(
1260 "Legacy Shell launcher removal requires exact previous resources, canonical rollback paths, and restore-removed-launcher-if-unchanged rollback"
1261 .to_string(),
1262 ))
1263 }
1264 (
1265 ActionKindV1::ReplaceShellSnapshot {
1266 destination,
1267 stage,
1268 rollback,
1269 previous_present,
1270 previous_files,
1271 desired_files,
1272 receipts,
1273 },
1274 RollbackSupportV1::RestorePreviousShellSnapshotIfUnchanged,
1275 ) if !destination.as_os_str().is_empty()
1276 && *stage == shell_snapshot_stage_path(destination)
1277 && *rollback == shell_snapshot_rollback_path(destination)
1278 && stage != rollback
1279 && valid_shell_tree_files(previous_files, true)
1280 && valid_shell_tree_files(desired_files, false)
1281 && (*previous_present || previous_files.is_empty())
1282 && valid_shell_receipt_transitions(receipts) =>
1283 {
1284 Ok(())
1285 }
1286 (ActionKindV1::ReplaceShellSnapshot { .. }, _) => Err(ActionIrError::Invalid(
1287 "Shell snapshot replacement requires canonical stage/rollback paths, valid tree identities and receipt transitions, and restore-previous-shell-snapshot-if-unchanged rollback"
1288 .to_string(),
1289 )),
1290 (
1291 ActionKindV1::ReplaceShellCache { files, receipts },
1292 RollbackSupportV1::RestorePreviousShellCacheIfUnchanged,
1293 ) if valid_shell_cache_files(files) && valid_shell_receipt_transitions(receipts) => {
1294 Ok(())
1295 }
1296 (ActionKindV1::ReplaceShellCache { .. }, _) => Err(ActionIrError::Invalid(
1297 "Shell cache replacement requires distinct previous/desired file identities, canonical rollback paths, valid receipt transitions, and restore-previous-shell-cache-if-unchanged rollback"
1298 .to_string(),
1299 )),
1300 (
1301 ActionKindV1::RemoveShellCache { files, receipts },
1302 RollbackSupportV1::RestoreRemovedShellCacheIfUnchanged,
1303 ) if valid_shell_cache_removal_files(files)
1304 && valid_shell_receipt_removal_set(receipts) =>
1305 {
1306 Ok(())
1307 }
1308 (ActionKindV1::RemoveShellCache { .. }, _) => Err(ActionIrError::Invalid(
1309 "Shell cache removal requires exact file identities, canonical rollback paths, valid previous receipts, and restore-removed-shell-cache-if-unchanged rollback"
1310 .to_string(),
1311 )),
1312 (
1313 ActionKindV1::RemoveShellSnapshot {
1314 destination,
1315 rollback,
1316 previous_files,
1317 receipts,
1318 },
1319 RollbackSupportV1::RestoreRemovedShellSnapshotIfUnchanged,
1320 ) if !destination.as_os_str().is_empty()
1321 && *rollback == shell_snapshot_rollback_path(destination)
1322 && valid_shell_tree_files(previous_files, true)
1323 && valid_shell_receipt_removal_set(receipts) =>
1324 {
1325 Ok(())
1326 }
1327 (ActionKindV1::RemoveShellSnapshot { .. }, _) => Err(ActionIrError::Invalid(
1328 "Shell snapshot removal requires an exact tree identity, canonical rollback path, valid previous receipts, and restore-removed-shell-snapshot-if-unchanged rollback"
1329 .to_string(),
1330 )),
1331 (
1332 ActionKindV1::ReconcileShellProfile {
1333 files,
1334 receipt_transitions,
1335 receipt_removals,
1336 legacy_targets,
1337 },
1338 RollbackSupportV1::RestorePreviousShellProfileIfUnchanged,
1339 ) if valid_shell_profile_files(files)
1340 && valid_shell_profile_receipts(
1341 receipt_transitions,
1342 receipt_removals,
1343 legacy_targets,
1344 ) =>
1345 {
1346 Ok(())
1347 }
1348 (ActionKindV1::ReconcileShellProfile { .. }, _) => Err(ActionIrError::Invalid(
1349 "Shell profile reconciliation requires valid whole-file or sentinel identities, canonical rollback paths, one receipt-boundary kind, and restore-previous-shell-profile-if-unchanged rollback"
1350 .to_string(),
1351 )),
1352 (
1353 ActionKindV1::ReconcileSysSplitDns { previous, desired },
1354 RollbackSupportV1::RestorePreviousSysSplitDnsIfUnchanged,
1355 ) if valid_sys_split_dns_transition(previous.as_ref(), desired.as_ref()) => Ok(()),
1356 (ActionKindV1::ReconcileSysSplitDns { .. }, _) => Err(ActionIrError::Invalid(
1357 "Sys split-DNS reconciliation requires one distinct previous/desired owned state and restore-previous-sys-split-dns-if-unchanged rollback"
1358 .to_string(),
1359 )),
1360 (
1361 ActionKindV1::ReconcileSysProfileBlocks { os_id, files },
1362 RollbackSupportV1::RestorePreviousSysProfileBlocksIfUnchanged,
1363 ) if !os_id.trim().is_empty() && valid_sys_profile_block_files(files) => Ok(()),
1364 (ActionKindV1::ReconcileSysProfileBlocks { .. }, _) => Err(ActionIrError::Invalid(
1365 "Sys profile block reconciliation requires distinct owned-block identities, canonical rollback paths, and restore-previous-sys-profile-blocks-if-unchanged rollback"
1366 .to_string(),
1367 )),
1368 (
1369 ActionKindV1::ReplaceShellRenderedFile {
1370 destination,
1371 rollback,
1372 previous,
1373 desired,
1374 receipts,
1375 },
1376 RollbackSupportV1::RestorePreviousShellRenderedFileIfUnchanged,
1377 ) if !destination.as_os_str().is_empty()
1378 && *rollback == managed_file_rollback_path(destination)
1379 && previous.as_ref() != Some(desired)
1380 && valid_shell_receipt_transitions(receipts) =>
1381 {
1382 Ok(())
1383 }
1384 (ActionKindV1::ReplaceShellRenderedFile { .. }, _) => Err(ActionIrError::Invalid(
1385 "Shell rendered-file replacement requires a distinct previous/desired identity, canonical rollback path, valid receipt transitions, and restore-previous-shell-rendered-file-if-unchanged rollback"
1386 .to_string(),
1387 )),
1388 (
1389 ActionKindV1::RemoveShellRenderedFile {
1390 destination,
1391 rollback,
1392 receipts,
1393 ..
1394 },
1395 RollbackSupportV1::RestoreRemovedShellRenderedFileIfUnchanged,
1396 ) if !destination.as_os_str().is_empty()
1397 && *rollback == managed_file_rollback_path(destination)
1398 && valid_shell_receipt_removals(receipts, destination) =>
1399 {
1400 Ok(())
1401 }
1402 (ActionKindV1::RemoveShellRenderedFile { .. }, _) => Err(ActionIrError::Invalid(
1403 "Shell rendered-file removal requires a previous file identity, canonical rollback path, valid previous receipts, and restore-removed-shell-rendered-file-if-unchanged rollback"
1404 .to_string(),
1405 )),
1406 (
1407 ActionKindV1::OpaqueExecution { capability, .. },
1408 RollbackSupportV1::Unsupported { reason_code },
1409 ) => {
1410 validate_identity("opaque capability", capability)?;
1411 validate_identity("rollback reason", reason_code)
1412 }
1413 (ActionKindV1::OpaqueExecution { .. }, _) => Err(ActionIrError::Invalid(
1414 "opaque execution must declare rollback as unsupported".to_string(),
1415 )),
1416 }
1417 }
1418}
1419
1420#[derive(Clone, Debug, Eq, PartialEq)]
1421pub struct ManagedFileUpdateSpecV1 {
1422 pub destination: PathBuf,
1423 pub previous_backup: Option<PathBuf>,
1424 pub original_mode: Option<u32>,
1425 pub original_hash: u64,
1426 pub desired_hash: u64,
1427 pub requires_admin: bool,
1428}
1429
1430#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1431#[serde(deny_unknown_fields)]
1432pub struct ManagedFileRelocationBackupV1 {
1433 pub path: PathBuf,
1434 #[serde(default, skip_serializing_if = "Option::is_none")]
1435 pub mode: Option<u32>,
1436 pub hash: u64,
1437}
1438
1439#[derive(Clone, Debug, Eq, PartialEq)]
1440pub struct ManagedFileRelocationSpecV1 {
1441 pub previous_destination: PathBuf,
1442 pub previous_backup: Option<ManagedFileRelocationBackupV1>,
1443 pub desired_destination: PathBuf,
1444 pub previous_present: bool,
1445 pub previous_mode: Option<u32>,
1446 pub previous_hash: u64,
1447 pub desired_hash: u64,
1448 pub previous_uses_env: bool,
1449 pub desired_uses_env: bool,
1450 pub previous_requires_admin: bool,
1451 pub desired_requires_admin: bool,
1452}
1453
1454#[derive(Clone, Debug, Eq, PartialEq)]
1455pub struct ManagedFileCreationWithBackupSpecV1 {
1456 pub destination: PathBuf,
1457 pub backup: PathBuf,
1458 pub original_hash: u64,
1459 pub desired_hash: u64,
1460 pub requires_admin: bool,
1461}
1462
1463#[derive(Clone, Debug, Eq, PartialEq)]
1464pub struct ManagedFileRemoveSpecV1 {
1465 pub destination: PathBuf,
1466 pub original_mode: Option<u32>,
1467 pub original_hash: u64,
1468 pub uses_env: bool,
1469 pub requires_admin: bool,
1470}
1471
1472#[derive(Clone, Debug, Eq, PartialEq)]
1473pub struct ManagedFileRemoveWithBackupSpecV1 {
1474 pub destination: PathBuf,
1475 pub backup: PathBuf,
1476 pub managed_mode: Option<u32>,
1477 pub managed_hash: u64,
1478 pub backup_mode: Option<u32>,
1479 pub backup_hash: u64,
1480 pub uses_env: bool,
1481 pub requires_admin: bool,
1482}
1483
1484#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1485#[serde(deny_unknown_fields)]
1486pub struct ForcedManagedFileBackupV1 {
1487 pub path: PathBuf,
1488 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub mode: Option<u32>,
1490 pub hash: u64,
1491}
1492
1493#[derive(Clone, Debug, Eq, PartialEq)]
1494pub struct ForcedManagedFileRemoveSpecV1 {
1495 pub destination: PathBuf,
1496 pub persistent_backup: Option<ForcedManagedFileBackupV1>,
1497 pub receipt_hash: u64,
1498 pub current_mode: Option<u32>,
1499 pub current_hash: u64,
1500 pub uses_env: bool,
1501 pub requires_admin: bool,
1502}
1503
1504#[derive(Clone, Debug, Eq, PartialEq)]
1505pub struct ManagedJsonMergeSpecV1 {
1506 pub destination: PathBuf,
1507 pub original_mode: Option<u32>,
1508 pub original_hash: Option<u64>,
1509 pub previous_receipt_hash: Option<u64>,
1510 pub desired_managed_hash: u64,
1511 pub managed_keys: Vec<String>,
1512}
1513
1514#[derive(Clone, Debug, Eq, PartialEq)]
1515pub struct ManagedJsonRelocationSpecV1 {
1516 pub previous_destination: PathBuf,
1517 pub desired_destination: PathBuf,
1518 pub previous_present: bool,
1519 pub previous_mode: Option<u32>,
1520 pub previous_original_hash: Option<u64>,
1521 pub previous_receipt_hash: u64,
1522 pub previous_managed_keys: Vec<String>,
1523 pub desired_managed_hash: u64,
1524 pub desired_managed_keys: Vec<String>,
1525 pub previous_uses_env: bool,
1526 pub desired_uses_env: bool,
1527}
1528
1529#[derive(Clone, Debug, Eq, PartialEq)]
1530pub struct ManagedJsonRemoveSpecV1 {
1531 pub destination: PathBuf,
1532 pub original_mode: Option<u32>,
1533 pub original_hash: u64,
1534 pub receipt_managed_hash: u64,
1535 pub current_managed_hash: u64,
1536 pub managed_keys: Vec<String>,
1537 pub uses_env: bool,
1538}
1539
1540#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1541#[serde(tag = "kind", rename_all = "kebab-case")]
1542pub enum ActionKindV1 {
1543 CreateManagedFile {
1544 destination: PathBuf,
1545 desired_hash: u64,
1546 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1547 requires_admin: bool,
1548 },
1549 CreateManagedFileWithBackup {
1550 destination: PathBuf,
1551 backup: PathBuf,
1552 original_hash: u64,
1553 desired_hash: u64,
1554 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1555 requires_admin: bool,
1556 },
1557 UpdateManagedFile {
1558 destination: PathBuf,
1559 rollback: PathBuf,
1560 #[serde(default, skip_serializing_if = "Option::is_none")]
1561 previous_backup: Option<PathBuf>,
1562 #[serde(default, skip_serializing_if = "Option::is_none")]
1563 original_mode: Option<u32>,
1564 original_hash: u64,
1565 desired_hash: u64,
1566 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1567 requires_admin: bool,
1568 },
1569 RelocateManagedFile {
1570 previous_destination: PathBuf,
1571 #[serde(default, skip_serializing_if = "Option::is_none")]
1572 previous_backup: Option<ManagedFileRelocationBackupV1>,
1573 previous_rollback: PathBuf,
1574 desired_destination: PathBuf,
1575 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1576 previous_present: bool,
1577 #[serde(default, skip_serializing_if = "Option::is_none")]
1578 previous_mode: Option<u32>,
1579 previous_hash: u64,
1580 desired_hash: u64,
1581 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1582 previous_uses_env: bool,
1583 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1584 desired_uses_env: bool,
1585 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1586 previous_requires_admin: bool,
1587 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1588 desired_requires_admin: bool,
1589 },
1590 RemoveManagedFile {
1591 destination: PathBuf,
1592 rollback: PathBuf,
1593 #[serde(default, skip_serializing_if = "Option::is_none")]
1594 original_mode: Option<u32>,
1595 original_hash: u64,
1596 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1597 uses_env: bool,
1598 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1599 requires_admin: bool,
1600 },
1601 RemoveManagedFileWithBackup {
1602 destination: PathBuf,
1603 backup: PathBuf,
1604 rollback: PathBuf,
1605 #[serde(default, skip_serializing_if = "Option::is_none")]
1606 managed_mode: Option<u32>,
1607 managed_hash: u64,
1608 #[serde(default, skip_serializing_if = "Option::is_none")]
1609 backup_mode: Option<u32>,
1610 backup_hash: u64,
1611 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1612 uses_env: bool,
1613 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1614 requires_admin: bool,
1615 },
1616 ForceRemoveManagedFile {
1617 destination: PathBuf,
1618 #[serde(default, skip_serializing_if = "Option::is_none")]
1619 persistent_backup: Option<ForcedManagedFileBackupV1>,
1620 rollback: PathBuf,
1621 receipt_hash: u64,
1622 #[serde(default, skip_serializing_if = "Option::is_none")]
1623 current_mode: Option<u32>,
1624 current_hash: u64,
1625 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1626 uses_env: bool,
1627 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1628 requires_admin: bool,
1629 },
1630 MergeManagedJson {
1631 destination: PathBuf,
1632 rollback: PathBuf,
1633 #[serde(default, skip_serializing_if = "Option::is_none")]
1634 original_mode: Option<u32>,
1635 #[serde(default, skip_serializing_if = "Option::is_none")]
1636 original_hash: Option<u64>,
1637 #[serde(default, skip_serializing_if = "Option::is_none")]
1638 previous_receipt_hash: Option<u64>,
1639 desired_managed_hash: u64,
1640 managed_keys: Vec<String>,
1641 },
1642 RelocateManagedJson {
1643 previous_destination: PathBuf,
1644 previous_rollback: PathBuf,
1645 desired_destination: PathBuf,
1646 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1647 previous_present: bool,
1648 #[serde(default, skip_serializing_if = "Option::is_none")]
1649 previous_mode: Option<u32>,
1650 #[serde(default, skip_serializing_if = "Option::is_none")]
1651 previous_original_hash: Option<u64>,
1652 previous_receipt_hash: u64,
1653 previous_managed_keys: Vec<String>,
1654 desired_managed_hash: u64,
1655 desired_managed_keys: Vec<String>,
1656 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1657 previous_uses_env: bool,
1658 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1659 desired_uses_env: bool,
1660 },
1661 RemoveManagedJson {
1662 destination: PathBuf,
1663 rollback: PathBuf,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 original_mode: Option<u32>,
1666 original_hash: u64,
1667 receipt_managed_hash: u64,
1668 current_managed_hash: u64,
1669 managed_keys: Vec<String>,
1670 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1671 uses_env: bool,
1672 },
1673 CreateShellLauncher {
1674 receipt: ShellLauncherReceiptV1,
1675 resources: Vec<ShellLauncherResourceV1>,
1676 },
1677 UpdateShellLauncher {
1678 previous_receipt: Box<ShellLauncherReceiptV1>,
1679 desired_receipt: Box<ShellLauncherReceiptV1>,
1680 resources: Vec<ShellLauncherUpdateResourceV1>,
1681 },
1682 RemoveShellLauncher {
1683 previous_receipt: Box<ShellLauncherReceiptV1>,
1684 resources: Vec<ShellLauncherRemovalResourceV1>,
1685 },
1686 RemoveLegacyShellLauncher {
1687 resources: Vec<ShellLauncherRemovalResourceV1>,
1688 },
1689 ReplaceShellSnapshot {
1690 destination: PathBuf,
1691 stage: PathBuf,
1692 rollback: PathBuf,
1693 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1694 previous_present: bool,
1695 #[serde(default)]
1696 previous_files: Vec<ShellTreeFileV1>,
1697 desired_files: Vec<ShellTreeFileV1>,
1698 receipts: Vec<ShellReceiptTransitionV1>,
1699 },
1700 ReplaceShellCache {
1701 files: Vec<ShellCacheFileReplacementV1>,
1702 receipts: Vec<ShellReceiptTransitionV1>,
1703 },
1704 RemoveShellCache {
1705 files: Vec<ShellCacheFileRemovalV1>,
1706 #[serde(default)]
1707 receipts: Vec<ShellReceiptRemovalV1>,
1708 },
1709 RemoveShellSnapshot {
1710 destination: PathBuf,
1711 rollback: PathBuf,
1712 #[serde(default)]
1713 previous_files: Vec<ShellTreeFileV1>,
1714 #[serde(default)]
1715 receipts: Vec<ShellReceiptRemovalV1>,
1716 },
1717 ReconcileShellProfile {
1718 files: Vec<ShellProfileFileV1>,
1719 #[serde(default)]
1720 receipt_transitions: Vec<ShellReceiptTransitionV1>,
1721 #[serde(default)]
1722 receipt_removals: Vec<ShellReceiptRemovalV1>,
1723 #[serde(default)]
1724 legacy_targets: Vec<String>,
1725 },
1726 ReconcileSysSplitDns {
1727 #[serde(default, skip_serializing_if = "Option::is_none")]
1728 previous: Option<SysSplitDnsStateV1>,
1729 #[serde(default, skip_serializing_if = "Option::is_none")]
1730 desired: Option<SysSplitDnsStateV1>,
1731 },
1732 ReconcileSysProfileBlocks {
1733 os_id: String,
1734 files: Vec<SysProfileBlockFileV1>,
1735 },
1736 ReplaceShellRenderedFile {
1737 destination: PathBuf,
1738 rollback: PathBuf,
1739 #[serde(default, skip_serializing_if = "Option::is_none")]
1740 previous: Option<ShellFileIdentityV1>,
1741 desired: ShellFileIdentityV1,
1742 receipts: Vec<ShellReceiptTransitionV1>,
1743 },
1744 RemoveShellRenderedFile {
1745 destination: PathBuf,
1746 rollback: PathBuf,
1747 previous: ShellFileIdentityV1,
1748 receipts: Vec<ShellReceiptRemovalV1>,
1749 },
1750 OpaqueExecution {
1751 capability: String,
1752 provenance: ActionProvenanceV1,
1753 requires_administrator: bool,
1754 },
1755}
1756
1757#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1758#[serde(rename_all = "kebab-case")]
1759pub enum ActionProvenanceV1 {
1760 Embedded,
1761 External,
1762 Overlay,
1763}
1764
1765#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1766#[serde(tag = "kind", rename_all = "kebab-case")]
1767pub enum RollbackSupportV1 {
1768 RemoveCreatedIfUnchanged,
1769 RestoreBackupIfUnchanged,
1770 RestorePreviousIfUnchanged,
1771 RestoreRelocatedPreviousIfUnchanged,
1772 RestorePreviousWithBackupIfUnchanged,
1773 RestoreForcedPreviousIfUnchanged,
1774 RestoreJsonKeysIfUnchanged,
1775 RestoreRelocatedJsonKeysIfUnchanged,
1776 RestoreRemovedJsonKeysIfUnchanged,
1777 RemoveCreatedLauncherIfUnchanged,
1778 RestorePreviousLauncherIfUnchanged,
1779 RestoreRemovedLauncherIfUnchanged,
1780 RestorePreviousShellSnapshotIfUnchanged,
1781 RestorePreviousShellCacheIfUnchanged,
1782 RestoreRemovedShellCacheIfUnchanged,
1783 RestoreRemovedShellSnapshotIfUnchanged,
1784 RestorePreviousShellProfileIfUnchanged,
1785 RestorePreviousSysSplitDnsIfUnchanged,
1786 RestorePreviousSysProfileBlocksIfUnchanged,
1787 RestorePreviousShellRenderedFileIfUnchanged,
1788 RestoreRemovedShellRenderedFileIfUnchanged,
1789 Unsupported { reason_code: String },
1790}
1791
1792#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1793#[serde(deny_unknown_fields)]
1794pub struct ShellTreeFileV1 {
1795 pub relative_path: PathBuf,
1796 pub content_hash: u64,
1797}
1798
1799#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1800#[serde(deny_unknown_fields)]
1801pub struct ShellReceiptTransitionV1 {
1802 pub target: String,
1803 #[serde(default, skip_serializing_if = "Option::is_none")]
1804 pub previous: Option<Box<ShellLauncherReceiptV1>>,
1805 pub desired: Box<ShellLauncherReceiptV1>,
1806}
1807
1808#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1809#[serde(deny_unknown_fields)]
1810pub struct ShellReceiptRemovalV1 {
1811 pub target: String,
1812 pub previous: Box<ShellLauncherReceiptV1>,
1813}
1814
1815#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1816#[serde(deny_unknown_fields)]
1817pub struct ShellFileIdentityV1 {
1818 pub content_hash: u64,
1819 #[serde(default, skip_serializing_if = "Option::is_none")]
1820 pub unix_mode: Option<u32>,
1821}
1822
1823#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1824#[serde(deny_unknown_fields)]
1825pub struct ShellCacheFileReplacementV1 {
1826 pub destination: PathBuf,
1827 pub rollback: PathBuf,
1828 #[serde(default, skip_serializing_if = "Option::is_none")]
1829 pub previous: Option<ShellFileIdentityV1>,
1830 pub desired: ShellFileIdentityV1,
1831}
1832
1833#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1834#[serde(deny_unknown_fields)]
1835pub struct ShellCacheFileRemovalV1 {
1836 pub destination: PathBuf,
1837 pub rollback: PathBuf,
1838 pub previous: ShellFileIdentityV1,
1839}
1840
1841#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1842#[serde(rename_all = "kebab-case")]
1843pub enum ShellProfileFileOwnershipV1 {
1844 WholeFile,
1845 SentinelBlock,
1846}
1847
1848#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1849#[serde(deny_unknown_fields)]
1850pub struct ShellProfileFileV1 {
1851 pub destination: PathBuf,
1852 pub rollback: PathBuf,
1853 pub ownership: ShellProfileFileOwnershipV1,
1854 #[serde(default, skip_serializing_if = "Option::is_none")]
1855 pub previous: Option<ShellFileIdentityV1>,
1856 #[serde(default, skip_serializing_if = "Option::is_none")]
1857 pub desired: Option<ShellFileIdentityV1>,
1858 #[serde(default, skip_serializing_if = "Option::is_none")]
1859 pub previous_block_hash: Option<u64>,
1860 #[serde(default, skip_serializing_if = "Option::is_none")]
1861 pub desired_block_hash: Option<u64>,
1862}
1863
1864#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1865#[serde(deny_unknown_fields)]
1866pub struct SysSplitDnsStateV1 {
1867 pub os_id: String,
1868 pub item_id: String,
1869 pub domain: String,
1870 pub servers: Vec<String>,
1871 pub resource: PathBuf,
1872 pub content_hash: u64,
1873}
1874
1875#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1876#[serde(deny_unknown_fields)]
1877pub struct SysProfileBlockFileV1 {
1878 pub destination: PathBuf,
1879 pub rollback: PathBuf,
1880 #[serde(default, skip_serializing_if = "Option::is_none")]
1881 pub previous: Option<ShellFileIdentityV1>,
1882 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub desired: Option<ShellFileIdentityV1>,
1884 #[serde(default, skip_serializing_if = "Option::is_none")]
1885 pub previous_owned_hash: Option<u64>,
1886 #[serde(default, skip_serializing_if = "Option::is_none")]
1887 pub desired_owned_hash: Option<u64>,
1888}
1889
1890#[derive(Clone, Debug, Eq, PartialEq)]
1891pub struct ShellCacheReplacementSpecV1 {
1892 pub files: Vec<ShellCacheFileReplacementV1>,
1893 pub receipts: Vec<ShellReceiptTransitionV1>,
1894}
1895
1896#[derive(Clone, Debug, Eq, PartialEq)]
1897pub struct ShellCacheRemovalSpecV1 {
1898 pub files: Vec<ShellCacheFileRemovalV1>,
1899 pub receipts: Vec<ShellReceiptRemovalV1>,
1900}
1901
1902#[derive(Clone, Debug, Eq, PartialEq)]
1903pub struct ShellProfileReconciliationSpecV1 {
1904 pub files: Vec<ShellProfileFileV1>,
1905 pub receipt_transitions: Vec<ShellReceiptTransitionV1>,
1906 pub receipt_removals: Vec<ShellReceiptRemovalV1>,
1907 pub legacy_targets: Vec<String>,
1908}
1909
1910#[derive(Clone, Debug, Eq, PartialEq)]
1911pub struct ShellRenderedFileReplacementSpecV1 {
1912 pub destination: PathBuf,
1913 pub previous: Option<ShellFileIdentityV1>,
1914 pub desired: ShellFileIdentityV1,
1915 pub receipts: Vec<ShellReceiptTransitionV1>,
1916}
1917
1918#[derive(Clone, Debug, Eq, PartialEq)]
1919pub struct ShellRenderedFileRemovalSpecV1 {
1920 pub destination: PathBuf,
1921 pub previous: ShellFileIdentityV1,
1922 pub receipts: Vec<ShellReceiptRemovalV1>,
1923}
1924
1925#[derive(Clone, Debug, Eq, PartialEq)]
1926pub struct ShellSnapshotReplacementSpecV1 {
1927 pub destination: PathBuf,
1928 pub previous_present: bool,
1929 pub previous_files: Vec<ShellTreeFileV1>,
1930 pub desired_files: Vec<ShellTreeFileV1>,
1931 pub receipts: Vec<ShellReceiptTransitionV1>,
1932}
1933
1934#[derive(Clone, Debug, Eq, PartialEq)]
1935pub struct ShellSnapshotRemovalSpecV1 {
1936 pub destination: PathBuf,
1937 pub previous_files: Vec<ShellTreeFileV1>,
1938 pub receipts: Vec<ShellReceiptRemovalV1>,
1939}
1940
1941#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1942#[serde(deny_unknown_fields)]
1943pub struct ShellLauncherReceiptV1 {
1944 pub category: String,
1945 pub command: String,
1946 pub mode: String,
1947 pub source_path: PathBuf,
1948 pub rendered_path: PathBuf,
1949 pub runtime: String,
1950 #[serde(default, skip_serializing_if = "Option::is_none")]
1951 pub bun_dependencies: Option<String>,
1952 #[serde(default, skip_serializing_if = "Option::is_none")]
1953 pub dependency_hash: Option<u64>,
1954 #[serde(default)]
1955 pub transforms: Vec<String>,
1956 #[serde(default)]
1957 pub env: Vec<String>,
1958 #[serde(default)]
1959 pub needs_source: bool,
1960 pub content_hash: u64,
1961}
1962
1963impl ShellLauncherReceiptV1 {
1964 fn is_valid(&self) -> bool {
1965 !self.category.is_empty()
1966 && !self.command.is_empty()
1967 && matches!(self.mode.as_str(), "snapshot" | "live")
1968 && !self.source_path.as_os_str().is_empty()
1969 && !self.rendered_path.as_os_str().is_empty()
1970 && matches!(self.runtime.as_str(), "native" | "bun")
1971 && !self.category.chars().any(char::is_control)
1972 && !self.command.chars().any(char::is_control)
1973 }
1974}
1975
1976#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1977#[serde(tag = "kind", rename_all = "kebab-case")]
1978pub enum ShellLauncherResourceV1 {
1979 Symlink {
1980 destination: PathBuf,
1981 target: PathBuf,
1982 },
1983 File {
1984 destination: PathBuf,
1985 desired_hash: u64,
1986 #[serde(default, skip_serializing_if = "Option::is_none")]
1987 unix_mode: Option<u32>,
1988 },
1989}
1990
1991#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1992#[serde(deny_unknown_fields)]
1993pub struct ShellLauncherUpdateResourceV1 {
1994 pub previous: ShellLauncherResourceV1,
1995 pub desired: ShellLauncherResourceV1,
1996 pub rollback: PathBuf,
1997}
1998
1999#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2000#[serde(deny_unknown_fields)]
2001pub struct ShellLauncherRemovalResourceV1 {
2002 pub previous: ShellLauncherResourceV1,
2003 pub rollback: PathBuf,
2004}
2005
2006impl ShellLauncherResourceV1 {
2007 pub fn destination(&self) -> &Path {
2008 match self {
2009 Self::Symlink { destination, .. } | Self::File { destination, .. } => destination,
2010 }
2011 }
2012}
2013
2014fn valid_shell_launcher_resources(resources: &[ShellLauncherResourceV1]) -> bool {
2015 !resources.is_empty()
2016 && resources.iter().all(|resource| match resource {
2017 ShellLauncherResourceV1::Symlink {
2018 destination,
2019 target,
2020 } => !destination.as_os_str().is_empty() && !target.as_os_str().is_empty(),
2021 ShellLauncherResourceV1::File { destination, .. } => {
2022 !destination.as_os_str().is_empty()
2023 }
2024 })
2025 && resources
2026 .iter()
2027 .map(ShellLauncherResourceV1::destination)
2028 .collect::<BTreeSet<_>>()
2029 .len()
2030 == resources.len()
2031}
2032
2033fn valid_shell_launcher_update_resources(resources: &[ShellLauncherUpdateResourceV1]) -> bool {
2034 !resources.is_empty()
2035 && resources.iter().all(|resource| {
2036 resource.previous.destination() == resource.desired.destination()
2037 && resource.previous != resource.desired
2038 && resource.rollback == managed_file_rollback_path(resource.previous.destination())
2039 && resource.rollback != resource.previous.destination()
2040 })
2041 && resources
2042 .iter()
2043 .map(|resource| resource.previous.destination())
2044 .collect::<BTreeSet<_>>()
2045 .len()
2046 == resources.len()
2047}
2048
2049fn valid_shell_launcher_removal_resources(resources: &[ShellLauncherRemovalResourceV1]) -> bool {
2050 !resources.is_empty()
2051 && resources.iter().all(|resource| {
2052 !resource.previous.destination().as_os_str().is_empty()
2053 && resource.rollback == managed_file_rollback_path(resource.previous.destination())
2054 && resource.rollback != resource.previous.destination()
2055 })
2056 && resources
2057 .iter()
2058 .map(|resource| resource.previous.destination())
2059 .collect::<BTreeSet<_>>()
2060 .len()
2061 == resources.len()
2062}
2063
2064fn valid_shell_tree_files(files: &[ShellTreeFileV1], allow_empty: bool) -> bool {
2065 (allow_empty || !files.is_empty())
2066 && files.iter().all(|file| {
2067 !file.relative_path.as_os_str().is_empty()
2068 && !file.relative_path.is_absolute()
2069 && file
2070 .relative_path
2071 .components()
2072 .all(|component| matches!(component, std::path::Component::Normal(_)))
2073 })
2074 && files
2075 .iter()
2076 .map(|file| &file.relative_path)
2077 .collect::<BTreeSet<_>>()
2078 .len()
2079 == files.len()
2080}
2081
2082fn valid_shell_receipt_transitions(receipts: &[ShellReceiptTransitionV1]) -> bool {
2083 !receipts.is_empty()
2084 && receipts.iter().all(|transition| {
2085 transition.desired.is_valid()
2086 && transition.target
2087 == format!(
2088 "shell/{}/{}",
2089 transition.desired.category, transition.desired.command
2090 )
2091 && transition.previous.as_ref().is_none_or(|previous| {
2092 previous.is_valid()
2093 && previous.category == transition.desired.category
2094 && previous.command == transition.desired.command
2095 })
2096 })
2097 && receipts
2098 .iter()
2099 .map(|transition| &transition.target)
2100 .collect::<BTreeSet<_>>()
2101 .len()
2102 == receipts.len()
2103}
2104
2105fn valid_shell_receipt_removals(receipts: &[ShellReceiptRemovalV1], destination: &Path) -> bool {
2106 !receipts.is_empty()
2107 && receipts.iter().all(|removal| {
2108 removal.previous.is_valid()
2109 && removal.previous.rendered_path == destination
2110 && removal.target
2111 == format!(
2112 "shell/{}/{}",
2113 removal.previous.category, removal.previous.command
2114 )
2115 })
2116 && receipts
2117 .iter()
2118 .map(|removal| &removal.target)
2119 .collect::<BTreeSet<_>>()
2120 .len()
2121 == receipts.len()
2122}
2123
2124fn valid_shell_receipt_removal_set(receipts: &[ShellReceiptRemovalV1]) -> bool {
2125 receipts.iter().all(|removal| {
2126 removal.previous.is_valid()
2127 && removal.target
2128 == format!(
2129 "shell/{}/{}",
2130 removal.previous.category, removal.previous.command
2131 )
2132 }) && receipts
2133 .iter()
2134 .map(|removal| &removal.target)
2135 .collect::<BTreeSet<_>>()
2136 .len()
2137 == receipts.len()
2138}
2139
2140fn valid_shell_cache_files(files: &[ShellCacheFileReplacementV1]) -> bool {
2141 let destinations = files
2142 .iter()
2143 .map(|file| &file.destination)
2144 .collect::<BTreeSet<_>>();
2145 let rollbacks = files
2146 .iter()
2147 .map(|file| &file.rollback)
2148 .collect::<BTreeSet<_>>();
2149 !files.is_empty()
2150 && files.iter().all(|file| {
2151 !file.destination.as_os_str().is_empty()
2152 && file.rollback == managed_file_rollback_path(&file.destination)
2153 && file.previous.as_ref() != Some(&file.desired)
2154 })
2155 && destinations.len() == files.len()
2156 && rollbacks.len() == files.len()
2157 && destinations.is_disjoint(&rollbacks)
2158}
2159
2160fn valid_shell_cache_removal_files(files: &[ShellCacheFileRemovalV1]) -> bool {
2161 let destinations = files
2162 .iter()
2163 .map(|file| &file.destination)
2164 .collect::<BTreeSet<_>>();
2165 let rollbacks = files
2166 .iter()
2167 .map(|file| &file.rollback)
2168 .collect::<BTreeSet<_>>();
2169 !files.is_empty()
2170 && files.iter().all(|file| {
2171 !file.destination.as_os_str().is_empty()
2172 && file.rollback == managed_file_rollback_path(&file.destination)
2173 })
2174 && destinations.len() == files.len()
2175 && rollbacks.len() == files.len()
2176 && destinations.is_disjoint(&rollbacks)
2177}
2178
2179fn valid_shell_profile_files(files: &[ShellProfileFileV1]) -> bool {
2180 let destinations = files
2181 .iter()
2182 .map(|file| &file.destination)
2183 .collect::<BTreeSet<_>>();
2184 let rollbacks = files
2185 .iter()
2186 .map(|file| &file.rollback)
2187 .collect::<BTreeSet<_>>();
2188 !files.is_empty()
2189 && files.iter().all(|file| {
2190 !file.destination.as_os_str().is_empty()
2191 && file.rollback == managed_file_rollback_path(&file.destination)
2192 && file.previous != file.desired
2193 && match file.ownership {
2194 ShellProfileFileOwnershipV1::WholeFile => {
2195 file.previous_block_hash.is_none() && file.desired_block_hash.is_none()
2196 }
2197 ShellProfileFileOwnershipV1::SentinelBlock => {
2198 file.previous_block_hash != file.desired_block_hash
2199 }
2200 }
2201 })
2202 && destinations.len() == files.len()
2203 && rollbacks.len() == files.len()
2204 && destinations.is_disjoint(&rollbacks)
2205}
2206
2207fn valid_shell_profile_receipts(
2208 transitions: &[ShellReceiptTransitionV1],
2209 removals: &[ShellReceiptRemovalV1],
2210 legacy_targets: &[String],
2211) -> bool {
2212 let receipt_targets = transitions
2213 .iter()
2214 .map(|transition| transition.target.as_str())
2215 .chain(removals.iter().map(|removal| removal.target.as_str()))
2216 .collect::<BTreeSet<_>>();
2217 let legacy = legacy_targets
2218 .iter()
2219 .map(String::as_str)
2220 .collect::<BTreeSet<_>>();
2221 (!transitions.is_empty() || !removals.is_empty() || !legacy_targets.is_empty())
2222 && (transitions.is_empty() || valid_shell_receipt_transitions(transitions))
2223 && (removals.is_empty() || valid_shell_receipt_removal_set(removals))
2224 && legacy.len() == legacy_targets.len()
2225 && legacy_targets
2226 .iter()
2227 .all(|target| target.starts_with("shell/") && target.len() > 6)
2228 && receipt_targets.is_disjoint(&legacy)
2229}
2230
2231fn valid_sys_split_dns_transition(
2232 previous: Option<&SysSplitDnsStateV1>,
2233 desired: Option<&SysSplitDnsStateV1>,
2234) -> bool {
2235 let valid = |state: &SysSplitDnsStateV1| {
2236 !state.os_id.trim().is_empty()
2237 && !state.item_id.trim().is_empty()
2238 && !state.domain.trim().is_empty()
2239 && !state.servers.is_empty()
2240 && !state.resource.as_os_str().is_empty()
2241 };
2242 (previous.is_some() || desired.is_some())
2243 && previous != desired
2244 && previous.is_none_or(valid)
2245 && desired.is_none_or(valid)
2246}
2247
2248fn valid_sys_profile_block_files(files: &[SysProfileBlockFileV1]) -> bool {
2249 let destinations = files
2250 .iter()
2251 .map(|file| &file.destination)
2252 .collect::<BTreeSet<_>>();
2253 let rollbacks = files
2254 .iter()
2255 .map(|file| &file.rollback)
2256 .collect::<BTreeSet<_>>();
2257 !files.is_empty()
2258 && files.iter().all(|file| {
2259 !file.destination.as_os_str().is_empty()
2260 && file.rollback == managed_file_rollback_path(&file.destination)
2261 && file.previous != file.desired
2262 && file.previous_owned_hash != file.desired_owned_hash
2263 })
2264 && destinations.len() == files.len()
2265 && rollbacks.len() == files.len()
2266 && destinations.is_disjoint(&rollbacks)
2267}
2268
2269pub fn managed_file_rollback_path(destination: &Path) -> PathBuf {
2273 let name = destination
2274 .file_name()
2275 .and_then(|name| name.to_str())
2276 .unwrap_or("file");
2277 destination.with_file_name(format!("{name}.shine.rollback"))
2278}
2279
2280pub fn shell_snapshot_stage_path(destination: &Path) -> PathBuf {
2281 let name = destination
2282 .file_name()
2283 .and_then(|name| name.to_str())
2284 .unwrap_or("snapshot");
2285 destination.with_file_name(format!(".{name}.shine.stage"))
2286}
2287
2288pub fn shell_snapshot_rollback_path(destination: &Path) -> PathBuf {
2289 let name = destination
2290 .file_name()
2291 .and_then(|name| name.to_str())
2292 .unwrap_or("snapshot");
2293 destination.with_file_name(format!(".{name}.shine.rollback"))
2294}
2295
2296#[derive(Clone, Debug, Default, Eq, PartialEq)]
2297pub struct ActionPermissionRequirementsV1 {
2298 pub required: PermissionSetV1,
2299 pub uncomputable_codes: BTreeSet<String>,
2300}
2301
2302#[derive(Clone, Debug, Eq, PartialEq)]
2303pub enum ActionIrError {
2304 UnsupportedSchema(u32),
2305 Invalid(String),
2306 DuplicateAction(String),
2307}
2308
2309impl fmt::Display for ActionIrError {
2310 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2311 match self {
2312 Self::UnsupportedSchema(version) => {
2313 write!(formatter, "unsupported action IR schema version {version}")
2314 }
2315 Self::Invalid(message) => formatter.write_str(message),
2316 Self::DuplicateAction(action) => write!(formatter, "duplicate action id `{action}`"),
2317 }
2318 }
2319}
2320
2321impl std::error::Error for ActionIrError {}
2322
2323fn validate_identity(kind: &str, value: &str) -> Result<(), ActionIrError> {
2324 if value.is_empty() || value.chars().any(char::is_control) {
2325 return Err(ActionIrError::Invalid(format!(
2326 "{kind} identity must be non-empty and single-line"
2327 )));
2328 }
2329 Ok(())
2330}
2331
2332fn valid_managed_json_keys(keys: &[String]) -> bool {
2333 !keys.is_empty()
2334 && keys.iter().all(|key| {
2335 !key.trim().is_empty() && !key.contains('.') && !key.chars().any(char::is_control)
2336 })
2337 && keys.iter().collect::<BTreeSet<_>>().len() == keys.len()
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342 use super::*;
2343 use crate::install::hash_content;
2344
2345 fn ir() -> ActionIrV1 {
2346 ActionIrV1::new(
2347 "operation-1",
2348 vec![DeclarativeActionV1::create_managed_file(
2349 "action-1",
2350 "app/demo",
2351 "config",
2352 PathBuf::from("/home/test/.config/demo/config"),
2353 hash_content(b"super-secret-managed-bytes"),
2354 false,
2355 )],
2356 )
2357 }
2358
2359 #[test]
2360 fn action_ir_roundtrip_is_stable_and_payload_free() {
2361 let value = ir();
2362 value.validate().unwrap();
2363 let first = toml::to_string(&value).unwrap();
2364 let decoded: ActionIrV1 = toml::from_str(&first).unwrap();
2365 let second = toml::to_string(&decoded).unwrap();
2366 assert_eq!(value, decoded);
2367 assert_eq!(first, second);
2368 assert!(!first.contains("super-secret-managed-bytes"));
2369 }
2370
2371 #[test]
2372 fn action_permissions_are_derived_from_the_executable_kind() {
2373 let requirements =
2374 ir().permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
2375 assert!(requirements.uncomputable_codes.is_empty());
2376 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2377 access: FilesystemAccessV1::Write,
2378 path: "absolute:/home/test/.config/demo/config".to_string(),
2379 }));
2380 }
2381
2382 #[test]
2383 fn relocation_action_binds_both_destinations_and_previous_rollback() {
2384 let previous = PathBuf::from("/etc/demo-old/config.toml");
2385 let desired = PathBuf::from("/home/test/.config/demo-next/config.toml");
2386 let rollback = managed_file_rollback_path(&previous);
2387 let value = ActionIrV1::new(
2388 "relocate-operation",
2389 vec![DeclarativeActionV1::relocate_managed_file(
2390 "relocate-config",
2391 "app/demo",
2392 "config.toml",
2393 ManagedFileRelocationSpecV1 {
2394 previous_destination: previous.clone(),
2395 previous_backup: None,
2396 desired_destination: desired.clone(),
2397 previous_present: true,
2398 previous_mode: Some(0o100600),
2399 previous_hash: hash_content(b"previous-private-bytes"),
2400 desired_hash: hash_content(b"desired-private-bytes"),
2401 previous_uses_env: false,
2402 desired_uses_env: false,
2403 previous_requires_admin: true,
2404 desired_requires_admin: false,
2405 },
2406 )],
2407 );
2408 value.validate().unwrap();
2409 let encoded = toml::to_string(&value).unwrap();
2410 assert!(!encoded.contains("previous-private-bytes"));
2411 assert!(!encoded.contains("desired-private-bytes"));
2412 let decoded: ActionIrV1 = toml::from_str(&encoded).unwrap();
2413 assert_eq!(decoded, value);
2414 let requirements =
2415 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2416 for (access, path) in [
2417 (FilesystemAccessV1::Remove, &previous),
2418 (FilesystemAccessV1::Write, &desired),
2419 (FilesystemAccessV1::Write, &rollback),
2420 (FilesystemAccessV1::Remove, &rollback),
2421 ] {
2422 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2423 access,
2424 path: format!("absolute:{}", path.display()),
2425 }));
2426 }
2427 assert!(requirements.required.contains(&PermissionV1::Administrator));
2428 }
2429
2430 #[test]
2431 fn shell_launcher_action_is_payload_free_and_derives_each_resource_write() {
2432 let ps1 = PathBuf::from("/home/test/.shine/bin/demo.ps1");
2433 let cmd = PathBuf::from("/home/test/.shine/bin/demo.cmd");
2434 let value = ActionIrV1::new(
2435 "shell-create",
2436 vec![DeclarativeActionV1::create_shell_launcher(
2437 "create-launcher",
2438 "shell/demo/demo",
2439 "launcher",
2440 ShellLauncherReceiptV1 {
2441 category: "demo".to_string(),
2442 command: "demo".to_string(),
2443 mode: "snapshot".to_string(),
2444 source_path: PathBuf::from("/home/test/.shine/installed/shell/demo/demo.sh"),
2445 rendered_path: PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh"),
2446 runtime: "native".to_string(),
2447 bun_dependencies: None,
2448 dependency_hash: None,
2449 transforms: Vec::new(),
2450 env: Vec::new(),
2451 needs_source: false,
2452 content_hash: hash_content(b"source bytes"),
2453 },
2454 vec![
2455 ShellLauncherResourceV1::File {
2456 destination: ps1.clone(),
2457 desired_hash: hash_content(b"private ps1 launcher bytes"),
2458 unix_mode: None,
2459 },
2460 ShellLauncherResourceV1::File {
2461 destination: cmd.clone(),
2462 desired_hash: hash_content(b"private cmd launcher bytes"),
2463 unix_mode: None,
2464 },
2465 ],
2466 )],
2467 );
2468 value.validate().unwrap();
2469 let encoded = toml::to_string(&value).unwrap();
2470 assert!(!encoded.contains("private ps1 launcher bytes"));
2471 assert!(!encoded.contains("private cmd launcher bytes"));
2472 let requirements =
2473 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2474 for destination in [ps1, cmd] {
2475 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2476 access: FilesystemAccessV1::Write,
2477 path: format!("absolute:{}", destination.display()),
2478 }));
2479 }
2480 }
2481
2482 #[test]
2483 fn shell_launcher_update_is_payload_free_and_derives_rollback_permissions() {
2484 let destination = PathBuf::from("/home/test/.shine/bin/demo");
2485 let rollback = managed_file_rollback_path(&destination);
2486 let previous_receipt = ShellLauncherReceiptV1 {
2487 category: "demo".to_string(),
2488 command: "demo".to_string(),
2489 mode: "snapshot".to_string(),
2490 source_path: PathBuf::from("/home/test/.shine/installed/shell/demo/demo.sh"),
2491 rendered_path: PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh"),
2492 runtime: "native".to_string(),
2493 bun_dependencies: None,
2494 dependency_hash: None,
2495 transforms: Vec::new(),
2496 env: Vec::new(),
2497 needs_source: false,
2498 content_hash: hash_content(b"old source"),
2499 };
2500 let mut desired_receipt = previous_receipt.clone();
2501 desired_receipt.runtime = "bun".to_string();
2502 desired_receipt.content_hash = hash_content(b"new source");
2503 let value = ActionIrV1::new(
2504 "shell-update",
2505 vec![DeclarativeActionV1::update_shell_launcher(
2506 "update-launcher",
2507 "shell/demo/demo",
2508 "launcher",
2509 previous_receipt,
2510 desired_receipt,
2511 vec![ShellLauncherUpdateResourceV1 {
2512 previous: ShellLauncherResourceV1::Symlink {
2513 destination: destination.clone(),
2514 target: PathBuf::from("/home/test/.shine/installed/shell/demo/demo.sh"),
2515 },
2516 desired: ShellLauncherResourceV1::File {
2517 destination: destination.clone(),
2518 desired_hash: hash_content(b"private replacement launcher bytes"),
2519 unix_mode: Some(0o755),
2520 },
2521 rollback: rollback.clone(),
2522 }],
2523 )],
2524 );
2525 value.validate().unwrap();
2526 let encoded = toml::to_string(&value).unwrap();
2527 assert!(!encoded.contains("private replacement launcher bytes"));
2528 let requirements =
2529 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2530 for (access, path) in [
2531 (FilesystemAccessV1::Write, &destination),
2532 (FilesystemAccessV1::Remove, &destination),
2533 (FilesystemAccessV1::Write, &rollback),
2534 (FilesystemAccessV1::Remove, &rollback),
2535 ] {
2536 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2537 access,
2538 path: format!("absolute:{}", path.display()),
2539 }));
2540 }
2541 }
2542
2543 #[test]
2544 fn shell_launcher_removal_is_payload_free_and_derives_rollback_permissions() {
2545 let destination = PathBuf::from("/home/test/.shine/bin/demo");
2546 let rollback = managed_file_rollback_path(&destination);
2547 let value = ActionIrV1::new(
2548 "shell-remove",
2549 vec![DeclarativeActionV1::remove_shell_launcher(
2550 "remove-launcher",
2551 "shell/demo/demo",
2552 "launcher",
2553 ShellLauncherReceiptV1 {
2554 category: "demo".to_string(),
2555 command: "demo".to_string(),
2556 mode: "snapshot".to_string(),
2557 source_path: PathBuf::from("/home/test/.shine/installed/shell/demo/demo.sh"),
2558 rendered_path: PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh"),
2559 runtime: "native".to_string(),
2560 bun_dependencies: None,
2561 dependency_hash: None,
2562 transforms: Vec::new(),
2563 env: Vec::new(),
2564 needs_source: false,
2565 content_hash: hash_content(b"private source bytes"),
2566 },
2567 vec![ShellLauncherRemovalResourceV1 {
2568 previous: ShellLauncherResourceV1::Symlink {
2569 destination: destination.clone(),
2570 target: PathBuf::from("/home/test/.shine/installed/shell/demo/demo.sh"),
2571 },
2572 rollback: rollback.clone(),
2573 }],
2574 )],
2575 );
2576 value.validate().unwrap();
2577 let encoded = toml::to_string(&value).unwrap();
2578 assert!(!encoded.contains("private source bytes"));
2579 let requirements =
2580 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2581 for (access, path) in [
2582 (FilesystemAccessV1::Remove, &destination),
2583 (FilesystemAccessV1::Write, &rollback),
2584 (FilesystemAccessV1::Remove, &rollback),
2585 ] {
2586 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2587 access,
2588 path: format!("absolute:{}", path.display()),
2589 }));
2590 }
2591 }
2592
2593 #[test]
2594 fn shell_rendered_file_action_is_payload_free_and_derives_rollback_permissions() {
2595 let destination = PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh");
2596 let rollback = managed_file_rollback_path(&destination);
2597 let receipt = ShellLauncherReceiptV1 {
2598 category: "demo".to_string(),
2599 command: "demo".to_string(),
2600 mode: "snapshot".to_string(),
2601 source_path: PathBuf::from("/home/test/.shine/presets/shell/demo/demo.sh"),
2602 rendered_path: destination.clone(),
2603 runtime: "native".to_string(),
2604 bun_dependencies: None,
2605 dependency_hash: None,
2606 transforms: vec!["template".to_string()],
2607 env: Vec::new(),
2608 needs_source: false,
2609 content_hash: hash_content(b"private source bytes"),
2610 };
2611 let value = ActionIrV1::new(
2612 "shell-rendered",
2613 vec![DeclarativeActionV1::replace_shell_rendered_file(
2614 "replace-rendered",
2615 "shell/demo/demo",
2616 "rendered-output",
2617 ShellRenderedFileReplacementSpecV1 {
2618 destination: destination.clone(),
2619 previous: Some(ShellFileIdentityV1 {
2620 content_hash: hash_content(b"private previous rendered bytes"),
2621 unix_mode: Some(0o755),
2622 }),
2623 desired: ShellFileIdentityV1 {
2624 content_hash: hash_content(b"private desired rendered bytes"),
2625 unix_mode: Some(0o755),
2626 },
2627 receipts: vec![ShellReceiptTransitionV1 {
2628 target: "shell/demo/demo".to_string(),
2629 previous: Some(Box::new(receipt.clone())),
2630 desired: Box::new(receipt),
2631 }],
2632 },
2633 )],
2634 );
2635 value.validate().unwrap();
2636 let encoded = toml::to_string(&value).unwrap();
2637 assert!(!encoded.contains("private previous rendered bytes"));
2638 assert!(!encoded.contains("private desired rendered bytes"));
2639 let requirements =
2640 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2641 for (access, path) in [
2642 (FilesystemAccessV1::Write, &destination),
2643 (FilesystemAccessV1::Remove, &destination),
2644 (FilesystemAccessV1::Write, &rollback),
2645 (FilesystemAccessV1::Remove, &rollback),
2646 ] {
2647 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2648 access,
2649 path: format!("absolute:{}", path.display()),
2650 }));
2651 }
2652 }
2653
2654 #[test]
2655 fn shell_rendered_file_removal_is_payload_free_and_derives_rollback_permissions() {
2656 let destination = PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh");
2657 let rollback = managed_file_rollback_path(&destination);
2658 let receipt = ShellLauncherReceiptV1 {
2659 category: "demo".to_string(),
2660 command: "demo".to_string(),
2661 mode: "live".to_string(),
2662 source_path: PathBuf::from("/home/test/presets/shell/demo/demo.sh"),
2663 rendered_path: destination.clone(),
2664 runtime: "native".to_string(),
2665 bun_dependencies: None,
2666 dependency_hash: None,
2667 transforms: vec!["template".to_string()],
2668 env: Vec::new(),
2669 needs_source: false,
2670 content_hash: hash_content(b"private source bytes"),
2671 };
2672 let value = ActionIrV1::new(
2673 "shell-rendered-remove",
2674 vec![DeclarativeActionV1::remove_shell_rendered_file(
2675 "remove-rendered",
2676 "shell/demo/demo",
2677 "rendered-output",
2678 ShellRenderedFileRemovalSpecV1 {
2679 destination: destination.clone(),
2680 previous: ShellFileIdentityV1 {
2681 content_hash: hash_content(b"private rendered bytes"),
2682 unix_mode: Some(0o755),
2683 },
2684 receipts: vec![ShellReceiptRemovalV1 {
2685 target: "shell/demo/demo".to_string(),
2686 previous: Box::new(receipt),
2687 }],
2688 },
2689 )],
2690 );
2691 value.validate().unwrap();
2692 let encoded = toml::to_string(&value).unwrap();
2693 assert!(!encoded.contains("private rendered bytes"));
2694 let decoded: ActionIrV1 = toml::from_str(&encoded).unwrap();
2695 assert_eq!(decoded, value);
2696 let requirements =
2697 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2698 for (access, path) in [
2699 (FilesystemAccessV1::Remove, &destination),
2700 (FilesystemAccessV1::Write, &rollback),
2701 (FilesystemAccessV1::Remove, &rollback),
2702 ] {
2703 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2704 access,
2705 path: format!("absolute:{}", path.display()),
2706 }));
2707 }
2708 }
2709
2710 #[test]
2711 fn shell_cache_action_is_payload_free_and_derives_all_file_permissions() {
2712 let destination = PathBuf::from("/home/test/.shine/presets/shell/demo/demo.sh");
2713 let rollback = managed_file_rollback_path(&destination);
2714 let receipt = ShellLauncherReceiptV1 {
2715 category: "demo".to_string(),
2716 command: "demo".to_string(),
2717 mode: "snapshot".to_string(),
2718 source_path: destination.clone(),
2719 rendered_path: PathBuf::from("/home/test/.shine/rendered/shell/demo/demo.sh"),
2720 runtime: "native".to_string(),
2721 bun_dependencies: None,
2722 dependency_hash: None,
2723 transforms: Vec::new(),
2724 env: Vec::new(),
2725 needs_source: false,
2726 content_hash: hash_content(b"private desired cache bytes"),
2727 };
2728 let value = ActionIrV1::new(
2729 "shell-cache",
2730 vec![DeclarativeActionV1::replace_shell_cache(
2731 "replace-cache",
2732 "shell/demo",
2733 "preset-cache",
2734 ShellCacheReplacementSpecV1 {
2735 files: vec![ShellCacheFileReplacementV1 {
2736 destination: destination.clone(),
2737 rollback: rollback.clone(),
2738 previous: Some(ShellFileIdentityV1 {
2739 content_hash: hash_content(b"private previous cache bytes"),
2740 unix_mode: Some(0o100755),
2741 }),
2742 desired: ShellFileIdentityV1 {
2743 content_hash: hash_content(b"private desired cache bytes"),
2744 unix_mode: Some(0o100755),
2745 },
2746 }],
2747 receipts: vec![ShellReceiptTransitionV1 {
2748 target: "shell/demo/demo".to_string(),
2749 previous: Some(Box::new(receipt.clone())),
2750 desired: Box::new(receipt),
2751 }],
2752 },
2753 )],
2754 );
2755 value.validate().unwrap();
2756 let encoded = toml::to_string(&value).unwrap();
2757 assert!(!encoded.contains("private previous cache bytes"));
2758 assert!(!encoded.contains("private desired cache bytes"));
2759 let requirements =
2760 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2761 for (access, path) in [
2762 (FilesystemAccessV1::Write, &destination),
2763 (FilesystemAccessV1::Remove, &destination),
2764 (FilesystemAccessV1::Write, &rollback),
2765 (FilesystemAccessV1::Remove, &rollback),
2766 ] {
2767 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2768 access,
2769 path: format!("absolute:{}", path.display()),
2770 }));
2771 }
2772 }
2773
2774 #[test]
2775 fn privileged_file_actions_derive_administrator_permission() {
2776 let destination = PathBuf::from("/etc/demo/config");
2777 let value = ActionIrV1::new(
2778 "operation-privileged",
2779 vec![DeclarativeActionV1::update_managed_file(
2780 "action-privileged",
2781 "app/demo",
2782 "config",
2783 ManagedFileUpdateSpecV1 {
2784 destination,
2785 previous_backup: None,
2786 original_mode: Some(0o100600),
2787 original_hash: hash_content(b"previous"),
2788 desired_hash: hash_content(b"next"),
2789 requires_admin: true,
2790 },
2791 )],
2792 );
2793
2794 let requirements =
2795 value.permission_requirements(|path| format!("absolute:{}", path.display()));
2796 assert!(requirements.required.contains(&PermissionV1::Administrator));
2797 }
2798
2799 #[test]
2800 fn backup_creation_is_payload_free_and_derives_both_path_effects() {
2801 let destination = PathBuf::from("/home/test/.config/demo/config");
2802 let backup = PathBuf::from("/home/test/.config/demo/config.shine.bak");
2803 let value = ActionIrV1::new(
2804 "operation-backup",
2805 vec![DeclarativeActionV1::create_managed_file_with_backup(
2806 "action-backup",
2807 "app/demo",
2808 "config",
2809 ManagedFileCreationWithBackupSpecV1 {
2810 destination: destination.clone(),
2811 backup: backup.clone(),
2812 original_hash: hash_content(b"private-original"),
2813 desired_hash: hash_content(b"private-managed"),
2814 requires_admin: false,
2815 },
2816 )],
2817 );
2818 value.validate().unwrap();
2819 let encoded = toml::to_string(&value).unwrap();
2820 assert!(!encoded.contains("private-original"));
2821 assert!(!encoded.contains("private-managed"));
2822
2823 let requirements =
2824 value.permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
2825 assert!(requirements.uncomputable_codes.is_empty());
2826 for permission in [
2827 PermissionV1::Filesystem {
2828 access: FilesystemAccessV1::Write,
2829 path: format!("absolute:{}", destination.display()),
2830 },
2831 PermissionV1::Filesystem {
2832 access: FilesystemAccessV1::Remove,
2833 path: format!("absolute:{}", destination.display()),
2834 },
2835 PermissionV1::Filesystem {
2836 access: FilesystemAccessV1::Write,
2837 path: format!("absolute:{}", backup.display()),
2838 },
2839 ] {
2840 assert!(requirements.required.contains(&permission));
2841 }
2842 }
2843
2844 #[test]
2845 fn managed_update_is_payload_free_and_derives_transaction_path_effects() {
2846 let destination = PathBuf::from("/home/test/.config/demo/config");
2847 let rollback = managed_file_rollback_path(&destination);
2848 let value = ActionIrV1::new(
2849 "operation-update",
2850 vec![DeclarativeActionV1::update_managed_file(
2851 "action-update",
2852 "app/demo",
2853 "config",
2854 ManagedFileUpdateSpecV1 {
2855 destination: destination.clone(),
2856 previous_backup: Some(PathBuf::from(
2857 "/home/test/.config/demo/config.shine.bak",
2858 )),
2859 original_mode: Some(0o100600),
2860 original_hash: hash_content(b"private-previous-managed"),
2861 desired_hash: hash_content(b"private-next-managed"),
2862 requires_admin: false,
2863 },
2864 )],
2865 );
2866 value.validate().unwrap();
2867 let encoded = toml::to_string(&value).unwrap();
2868 assert!(!encoded.contains("private-previous-managed"));
2869 assert!(!encoded.contains("private-next-managed"));
2870
2871 let requirements =
2872 value.permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
2873 for (access, path) in [
2874 (FilesystemAccessV1::Write, destination.clone()),
2875 (FilesystemAccessV1::Remove, destination),
2876 (FilesystemAccessV1::Write, rollback.clone()),
2877 (FilesystemAccessV1::Remove, rollback),
2878 ] {
2879 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2880 access,
2881 path: format!("absolute:{}", path.display()),
2882 }));
2883 }
2884 }
2885
2886 #[test]
2887 fn managed_remove_is_payload_free_and_derives_transaction_path_effects() {
2888 let destination = PathBuf::from("/home/test/.config/demo/config");
2889 let rollback = managed_file_rollback_path(&destination);
2890 let value = ActionIrV1::new(
2891 "operation-remove",
2892 vec![DeclarativeActionV1::remove_managed_file(
2893 "action-remove",
2894 "app/demo",
2895 "config",
2896 ManagedFileRemoveSpecV1 {
2897 destination: destination.clone(),
2898 original_mode: Some(0o100600),
2899 original_hash: hash_content(b"private-managed"),
2900 uses_env: true,
2901 requires_admin: true,
2902 },
2903 )],
2904 );
2905 value.validate().unwrap();
2906 let encoded = toml::to_string(&value).unwrap();
2907 assert!(!encoded.contains("private-managed"));
2908
2909 let requirements =
2910 value.permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
2911 for (access, path) in [
2912 (FilesystemAccessV1::Remove, destination),
2913 (FilesystemAccessV1::Write, rollback.clone()),
2914 (FilesystemAccessV1::Remove, rollback),
2915 ] {
2916 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2917 access,
2918 path: format!("absolute:{}", path.display()),
2919 }));
2920 }
2921 assert!(requirements.required.contains(&PermissionV1::Administrator));
2922 }
2923
2924 #[test]
2925 fn backup_restoring_remove_is_payload_free_and_derives_all_path_effects() {
2926 let destination = PathBuf::from("/home/test/.config/demo/config");
2927 let backup = crate::install::backup_path(&destination);
2928 let rollback = managed_file_rollback_path(&destination);
2929 let value = ActionIrV1::new(
2930 "operation-remove-with-backup",
2931 vec![DeclarativeActionV1::remove_managed_file_with_backup(
2932 "action-remove-with-backup",
2933 "app/demo",
2934 "config",
2935 ManagedFileRemoveWithBackupSpecV1 {
2936 destination: destination.clone(),
2937 backup: backup.clone(),
2938 managed_mode: Some(0o100600),
2939 managed_hash: hash_content(b"private-managed"),
2940 backup_mode: Some(0o100640),
2941 backup_hash: hash_content(b"private-user-original"),
2942 uses_env: true,
2943 requires_admin: false,
2944 },
2945 )],
2946 );
2947 value.validate().unwrap();
2948 let encoded = toml::to_string(&value).unwrap();
2949 assert!(!encoded.contains("private-managed"));
2950 assert!(!encoded.contains("private-user-original"));
2951
2952 let requirements =
2953 value.permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
2954 for (access, path) in [
2955 (FilesystemAccessV1::Write, destination.clone()),
2956 (FilesystemAccessV1::Remove, destination),
2957 (FilesystemAccessV1::Remove, backup),
2958 (FilesystemAccessV1::Write, rollback.clone()),
2959 (FilesystemAccessV1::Remove, rollback),
2960 ] {
2961 assert!(requirements.required.contains(&PermissionV1::Filesystem {
2962 access,
2963 path: format!("absolute:{}", path.display()),
2964 }));
2965 }
2966 }
2967
2968 #[test]
2969 fn forced_remove_is_distinct_payload_free_and_derives_all_path_effects() {
2970 let destination = PathBuf::from("/home/test/.config/demo/config");
2971 let backup = crate::install::backup_path(&destination);
2972 let rollback = managed_file_rollback_path(&destination);
2973 let value = ActionIrV1::new(
2974 "operation-force-remove",
2975 vec![DeclarativeActionV1::force_remove_managed_file(
2976 "action-force-remove",
2977 "app/demo",
2978 "config",
2979 ForcedManagedFileRemoveSpecV1 {
2980 destination: destination.clone(),
2981 persistent_backup: Some(ForcedManagedFileBackupV1 {
2982 path: backup.clone(),
2983 mode: Some(0o100640),
2984 hash: hash_content(b"private-user-original"),
2985 }),
2986 receipt_hash: hash_content(b"previous-managed"),
2987 current_mode: Some(0o100600),
2988 current_hash: hash_content(b"private-user-modification"),
2989 uses_env: true,
2990 requires_admin: false,
2991 },
2992 )],
2993 );
2994 value.validate().unwrap();
2995 let encoded = toml::to_string(&value).unwrap();
2996 assert!(!encoded.contains("previous-managed"));
2997 assert!(!encoded.contains("private-user-modification"));
2998 assert!(!encoded.contains("private-user-original"));
2999
3000 let requirements =
3001 value.permission_requirements(|path| format!("absolute:{}", path.to_string_lossy()));
3002 for (access, path) in [
3003 (FilesystemAccessV1::Write, destination.clone()),
3004 (FilesystemAccessV1::Remove, destination),
3005 (FilesystemAccessV1::Remove, backup),
3006 (FilesystemAccessV1::Write, rollback.clone()),
3007 (FilesystemAccessV1::Remove, rollback),
3008 ] {
3009 assert!(requirements.required.contains(&PermissionV1::Filesystem {
3010 access,
3011 path: format!("absolute:{}", path.display()),
3012 }));
3013 }
3014 }
3015
3016 #[test]
3017 fn opaque_execution_fails_permission_derivation_closed() {
3018 let value = ActionIrV1::new(
3019 "operation-opaque",
3020 vec![DeclarativeActionV1 {
3021 action_id: "opaque-1".to_string(),
3022 target: "app/demo".to_string(),
3023 resource: "hook:0".to_string(),
3024 kind: ActionKindV1::OpaqueExecution {
3025 capability: "app-hook".to_string(),
3026 provenance: ActionProvenanceV1::External,
3027 requires_administrator: false,
3028 },
3029 rollback: RollbackSupportV1::Unsupported {
3030 reason_code: "opaque_action_not_reversible".to_string(),
3031 },
3032 }],
3033 );
3034 value.validate().unwrap();
3035 assert_eq!(
3036 value
3037 .permission_requirements(|_| "unused".to_string())
3038 .uncomputable_codes,
3039 BTreeSet::from(["opaque_action_permissions_uncomputable".to_string()])
3040 );
3041 }
3042}