Skip to main content

shine_core/
action.rs

1//! Versioned executable action contracts.
2//!
3//! Security [`crate::plan::PlanV1`] values remain payload-free review
4//! descriptions. An action IR is a separate, Core-owned execution contract
5//! created only after planning. It may contain resolved runtime paths and
6//! content identities, but never managed file bytes, environment values,
7//! secret plaintext, or raw command arguments.
8
9use 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    /// Derive the exact filesystem capabilities required by the executable
55    /// actions. Infrastructure permissions for journal persistence are added
56    /// by the runtime planner, not hidden inside this derivation.
57    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
2269/// Same-directory, transaction-owned material used only while replacing an
2270/// existing managed file. It is distinct from the persistent `.shine.bak`
2271/// that preserves a pre-install user file.
2272pub 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}