1use crate::action::{ActionIrV1, ActionKindV1};
2use crate::env::EnvVarSpec;
3use crate::install::file_ops::{
4 InstallOutcome, UninstallOutcome, install_bytes_with_host, uninstall_entry_with_host,
5};
6use crate::install::{AppEntry, AppInstallStrategy, AppManifest, hash_content};
7use crate::lifecycle::{
8 LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1, LifecycleResultV1, LifecycleStatus,
9};
10use crate::permission::PermissionDeclarationV1;
11use crate::plan::{PermissionV1, PlanActionV1, PlanApprovalV1, PlanV1};
12use crate::runtime::{
13 AppFileInspection, CoreRuntime, FileSystemHost, InspectionChange, InspectionFileStatus,
14 PrivilegedFileSystemHost, ProcessHost, ProcessIo, ProcessRequest, RuntimeEvent,
15 RuntimeInteraction, RuntimeObserver,
16};
17use crate::trust::TrustCapabilityV1;
18use anyhow::{Context, Result, bail};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::Path;
22use std::path::PathBuf;
23use std::time::Duration;
24
25const GENERATOR_TIMEOUT: Duration = Duration::from_secs(30);
26const GENERATOR_STDOUT_LIMIT: usize = 8 * 1024 * 1024;
27const GENERATOR_STDERR_LIMIT: usize = 64 * 1024;
28
29#[derive(Debug, Clone)]
30pub struct AppCategory {
31 pub name: String,
32 pub description: Option<String>,
33 pub destination_root: Option<String>,
34 pub files: Vec<AppFile>,
35 pub list_mode: AppListMode,
36 pub post_upgrade: Vec<AppHook>,
37 pub post_install: Vec<AppHook>,
38 pub uses_metadata: bool,
39 pub has_explicit_files: bool,
40 pub artifact: Option<AppArtifact>,
41 pub permissions: Option<PermissionDeclarationV1>,
42 pub metadata_schema_version: u32,
44 pub metadata_is_overlay: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum AppListMode {
50 Category,
51 Files,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AppHook {
56 pub command: String,
57 pub args: Vec<String>,
58 pub show_output: bool,
59 pub env: Vec<EnvVarSpec>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum ArtifactRuntime {
64 #[default]
65 Native,
66 Bun,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct AppArtifact {
71 pub script: String,
72 pub teardown: Option<String>,
73 pub runtime: ArtifactRuntime,
74 pub env: Vec<EnvVarSpec>,
75}
76
77#[derive(Debug, Clone)]
78pub struct AppFile {
79 pub source_rel: PathBuf,
80 pub target_rel: PathBuf,
81 pub destination_root: Option<AppDestinationRoot>,
82 pub description: Option<String>,
83 pub display_name: Option<String>,
84 pub legacy_dest_annotation: Option<String>,
85 pub transforms: Vec<String>,
86 pub install_strategy: AppInstallStrategy,
87 pub requires_admin: bool,
88 pub restart_hint: Option<String>,
89 pub generator: Option<AppGenerator>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum AppDestinationRoot {
94 Path(String),
95 DataDir(PathBuf),
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct AppGenerator {
100 pub script: PathBuf,
101 pub runtime: ArtifactRuntime,
102 pub env: Vec<EnvVarSpec>,
103 pub when_env: String,
104 pub auto: bool,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum AppHookPhase {
109 PostInstall,
110 PostUpgrade,
111}
112
113#[derive(Clone, Debug)]
114pub struct AppHookRequest {
115 pub categories: Vec<AppCategory>,
116 pub changed: BTreeSet<String>,
117 pub phase: AppHookPhase,
118 pub show_success: bool,
119}
120
121#[derive(Clone, Debug)]
122pub struct AppGeneratorRequest {
123 pub category: String,
124 pub source: String,
125 pub generator: AppGenerator,
126 pub explicit: bool,
127}
128
129#[derive(Clone, Debug, Default)]
130pub struct AppInspectionOptions {
131 pub run_generators: bool,
134 pub categories: Vec<String>,
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum AppArtifactAction {
140 Apply,
141 Remove,
142}
143
144#[derive(Clone, Debug)]
145pub struct AppArtifactRequest {
146 pub category: String,
147 pub artifact: AppArtifact,
148 pub action: AppArtifactAction,
149 pub implicit: bool,
150 pub dry_run: bool,
151}
152
153#[derive(Clone, Debug)]
154pub struct AppCacheRequest {
155 pub prefix: String,
156 pub dry_run: bool,
157 pub remove: bool,
158 pub purge: bool,
159 pub overwrite: bool,
160}
161
162#[derive(Clone, Debug, Default, Eq, PartialEq)]
163pub struct AppHookReport {
164 pub outcomes: Vec<LifecycleOutcomeV1>,
165 pub notes: Vec<String>,
166}
167
168#[derive(Clone, Debug, Default, Eq, PartialEq)]
171pub struct AppLifecycleRequest {
172 pub target: Option<String>,
173 pub dry_run: bool,
174 pub force: bool,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq)]
178pub struct AppUninstallLifecycleRequest {
179 pub target: Option<String>,
180 pub dry_run: bool,
181 pub force: bool,
182 pub purge: bool,
183}
184
185#[derive(Clone, Debug, Default, Eq, PartialEq)]
186pub struct AppRefreshRequest {
187 pub category: String,
188 pub file: Option<PathBuf>,
189 pub force: bool,
190}
191
192#[derive(Clone, Debug, Default, Eq, PartialEq)]
193pub struct AppUpgradeRequest {
194 pub category: Option<String>,
195 pub prune_stale: bool,
196 pub prompt_stale: bool,
197 pub show_hook_success: bool,
198}
199
200#[derive(Clone, Debug)]
201pub struct AppUpgradeLifecycleReport {
202 pub files: Vec<AppFileLifecycleReport>,
203 pub updated_categories: Vec<String>,
204 pub skipped: usize,
205 pub failed: usize,
206 pub user_modified: usize,
207 pub restart_hints: BTreeSet<String>,
208 pub lifecycle: LifecycleResultV1,
209}
210
211#[derive(Clone, Debug)]
212pub struct AppFileLifecycleReport {
213 pub category: String,
214 pub source: PathBuf,
215 pub destination: PathBuf,
216 pub transforms: Vec<String>,
217 pub backup: Option<PathBuf>,
218 pub restart_hint: Option<String>,
219 pub generator_error: Option<String>,
220 pub error: Option<String>,
221 pub status: LifecycleStatus,
222 pub action: AppFileAction,
223}
224
225#[derive(Clone, Copy, Debug, Eq, PartialEq)]
226pub enum AppFileAction {
227 Installed,
228 BackedUp,
229 Unchanged,
230 PreviewInstall,
231 GeneratorPreserved,
232 Removed,
233 Restored,
234 ForceRemoved,
235 ForceRestored,
236 Missing,
237 UserModified,
238 PreviewRemove,
239 Failed,
240}
241
242#[derive(Clone, Debug)]
243pub struct AppLifecycleReport {
244 pub categories: Vec<AppCategory>,
245 pub files: Vec<AppFileLifecycleReport>,
246 pub lifecycle: LifecycleResultV1,
247}
248
249#[derive(Clone, Debug)]
250struct AssessedAppFile {
251 category: AppCategory,
252 file: AppFile,
253 destination: PathBuf,
254 content: Option<Vec<u8>>,
255 generator_error: Option<String>,
256 generator_diagnostic: Option<&'static str>,
257}
258
259#[derive(Clone, Copy, Debug, Default)]
260struct AppAssessmentOptions {
261 dry_run: bool,
262 run_generators: bool,
263 explicit_generators: bool,
264 preserve_generator_errors: bool,
265}
266
267struct ApprovedAppInstall<'a> {
268 plan: &'a PlanV1,
269 approval: &'a PlanApprovalV1,
270 action_irs: Vec<ActionIrV1>,
271}
272
273struct ApprovedAppUninstall<'a> {
274 plan: &'a PlanV1,
275 approval: &'a PlanApprovalV1,
276 action_irs: Vec<ActionIrV1>,
277}
278
279fn generator_inspection_status(diagnostic: Option<&'static str>) -> InspectionFileStatus {
280 match diagnostic {
281 Some("app_generator_trust_required") => InspectionFileStatus::GeneratorTrustRequired,
282 Some("app_generator_evaluation_failed") => InspectionFileStatus::GeneratorEvaluationFailed,
283 _ => InspectionFileStatus::GeneratorNotEvaluated,
284 }
285}
286
287impl AppLifecycleReport {
288 fn new(operation: LifecycleOperation, dry_run: bool, categories: Vec<AppCategory>) -> Self {
289 Self {
290 categories,
291 files: Vec::new(),
292 lifecycle: LifecycleResultV1::new(operation, dry_run),
293 }
294 }
295}
296
297impl<H> CoreRuntime<H>
298where
299 H: FileSystemHost + PrivilegedFileSystemHost + ProcessHost,
300{
301 pub(crate) async fn install_apps(
305 &self,
306 request: AppLifecycleRequest,
307 observer: &mut impl RuntimeObserver,
308 interaction: &mut impl RuntimeInteraction,
309 ) -> Result<AppLifecycleReport> {
310 self.install_apps_inner(request, None, observer, interaction)
311 .await
312 }
313
314 pub(crate) async fn install_apps_with_approved_actions(
315 &self,
316 request: AppLifecycleRequest,
317 plan: &PlanV1,
318 approval: &PlanApprovalV1,
319 action_irs: Vec<ActionIrV1>,
320 observer: &mut impl RuntimeObserver,
321 interaction: &mut impl RuntimeInteraction,
322 ) -> Result<AppLifecycleReport> {
323 self.install_apps_inner(
324 request,
325 Some(ApprovedAppInstall {
326 plan,
327 approval,
328 action_irs,
329 }),
330 observer,
331 interaction,
332 )
333 .await
334 }
335
336 async fn install_apps_inner(
337 &self,
338 request: AppLifecycleRequest,
339 mut approved: Option<ApprovedAppInstall<'_>>,
340 observer: &mut impl RuntimeObserver,
341 interaction: &mut impl RuntimeInteraction,
342 ) -> Result<AppLifecycleReport> {
343 let categories = self.app_categories(request.target.as_deref())?;
344 if let Some(target) = &request.target
345 && categories.is_empty()
346 {
347 bail!("app preset category not found: {target}");
348 }
349 validate_app_destinations(self, &categories)?;
350
351 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
354 let mut report = AppLifecycleReport::new(
355 LifecycleOperation::Install,
356 request.dry_run,
357 categories.clone(),
358 );
359 for category in &categories {
360 let cache = if self.context.is_external_presets {
361 LifecycleOutcomeV1::new(
362 format!("app/{}", category.name),
363 Some("preset-cache"),
364 LifecycleStatus::Skipped,
365 [LifecycleEffect::UserResourcePreserved],
366 )
367 .with_diagnostic_code("app_external_preset_cache_preserved")
368 } else {
369 self.reconcile_app_cache(AppCacheRequest {
370 prefix: format!("app/{}", category.name),
371 dry_run: request.dry_run,
372 remove: false,
373 purge: false,
374 overwrite: true,
376 })
377 .await?
378 };
379 report.lifecycle.push(cache);
380 }
381
382 let assessed = self
383 .assess_app_files(
384 &categories,
385 AppAssessmentOptions {
386 dry_run: request.dry_run,
387 run_generators: true,
388 explicit_generators: true,
389 preserve_generator_errors: false,
390 },
391 &manifest,
392 observer,
393 )
394 .await?;
395 let admin_count = assessed
396 .iter()
397 .filter(|assessment| assessment.file.requires_admin)
398 .count();
399 let admin_authorized = request.dry_run
400 || admin_count == 0
401 || self.context.running_as_admin
402 || interaction.authorize_admin(admin_count).await?;
403
404 let mut changed = BTreeSet::new();
405 for assessment in assessed {
406 let source = assessment.file.source_rel.clone();
407 let target = format!("app/{}", assessment.category.name);
408 if assessment.file.requires_admin && !admin_authorized {
409 report.lifecycle.push(
410 LifecycleOutcomeV1::new(
411 &target,
412 Some(source.display().to_string()),
413 LifecycleStatus::Failed,
414 [],
415 )
416 .with_diagnostic_code("app_admin_not_authorized"),
417 );
418 report.files.push(AppFileLifecycleReport {
419 category: assessment.category.name,
420 source,
421 destination: assessment.destination,
422 transforms: assessment.file.transforms,
423 backup: None,
424 restart_hint: assessment.file.restart_hint,
425 generator_error: None,
426 error: Some("administrator permission was not granted".to_string()),
427 status: LifecycleStatus::Failed,
428 action: AppFileAction::Failed,
429 });
430 continue;
431 }
432 if let Some(generator_error) = assessment.generator_error {
433 report.lifecycle.push(
434 LifecycleOutcomeV1::new(
435 &target,
436 Some(source.display().to_string()),
437 LifecycleStatus::Preserved,
438 [LifecycleEffect::ManagedResourcePreserved],
439 )
440 .with_diagnostic_code("app_generator_unavailable"),
441 );
442 report.files.push(AppFileLifecycleReport {
443 category: assessment.category.name,
444 source,
445 destination: assessment.destination,
446 transforms: assessment.file.transforms,
447 backup: None,
448 restart_hint: assessment.file.restart_hint,
449 generator_error: Some(generator_error),
450 error: None,
451 status: LifecycleStatus::Preserved,
452 action: AppFileAction::GeneratorPreserved,
453 });
454 continue;
455 }
456 let content = assessment
457 .content
458 .as_deref()
459 .context("App assessment did not contain install content")?;
460 let previous = manifest.find_by_dest(&assessment.destination).cloned();
461 let resource = source.display().to_string();
462 let action_index = approved.as_ref().and_then(|approved| {
463 approved.action_irs.iter().position(|ir| {
464 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
465 })
466 });
467 let mut journal_execution = None;
468 let outcome = if let Some(index) = action_index {
469 let approved = approved.as_mut().expect("approved App actions");
470 let action_ir = approved.action_irs.remove(index);
471 let is_update = matches!(
472 action_ir.actions.as_slice(),
473 [action] if matches!(action.kind, crate::action::ActionKindV1::UpdateManagedFile { .. })
474 );
475 let is_json = matches!(
476 action_ir.actions.as_slice(),
477 [action] if matches!(action.kind, crate::action::ActionKindV1::MergeManagedJson { .. })
478 );
479 let execution = if is_json {
480 self.execute_app_managed_json_merge_approved(
481 approved.plan,
482 approved.approval,
483 action_ir,
484 content,
485 )
486 .await?
487 } else if is_update {
488 self.execute_app_managed_file_update_approved(
489 approved.plan,
490 approved.approval,
491 action_ir,
492 content,
493 )
494 .await?
495 } else {
496 self.execute_app_managed_file_creation_approved(
497 approved.plan,
498 approved.approval,
499 action_ir,
500 content,
501 )
502 .await?
503 };
504 let backup = (!is_update).then(|| execution.backup.clone()).flatten();
505 journal_execution = Some(execution);
506 let installed_hash = desired_app_hash(&assessment.file, content)?;
507 Ok(match backup {
508 Some(backup) => InstallOutcome::BackedUpAndInstalled {
509 backup,
510 hash: installed_hash,
511 },
512 None => InstallOutcome::Installed {
513 hash: installed_hash,
514 },
515 })
516 } else {
517 self.install_app_content(
518 &assessment.file,
519 content,
520 &assessment.destination,
521 previous.is_some(),
522 request.dry_run,
523 request.force,
524 )
525 .await
526 };
527 let (status, effects, backup, error, action) = match outcome {
528 Ok(InstallOutcome::Installed { hash }) => {
529 if !request.dry_run {
530 manifest.upsert(app_entry(
531 &assessment,
532 hash,
533 previous.as_ref().and_then(|entry| entry.backup.clone()),
534 ));
535 if let Some(execution) = journal_execution {
536 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
540 self.commit_app_managed_file_operation(&execution).await?;
541 }
542 }
543 changed.insert(assessment.category.name.clone());
544 (
545 LifecycleStatus::Changed,
546 vec![
547 LifecycleEffect::ResourceWritten,
548 LifecycleEffect::ReceiptWritten,
549 ],
550 previous.and_then(|entry| entry.backup),
551 None,
552 AppFileAction::Installed,
553 )
554 }
555 Ok(InstallOutcome::BackedUpAndInstalled { backup, hash }) => {
556 if !request.dry_run {
557 manifest.upsert(app_entry(&assessment, hash, Some(backup.clone())));
558 if let Some(execution) = journal_execution {
559 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
560 self.commit_app_managed_file_operation(&execution).await?;
561 }
562 }
563 changed.insert(assessment.category.name.clone());
564 (
565 LifecycleStatus::Changed,
566 vec![
567 LifecycleEffect::BackupCreated,
568 LifecycleEffect::ResourceWritten,
569 LifecycleEffect::ReceiptWritten,
570 ],
571 Some(backup),
572 None,
573 AppFileAction::BackedUp,
574 )
575 }
576 Ok(InstallOutcome::AlreadyManaged) => (
577 LifecycleStatus::Unchanged,
578 Vec::new(),
579 previous.and_then(|entry| entry.backup),
580 None,
581 AppFileAction::Unchanged,
582 ),
583 Ok(InstallOutcome::DryRun) => (
584 LifecycleStatus::Previewed,
585 vec![
586 LifecycleEffect::ResourceWritePreviewed,
587 LifecycleEffect::ReceiptWritePreviewed,
588 ],
589 previous.and_then(|entry| entry.backup),
590 None,
591 AppFileAction::PreviewInstall,
592 ),
593 Err(error) => (
594 LifecycleStatus::Failed,
595 Vec::new(),
596 previous.and_then(|entry| entry.backup),
597 Some(format!("{error:#}")),
598 AppFileAction::Failed,
599 ),
600 };
601 let mut lifecycle = LifecycleOutcomeV1::new(
602 target,
603 Some(source.display().to_string()),
604 status,
605 effects,
606 );
607 if error.is_some() {
608 lifecycle = lifecycle.with_diagnostic_code("app_install_failed");
609 }
610 report.lifecycle.push(lifecycle);
611 report.files.push(AppFileLifecycleReport {
612 category: assessment.category.name,
613 source,
614 destination: assessment.destination,
615 transforms: assessment.file.transforms,
616 backup,
617 restart_hint: assessment.file.restart_hint,
618 generator_error: None,
619 error,
620 status,
621 action,
622 });
623 }
624 if let Some(approved) = approved
625 && !approved.action_irs.is_empty()
626 {
627 bail!("approved App creation actions were not consumed by lifecycle execution");
628 }
629 if !request.dry_run {
630 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
631 let hooks = self
632 .run_app_hooks(
633 AppHookRequest {
634 categories,
635 changed,
636 phase: AppHookPhase::PostInstall,
637 show_success: true,
638 },
639 observer,
640 )
641 .await;
642 report.lifecycle.outcomes.extend(hooks.outcomes);
643 }
644 Ok(report)
645 }
646
647 pub(crate) async fn uninstall_apps(
650 &self,
651 request: AppUninstallLifecycleRequest,
652 observer: &mut impl RuntimeObserver,
653 interaction: &mut impl RuntimeInteraction,
654 ) -> Result<AppLifecycleReport> {
655 self.uninstall_apps_inner(request, None, observer, interaction)
656 .await
657 }
658
659 pub(crate) async fn uninstall_apps_with_approved_actions(
660 &self,
661 request: AppUninstallLifecycleRequest,
662 plan: &PlanV1,
663 approval: &PlanApprovalV1,
664 action_irs: Vec<ActionIrV1>,
665 observer: &mut impl RuntimeObserver,
666 interaction: &mut impl RuntimeInteraction,
667 ) -> Result<AppLifecycleReport> {
668 self.uninstall_apps_inner(
669 request,
670 Some(ApprovedAppUninstall {
671 plan,
672 approval,
673 action_irs,
674 }),
675 observer,
676 interaction,
677 )
678 .await
679 }
680
681 async fn uninstall_apps_inner(
682 &self,
683 request: AppUninstallLifecycleRequest,
684 mut approved: Option<ApprovedAppUninstall<'_>>,
685 observer: &mut impl RuntimeObserver,
686 interaction: &mut impl RuntimeInteraction,
687 ) -> Result<AppLifecycleReport> {
688 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
689 let target_destinations = if let Some(target) = &request.target {
690 self.app_categories(Some(target))?
691 .iter()
692 .flat_map(|category| {
693 category
694 .files
695 .iter()
696 .filter_map(|file| self.app_destination(category, file).ok())
697 })
698 .collect::<BTreeSet<_>>()
699 } else {
700 BTreeSet::new()
701 };
702 let selected = manifest
703 .entries
704 .iter()
705 .filter(|entry| {
706 request.target.as_ref().is_none_or(|category| {
707 entry.source.starts_with(&format!("app/{category}/"))
708 || target_destinations.contains(&entry.destination)
709 })
710 })
711 .cloned()
712 .collect::<Vec<_>>();
713 let category_names = selected
714 .iter()
715 .filter_map(|entry| entry.source.split('/').nth(1).map(str::to_string))
716 .chain(request.target.iter().cloned())
717 .collect::<BTreeSet<_>>();
718 let categories = self
719 .app_categories(None)?
720 .into_iter()
721 .filter(|category| category_names.contains(&category.name))
722 .collect::<Vec<_>>();
723 let mut report = AppLifecycleReport::new(
724 LifecycleOperation::Uninstall,
725 request.dry_run,
726 categories.clone(),
727 );
728 if request.target.is_some() && selected.is_empty() {
729 return Ok(report);
730 }
731
732 let admin_count = if let Some(approved) = &approved {
733 if approved
734 .plan
735 .permissions
736 .required
737 .contains(&PermissionV1::Administrator)
738 {
739 selected
740 .iter()
741 .filter(|entry| {
742 if !entry.requires_admin {
743 return false;
744 }
745 let Some((category, resource)) = app_source_parts(&entry.source) else {
746 return false;
747 };
748 approved.plan.steps.iter().any(|step| {
749 step.target == format!("app/{category}")
750 && step.resource.as_deref() == Some(resource)
751 && step.action == PlanActionV1::Remove
752 })
753 })
754 .count()
755 } else {
756 0
757 }
758 } else {
759 selected.iter().filter(|entry| entry.requires_admin).count()
760 };
761 let admin_authorized = request.dry_run
762 || admin_count == 0
763 || self.context.running_as_admin
764 || interaction.authorize_admin(admin_count).await?;
765
766 for category in &categories {
769 if let Some(artifact) = category.artifact.clone()
770 && artifact.teardown.is_some()
771 {
772 let outcome = self
773 .run_app_artifact(
774 AppArtifactRequest {
775 category: category.name.clone(),
776 artifact,
777 action: AppArtifactAction::Remove,
778 implicit: true,
779 dry_run: request.dry_run,
780 },
781 observer,
782 )
783 .await
784 .unwrap_or_else(|error| {
785 observer.emit(RuntimeEvent::Warning {
786 code: "app_artifact_teardown_failed",
787 target: Some(format!("app/{}", category.name)),
788 detail: format!("{error:#}"),
789 });
790 LifecycleOutcomeV1::new(
791 format!("app/{}", category.name),
792 Some("artifact:teardown"),
793 LifecycleStatus::Failed,
794 [],
795 )
796 .with_diagnostic_code("app_teardown_setup_failed")
797 });
798 report.lifecycle.push(outcome);
799 }
800 }
801
802 for entry in selected {
803 let category = entry.source.split('/').nth(1).unwrap_or("app").to_string();
804 let source = PathBuf::from(
805 entry
806 .source
807 .splitn(3, '/')
808 .nth(2)
809 .unwrap_or(entry.source.as_str()),
810 );
811 if entry.requires_admin && !admin_authorized {
812 report.lifecycle.push(
813 LifecycleOutcomeV1::new(
814 format!("app/{category}"),
815 Some(source.display().to_string()),
816 LifecycleStatus::Failed,
817 [],
818 )
819 .with_diagnostic_code("app_admin_not_authorized"),
820 );
821 report.files.push(AppFileLifecycleReport {
822 category,
823 source,
824 destination: entry.destination,
825 transforms: Vec::new(),
826 backup: entry.backup,
827 restart_hint: None,
828 generator_error: None,
829 error: Some("administrator permission was not granted".to_string()),
830 status: LifecycleStatus::Failed,
831 action: AppFileAction::Failed,
832 });
833 continue;
834 }
835 let target = format!("app/{category}");
836 let resource = source.display().to_string();
837 let action_index = approved.as_ref().and_then(|approved| {
838 approved.action_irs.iter().position(|ir| {
839 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
840 })
841 });
842 let mut journal_execution = None;
843 let outcome = if let Some(index) = action_index {
844 let approved = approved.as_mut().expect("approved App uninstall actions");
845 let action_ir = approved.action_irs.remove(index);
846 let is_json = matches!(
847 action_ir.actions.as_slice(),
848 [action] if matches!(action.kind, crate::action::ActionKindV1::RemoveManagedJson { .. })
849 );
850 let execution = if is_json {
851 self.execute_app_managed_json_removal_approved(
852 approved.plan,
853 approved.approval,
854 action_ir,
855 )
856 .await?
857 } else {
858 self.execute_app_managed_file_removal_approved(
859 approved.plan,
860 approved.approval,
861 action_ir,
862 )
863 .await?
864 };
865 let outcome = match (execution.forced, execution.backup.clone()) {
866 (false, None) => UninstallOutcome::Removed,
867 (false, Some(backup)) => UninstallOutcome::RestoredBackup { backup },
868 (true, None) => UninstallOutcome::ForceRemoved,
869 (true, Some(backup)) => UninstallOutcome::ForceRestoredBackup { backup },
870 };
871 journal_execution = Some(execution);
872 Ok(outcome)
873 } else {
874 self.uninstall_app_entry(&entry, request.dry_run, request.force)
875 .await
876 };
877 let (status, effects, remove_receipt, error, action) = match outcome {
878 Ok(UninstallOutcome::Removed) => (
879 LifecycleStatus::Changed,
880 vec![
881 LifecycleEffect::ResourceRemoved,
882 LifecycleEffect::ReceiptRemoved,
883 ],
884 true,
885 None,
886 AppFileAction::Removed,
887 ),
888 Ok(UninstallOutcome::ForceRemoved) => (
889 LifecycleStatus::Changed,
890 vec![
891 LifecycleEffect::UserModificationOverridden,
892 LifecycleEffect::ResourceRemoved,
893 LifecycleEffect::ReceiptRemoved,
894 ],
895 true,
896 None,
897 AppFileAction::ForceRemoved,
898 ),
899 Ok(UninstallOutcome::RestoredBackup { .. }) => (
900 LifecycleStatus::Changed,
901 vec![
902 LifecycleEffect::BackupRestored,
903 LifecycleEffect::ReceiptRemoved,
904 ],
905 true,
906 None,
907 AppFileAction::Restored,
908 ),
909 Ok(UninstallOutcome::ForceRestoredBackup { .. }) => (
910 LifecycleStatus::Changed,
911 vec![
912 LifecycleEffect::UserModificationOverridden,
913 LifecycleEffect::BackupRestored,
914 LifecycleEffect::ReceiptRemoved,
915 ],
916 true,
917 None,
918 AppFileAction::ForceRestored,
919 ),
920 Ok(UninstallOutcome::NotFound) => (
921 LifecycleStatus::Changed,
922 vec![LifecycleEffect::ReceiptRemoved],
923 true,
924 None,
925 AppFileAction::Missing,
926 ),
927 Ok(UninstallOutcome::UserModified) => (
928 LifecycleStatus::Preserved,
929 vec![LifecycleEffect::UserResourcePreserved],
930 false,
931 None,
932 AppFileAction::UserModified,
933 ),
934 Ok(UninstallOutcome::DryRun) => (
935 LifecycleStatus::Previewed,
936 vec![
937 LifecycleEffect::ResourceRemovePreviewed,
938 LifecycleEffect::ReceiptRemovePreviewed,
939 ],
940 false,
941 None,
942 AppFileAction::PreviewRemove,
943 ),
944 Err(error) => (
945 LifecycleStatus::Failed,
946 Vec::new(),
947 false,
948 Some(format!("{error:#}")),
949 AppFileAction::Failed,
950 ),
951 };
952 if remove_receipt {
953 manifest.remove_by_dest(&entry.destination);
954 if let Some(execution) = journal_execution {
955 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
956 self.commit_app_managed_file_operation(&execution).await?;
957 }
958 }
959 let mut lifecycle = LifecycleOutcomeV1::new(
960 format!("app/{category}"),
961 Some(source.display().to_string()),
962 status,
963 effects,
964 );
965 if error.is_some() {
966 lifecycle = lifecycle.with_diagnostic_code("app_uninstall_failed");
967 }
968 report.lifecycle.push(lifecycle);
969 report.files.push(AppFileLifecycleReport {
970 category,
971 source,
972 destination: entry.destination,
973 transforms: Vec::new(),
974 backup: entry.backup,
975 restart_hint: None,
976 generator_error: None,
977 error,
978 status,
979 action,
980 });
981 }
982 if !request.dry_run {
983 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
984 }
985
986 if !self.context.is_external_presets {
987 let cache_targets = if request.purge && request.target.is_none() {
988 vec!["app".to_string()]
989 } else if let Some(category) = &request.target {
990 vec![format!("app/{category}")]
991 } else {
992 category_names
993 .into_iter()
994 .map(|category| format!("app/{category}"))
995 .collect()
996 };
997 for target in cache_targets {
998 report.lifecycle.push(
999 self.reconcile_app_cache(AppCacheRequest {
1000 prefix: target,
1001 dry_run: request.dry_run,
1002 remove: true,
1003 purge: request.purge,
1004 overwrite: false,
1005 })
1006 .await?,
1007 );
1008 }
1009 } else if request.purge {
1010 report.lifecycle.push(
1011 LifecycleOutcomeV1::new(
1012 request
1013 .target
1014 .as_ref()
1015 .map(|category| format!("app/{category}"))
1016 .unwrap_or_else(|| "app".to_string()),
1017 Some("purge"),
1018 LifecycleStatus::Skipped,
1019 [LifecycleEffect::UserResourcePreserved],
1020 )
1021 .with_diagnostic_code("app_external_preset_cache_preserved"),
1022 );
1023 }
1024 Ok(report)
1025 }
1026
1027 pub(crate) async fn refresh_app_generators(
1030 &self,
1031 request: AppRefreshRequest,
1032 observer: &mut impl RuntimeObserver,
1033 interaction: &mut impl RuntimeInteraction,
1034 ) -> Result<AppLifecycleReport> {
1035 let categories = self.app_categories(Some(&request.category))?;
1036 let category = categories
1037 .first()
1038 .cloned()
1039 .with_context(|| format!("app preset category not found: {}", request.category))?;
1040 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1041 let candidates = if let Some(selector) = &request.file {
1042 let file = category
1043 .files
1044 .iter()
1045 .find(|file| &file.source_rel == selector)
1046 .with_context(|| {
1047 format!(
1048 "app '{}' file not found: {}",
1049 request.category,
1050 selector.display()
1051 )
1052 })?;
1053 if file.generator.is_none() {
1054 bail!(
1055 "app '{}' file is not generated: {}",
1056 request.category,
1057 selector.display()
1058 );
1059 }
1060 vec![file.clone()]
1061 } else {
1062 category
1063 .files
1064 .iter()
1065 .filter(|file| file.generator.is_some())
1066 .cloned()
1067 .collect::<Vec<_>>()
1068 };
1069 if candidates.is_empty() {
1070 bail!("app '{}' has no generated files", request.category);
1071 }
1072
1073 let mut selected = Vec::new();
1074 for file in candidates {
1075 let destination = self.app_destination(&category, &file)?;
1076 let Some(entry) = manifest.find_by_dest(&destination).cloned() else {
1077 if request.file.is_some() {
1078 bail!(
1079 "app '{}' generated file is not installed: {}",
1080 request.category,
1081 file.source_rel.display()
1082 );
1083 }
1084 continue;
1085 };
1086 selected.push((file, destination, entry));
1087 }
1088 if selected.is_empty() {
1089 bail!(
1090 "app '{}' has no installed generated files; run `shine install app/{}` first",
1091 request.category,
1092 request.category
1093 );
1094 }
1095
1096 let admin_count = selected
1097 .iter()
1098 .filter(|(file, _, _)| file.requires_admin)
1099 .count();
1100 if admin_count > 0
1101 && !self.context.running_as_admin
1102 && !interaction.authorize_admin(admin_count).await?
1103 {
1104 bail!("administrator permission was not granted");
1105 }
1106
1107 let mut report =
1108 AppLifecycleReport::new(LifecycleOperation::Update, false, vec![category.clone()]);
1109 let mut changed = BTreeSet::new();
1110 for (file, destination, entry) in selected {
1111 let source = file.source_rel.clone();
1112 let generator = file.generator.clone().expect("selected generated App file");
1113 let result: Result<AppFileAction> = async {
1114 if !self.context.env.contains_key(&generator.when_env) {
1115 bail!(
1116 "app '{}' generator requires config env '{}'",
1117 request.category,
1118 generator.when_env
1119 );
1120 }
1121 let bytes = self
1122 .run_app_generator(
1123 AppGeneratorRequest {
1124 category: request.category.clone(),
1125 source: source.display().to_string(),
1126 generator,
1127 explicit: true,
1128 },
1129 observer,
1130 )
1131 .await?
1132 .context("explicit App generator produced no content")?;
1133 let content =
1134 crate::install::transforms::apply(&file.transforms, &bytes, &self.context.env)?;
1135 let desired_hash = desired_app_hash(&file, &content)?;
1136 let current_hash = match self.host.read(&destination).await {
1137 Ok(bytes) => installed_app_hash(&file, &bytes)?,
1138 Err(error) if error.is_not_found() => None,
1139 Err(error) => {
1140 return Err(error.into_anyhow("reading generated App destination"));
1141 }
1142 };
1143 if current_hash == Some(entry.content_hash) && desired_hash == entry.content_hash {
1144 return Ok(AppFileAction::Unchanged);
1145 }
1146 if current_hash.is_some_and(|hash| hash != entry.content_hash) && !request.force {
1147 return Ok(AppFileAction::UserModified);
1148 }
1149 let outcome = self
1150 .install_app_content(&file, &content, &destination, true, false, true)
1151 .await?;
1152 let hash = match outcome {
1153 InstallOutcome::Installed { hash }
1154 | InstallOutcome::BackedUpAndInstalled { hash, .. } => hash,
1155 InstallOutcome::AlreadyManaged => return Ok(AppFileAction::Unchanged),
1156 InstallOutcome::DryRun => unreachable!("refresh is never dry-run"),
1157 };
1158 manifest.upsert(AppEntry {
1159 source: entry.source.clone(),
1160 destination: destination.clone(),
1161 backup: entry.backup.clone(),
1162 content_hash: hash,
1163 install_strategy: file.install_strategy.clone(),
1164 uses_env: true,
1165 requires_admin: file.requires_admin,
1166 });
1167 Ok(AppFileAction::Installed)
1168 }
1169 .await;
1170 let (action, status, error) = match result {
1171 Ok(AppFileAction::Installed) => {
1172 changed.insert(request.category.clone());
1173 (AppFileAction::Installed, LifecycleStatus::Changed, None)
1174 }
1175 Ok(AppFileAction::Unchanged) => {
1176 (AppFileAction::Unchanged, LifecycleStatus::Unchanged, None)
1177 }
1178 Ok(AppFileAction::UserModified) => (
1179 AppFileAction::UserModified,
1180 LifecycleStatus::Preserved,
1181 None,
1182 ),
1183 Ok(action) => (action, LifecycleStatus::Unchanged, None),
1184 Err(error) => (
1185 AppFileAction::Failed,
1186 LifecycleStatus::Failed,
1187 Some(format!("{error:#}")),
1188 ),
1189 };
1190 let effects = match status {
1191 LifecycleStatus::Changed => {
1192 vec![
1193 LifecycleEffect::ResourceWritten,
1194 LifecycleEffect::ReceiptWritten,
1195 ]
1196 }
1197 LifecycleStatus::Preserved => vec![LifecycleEffect::UserResourcePreserved],
1198 _ => Vec::new(),
1199 };
1200 let mut outcome = LifecycleOutcomeV1::new(
1201 format!("app/{}", request.category),
1202 Some(source.display().to_string()),
1203 status,
1204 effects,
1205 );
1206 if error.is_some() {
1207 outcome = outcome.with_diagnostic_code("app_refresh_failed");
1208 }
1209 report.lifecycle.push(outcome);
1210 report.files.push(AppFileLifecycleReport {
1211 category: request.category.clone(),
1212 source,
1213 destination,
1214 transforms: file.transforms,
1215 backup: entry.backup,
1216 restart_hint: file.restart_hint,
1217 generator_error: None,
1218 error,
1219 status,
1220 action,
1221 });
1222 }
1223 if !changed.is_empty() {
1224 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1225 let hooks = self
1226 .run_app_hooks(
1227 AppHookRequest {
1228 categories: vec![category],
1229 changed,
1230 phase: AppHookPhase::PostUpgrade,
1231 show_success: true,
1232 },
1233 observer,
1234 )
1235 .await;
1236 report.lifecycle.outcomes.extend(hooks.outcomes);
1237 }
1238 Ok(report)
1239 }
1240
1241 pub(crate) async fn upgrade_apps(
1245 &self,
1246 request: AppUpgradeRequest,
1247 plan: &PlanV1,
1248 approval: &PlanApprovalV1,
1249 action_irs: Vec<ActionIrV1>,
1250 observer: &mut impl RuntimeObserver,
1251 interaction: &mut impl RuntimeInteraction,
1252 ) -> Result<AppUpgradeLifecycleReport> {
1253 let mut approved = ApprovedAppInstall {
1254 plan,
1255 approval,
1256 action_irs,
1257 };
1258 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1259 let selected_entries = manifest
1260 .entries
1261 .iter()
1262 .filter(|entry| {
1263 request
1264 .category
1265 .as_ref()
1266 .is_none_or(|category| entry.source.starts_with(&format!("app/{category}/")))
1267 })
1268 .cloned()
1269 .collect::<Vec<_>>();
1270 if let Some(category) = &request.category
1271 && selected_entries.is_empty()
1272 {
1273 bail!("app preset is not installed: {category}");
1274 }
1275 let installed_categories = selected_entries
1276 .iter()
1277 .filter_map(|entry| {
1278 app_source_parts(&entry.source).map(|(category, _)| category.to_string())
1279 })
1280 .collect::<BTreeSet<_>>();
1281 let mut categories = Vec::new();
1282 for category in &installed_categories {
1283 let prefix = format!("app/{category}/");
1284 if !self
1285 .presets
1286 .files()
1287 .keys()
1288 .any(|logical| logical.starts_with(&prefix))
1289 {
1290 continue;
1291 }
1292 let mut loaded = self.app_categories(Some(category))?;
1293 categories.append(&mut loaded);
1294 if !self.context.is_external_presets {
1295 let _ = self
1296 .reconcile_app_cache(AppCacheRequest {
1297 prefix: format!("app/{category}"),
1298 dry_run: false,
1299 remove: false,
1300 purge: false,
1301 overwrite: true,
1302 })
1303 .await?;
1304 }
1305 }
1306 validate_app_destinations(self, &categories)?;
1307 let mut assessments = self
1308 .assess_app_files(
1309 &categories,
1310 AppAssessmentOptions {
1311 run_generators: true,
1312 ..AppAssessmentOptions::default()
1313 },
1314 &manifest,
1315 observer,
1316 )
1317 .await?
1318 .into_iter()
1319 .map(|assessment| {
1320 (
1321 format!(
1322 "app/{}/{}",
1323 assessment.category.name,
1324 assessment.file.source_rel.display()
1325 ),
1326 assessment,
1327 )
1328 })
1329 .collect::<BTreeMap<_, _>>();
1330 let additional_admin_count = approved
1331 .action_irs
1332 .iter()
1333 .filter(|ir| {
1334 ir.actions.iter().any(|action| {
1335 matches!(
1336 action.kind,
1337 ActionKindV1::RemoveManagedFile {
1338 requires_admin: true,
1339 ..
1340 } | ActionKindV1::RemoveManagedFileWithBackup {
1341 requires_admin: true,
1342 ..
1343 }
1344 ) || matches!(
1345 action.kind,
1346 ActionKindV1::RelocateManagedFile {
1347 previous_requires_admin: true,
1348 desired_requires_admin: false,
1349 previous_present: true,
1350 ..
1351 }
1352 )
1353 })
1354 })
1355 .count();
1356 let admin_count = assessments
1357 .values()
1358 .filter(|assessment| assessment.file.requires_admin)
1359 .count()
1360 + additional_admin_count;
1361 if admin_count > 0
1362 && !self.context.running_as_admin
1363 && !interaction.authorize_admin(admin_count).await?
1364 {
1365 bail!("administrator permission was not granted");
1366 }
1367
1368 let mut report = AppUpgradeLifecycleReport {
1369 files: Vec::new(),
1370 updated_categories: Vec::new(),
1371 skipped: 0,
1372 failed: 0,
1373 user_modified: 0,
1374 restart_hints: BTreeSet::new(),
1375 lifecycle: LifecycleResultV1::new(LifecycleOperation::Upgrade, false),
1376 };
1377 let mut changed = BTreeSet::new();
1378 for entry in selected_entries {
1379 let Some((category_name, file_rel)) = app_source_parts(&entry.source) else {
1380 report.skipped += 1;
1381 report.lifecycle.push(
1382 LifecycleOutcomeV1::new(
1383 "app/unknown",
1384 None::<String>,
1385 LifecycleStatus::Skipped,
1386 [],
1387 )
1388 .with_diagnostic_code("app_manifest_source_invalid"),
1389 );
1390 continue;
1391 };
1392 let assessment = assessments.remove(&entry.source);
1393 let Some(assessment) = assessment else {
1394 let should_remove = request.prune_stale
1395 || (request.prompt_stale && interaction.confirm("app_prune_stale", false)?);
1396 if !should_remove {
1397 report.skipped += 1;
1398 report.lifecycle.push(
1399 LifecycleOutcomeV1::new(
1400 format!("app/{category_name}"),
1401 Some(file_rel.to_string()),
1402 LifecycleStatus::Skipped,
1403 [],
1404 )
1405 .with_diagnostic_code("app_stale_source_preserved"),
1406 );
1407 continue;
1408 }
1409 let target = format!("app/{category_name}");
1410 let resource = file_rel.to_string();
1411 let action_index = approved.action_irs.iter().position(|ir| {
1412 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
1413 });
1414 let approved_remove = approved.plan.steps.iter().any(|step| {
1415 step.target == target
1416 && step.resource.as_deref() == Some(resource.as_str())
1417 && step.action == crate::plan::PlanActionV1::Remove
1418 && step
1419 .diagnostic_codes
1420 .contains(&"app_stale_source_pruned".to_string())
1421 });
1422 let mut journal_execution = None;
1423 let outcome = if let Some(index) = action_index {
1424 let action_ir = approved.action_irs.remove(index);
1425 let is_json = matches!(
1426 action_ir.actions.as_slice(),
1427 [action] if matches!(action.kind, ActionKindV1::RemoveManagedJson { .. })
1428 );
1429 let execution = if is_json {
1430 self.execute_app_managed_json_removal_approved(
1431 approved.plan,
1432 approved.approval,
1433 action_ir,
1434 )
1435 .await?
1436 } else {
1437 self.execute_app_managed_file_removal_approved(
1438 approved.plan,
1439 approved.approval,
1440 action_ir,
1441 )
1442 .await?
1443 };
1444 let outcome = match execution.backup.clone() {
1445 Some(backup) => UninstallOutcome::RestoredBackup { backup },
1446 None => UninstallOutcome::Removed,
1447 };
1448 journal_execution = Some(execution);
1449 outcome
1450 } else if approved_remove {
1451 match self.host.metadata(&entry.destination).await {
1452 Err(error) if error.is_not_found() => UninstallOutcome::NotFound,
1453 Ok(_) => {
1454 bail!("stale App removal destination changed after Plan approval")
1455 }
1456 Err(error) => {
1457 return Err(error.into_anyhow(
1458 "observing stale App removal destination after approval",
1459 ));
1460 }
1461 }
1462 } else {
1463 self.uninstall_app_entry(&entry, false, false).await?
1464 };
1465 let (status, effects, remove, action) = match outcome {
1466 UninstallOutcome::Removed => (
1467 LifecycleStatus::Changed,
1468 vec![
1469 LifecycleEffect::ResourceRemoved,
1470 LifecycleEffect::ReceiptRemoved,
1471 ],
1472 true,
1473 AppFileAction::Removed,
1474 ),
1475 UninstallOutcome::RestoredBackup { .. } => (
1476 LifecycleStatus::Changed,
1477 vec![
1478 LifecycleEffect::BackupRestored,
1479 LifecycleEffect::ReceiptRemoved,
1480 ],
1481 true,
1482 AppFileAction::Restored,
1483 ),
1484 UninstallOutcome::NotFound => (
1485 LifecycleStatus::Changed,
1486 vec![LifecycleEffect::ReceiptRemoved],
1487 true,
1488 AppFileAction::Missing,
1489 ),
1490 UninstallOutcome::UserModified => (
1491 LifecycleStatus::Preserved,
1492 vec![LifecycleEffect::UserResourcePreserved],
1493 false,
1494 AppFileAction::UserModified,
1495 ),
1496 UninstallOutcome::ForceRemoved
1497 | UninstallOutcome::ForceRestoredBackup { .. } => unreachable!(),
1498 UninstallOutcome::DryRun => unreachable!(),
1499 };
1500 if remove {
1501 manifest.remove_by_dest(&entry.destination);
1502 if let Some(execution) = journal_execution {
1503 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1504 self.commit_app_managed_file_operation(&execution).await?;
1505 }
1506 changed.insert(category_name.to_string());
1507 } else {
1508 report.user_modified += 1;
1509 report.skipped += 1;
1510 }
1511 report.lifecycle.push(LifecycleOutcomeV1::new(
1512 format!("app/{category_name}"),
1513 Some(file_rel.to_string()),
1514 status,
1515 effects,
1516 ));
1517 report.files.push(AppFileLifecycleReport {
1518 category: category_name.to_string(),
1519 source: PathBuf::from(file_rel),
1520 destination: entry.destination,
1521 transforms: Vec::new(),
1522 backup: entry.backup,
1523 restart_hint: None,
1524 generator_error: None,
1525 error: None,
1526 status,
1527 action,
1528 });
1529 continue;
1530 };
1531 if assessment
1532 .file
1533 .generator
1534 .as_ref()
1535 .is_some_and(|generator| !generator.auto)
1536 {
1537 report.skipped += 1;
1538 report.lifecycle.push(
1539 LifecycleOutcomeV1::new(
1540 format!("app/{category_name}"),
1541 Some(file_rel.to_string()),
1542 LifecycleStatus::Skipped,
1543 [],
1544 )
1545 .with_diagnostic_code("app_manual_refresh_required"),
1546 );
1547 continue;
1548 }
1549 if let Some(error) = assessment.generator_error.clone() {
1550 report.failed += 1;
1551 report.lifecycle.push(
1552 LifecycleOutcomeV1::new(
1553 format!("app/{category_name}"),
1554 Some(file_rel.to_string()),
1555 LifecycleStatus::Failed,
1556 [LifecycleEffect::ManagedResourcePreserved],
1557 )
1558 .with_diagnostic_code("app_generator_unavailable"),
1559 );
1560 report.files.push(app_upgrade_file_report(
1561 &assessment,
1562 LifecycleStatus::Failed,
1563 AppFileAction::GeneratorPreserved,
1564 Some(error),
1565 ));
1566 continue;
1567 }
1568 let content = assessment
1569 .content
1570 .as_deref()
1571 .context("missing App upgrade content")?;
1572 let desired_hash = desired_app_hash(&assessment.file, content)?;
1573 let desired_destination = assessment.destination.clone();
1574 let relocated = desired_destination != entry.destination;
1575 if relocated
1576 && (manifest.find_by_dest(&desired_destination).is_some()
1577 || self.host.metadata(&desired_destination).await.is_ok())
1578 {
1579 report.user_modified += 1;
1580 report.skipped += 1;
1581 report.lifecycle.push(
1582 LifecycleOutcomeV1::new(
1583 format!("app/{category_name}"),
1584 Some(file_rel.to_string()),
1585 LifecycleStatus::Conflict,
1586 [],
1587 )
1588 .with_diagnostic_code("app_destination_occupied"),
1589 );
1590 continue;
1591 }
1592 let current_hash = match self.host.read(&entry.destination).await {
1593 Ok(bytes) => installed_app_entry_hash(&entry, &bytes)?,
1594 Err(error) if error.is_not_found() => None,
1595 Err(error) => return Err(error.into_anyhow("reading installed App file")),
1596 };
1597 if current_hash.is_some_and(|hash| hash != entry.content_hash) {
1598 report.user_modified += 1;
1599 report.skipped += 1;
1600 report.lifecycle.push(LifecycleOutcomeV1::new(
1601 format!("app/{category_name}"),
1602 Some(file_rel.to_string()),
1603 LifecycleStatus::Preserved,
1604 [LifecycleEffect::UserResourcePreserved],
1605 ));
1606 report.files.push(app_upgrade_file_report(
1607 &assessment,
1608 LifecycleStatus::Preserved,
1609 AppFileAction::UserModified,
1610 None,
1611 ));
1612 continue;
1613 }
1614 if !relocated
1615 && current_hash == Some(entry.content_hash)
1616 && desired_hash == entry.content_hash
1617 {
1618 report.skipped += 1;
1619 report.lifecycle.push(LifecycleOutcomeV1::new(
1620 format!("app/{category_name}"),
1621 Some(file_rel.to_string()),
1622 LifecycleStatus::Unchanged,
1623 [],
1624 ));
1625 report.files.push(app_upgrade_file_report(
1626 &assessment,
1627 LifecycleStatus::Unchanged,
1628 AppFileAction::Unchanged,
1629 None,
1630 ));
1631 continue;
1632 }
1633 let target = format!("app/{category_name}");
1634 let resource = file_rel.to_string();
1635 let action_index = approved.action_irs.iter().position(|ir| {
1636 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
1637 });
1638 let mut journal_execution = None;
1639 let mut journaled_relocation = false;
1640 let installed = if let Some(index) = action_index {
1641 let action_ir = approved.action_irs.remove(index);
1642 let is_json = matches!(
1643 action_ir.actions.as_slice(),
1644 [action] if matches!(action.kind, crate::action::ActionKindV1::MergeManagedJson { .. })
1645 );
1646 let is_relocation = matches!(
1647 action_ir.actions.as_slice(),
1648 [action] if matches!(
1649 action.kind,
1650 crate::action::ActionKindV1::RelocateManagedFile { .. }
1651 | crate::action::ActionKindV1::RelocateManagedJson { .. }
1652 )
1653 );
1654 let execution = if is_relocation {
1655 journaled_relocation = true;
1656 if matches!(
1657 action_ir.actions.as_slice(),
1658 [action] if matches!(
1659 action.kind,
1660 crate::action::ActionKindV1::RelocateManagedJson { .. }
1661 )
1662 ) {
1663 self.execute_app_managed_json_relocation_approved(
1664 approved.plan,
1665 approved.approval,
1666 action_ir,
1667 content,
1668 )
1669 .await?
1670 } else {
1671 self.execute_app_managed_file_relocation_approved(
1672 approved.plan,
1673 approved.approval,
1674 action_ir,
1675 content,
1676 )
1677 .await?
1678 }
1679 } else if is_json {
1680 self.execute_app_managed_json_merge_approved(
1681 approved.plan,
1682 approved.approval,
1683 action_ir,
1684 content,
1685 )
1686 .await?
1687 } else {
1688 self.execute_app_managed_file_update_approved(
1689 approved.plan,
1690 approved.approval,
1691 action_ir,
1692 content,
1693 )
1694 .await?
1695 };
1696 journal_execution = Some(execution);
1697 Ok(InstallOutcome::Installed { hash: desired_hash })
1698 } else {
1699 self.install_app_content(
1700 &assessment.file,
1701 content,
1702 &desired_destination,
1703 !relocated,
1704 false,
1705 true,
1706 )
1707 .await
1708 };
1709 let hash = match installed {
1710 Ok(InstallOutcome::Installed { hash })
1711 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => hash,
1712 Ok(InstallOutcome::AlreadyManaged) => desired_hash,
1713 Ok(InstallOutcome::DryRun) => unreachable!(),
1714 Err(error) => {
1715 report.failed += 1;
1716 report.lifecycle.push(
1717 LifecycleOutcomeV1::new(
1718 format!("app/{category_name}"),
1719 Some(file_rel.to_string()),
1720 LifecycleStatus::Failed,
1721 [],
1722 )
1723 .with_diagnostic_code("app_upgrade_failed"),
1724 );
1725 report.files.push(app_upgrade_file_report(
1726 &assessment,
1727 LifecycleStatus::Failed,
1728 AppFileAction::Failed,
1729 Some(format!("{error:#}")),
1730 ));
1731 continue;
1732 }
1733 };
1734 if relocated && !journaled_relocation {
1735 match self.uninstall_app_entry(&entry, false, false).await {
1736 Ok(UninstallOutcome::Removed)
1737 | Ok(UninstallOutcome::RestoredBackup { .. })
1738 | Ok(UninstallOutcome::NotFound) => {
1739 manifest.remove_by_dest(&entry.destination);
1740 }
1741 _ => {
1742 let rollback = AppEntry {
1743 source: entry.source.clone(),
1744 destination: desired_destination.clone(),
1745 backup: None,
1746 content_hash: hash,
1747 install_strategy: assessment.file.install_strategy.clone(),
1748 uses_env: true,
1749 requires_admin: assessment.file.requires_admin,
1750 };
1751 let _ = self.uninstall_app_entry(&rollback, false, true).await;
1752 report.failed += 1;
1753 report.lifecycle.push(
1754 LifecycleOutcomeV1::new(
1755 format!("app/{category_name}"),
1756 Some(file_rel.to_string()),
1757 LifecycleStatus::Failed,
1758 [],
1759 )
1760 .with_diagnostic_code("app_relocation_rollback"),
1761 );
1762 continue;
1763 }
1764 }
1765 }
1766 manifest.upsert(app_entry(
1767 &assessment,
1768 hash,
1769 if relocated {
1770 None
1771 } else {
1772 entry.backup.clone()
1773 },
1774 ));
1775 if let Some(execution) = journal_execution {
1776 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1777 self.commit_app_managed_file_operation(&execution).await?;
1778 }
1779 changed.insert(category_name.to_string());
1780 if let Some(hint) = &assessment.file.restart_hint {
1781 report.restart_hints.insert(hint.clone());
1782 }
1783 let mut effects = vec![
1784 LifecycleEffect::ResourceWritten,
1785 LifecycleEffect::ReceiptWritten,
1786 ];
1787 if relocated {
1788 effects.push(LifecycleEffect::ResourceRemoved);
1789 }
1790 report.lifecycle.push(LifecycleOutcomeV1::new(
1791 format!("app/{category_name}"),
1792 Some(file_rel.to_string()),
1793 LifecycleStatus::Changed,
1794 effects,
1795 ));
1796 report.files.push(app_upgrade_file_report(
1797 &assessment,
1798 LifecycleStatus::Changed,
1799 AppFileAction::Installed,
1800 None,
1801 ));
1802 }
1803
1804 for (source, assessment) in assessments {
1807 if assessment
1808 .file
1809 .generator
1810 .as_ref()
1811 .is_some_and(|generator| !generator.auto)
1812 || manifest.find_by_source(&source).is_some()
1813 || manifest.find_by_dest(&assessment.destination).is_some()
1814 {
1815 continue;
1816 }
1817 if assessment.file.install_strategy.is_copy()
1818 && self.host.metadata(&assessment.destination).await.is_ok()
1819 {
1820 report.skipped += 1;
1821 report.lifecycle.push(
1822 LifecycleOutcomeV1::new(
1823 format!("app/{}", assessment.category.name),
1824 Some(assessment.file.source_rel.display().to_string()),
1825 LifecycleStatus::Conflict,
1826 [],
1827 )
1828 .with_diagnostic_code("app_destination_occupied"),
1829 );
1830 continue;
1831 }
1832 let Some(content) = assessment.content.as_deref() else {
1833 report.failed += 1;
1834 continue;
1835 };
1836 match self
1837 .install_app_content(
1838 &assessment.file,
1839 content,
1840 &assessment.destination,
1841 false,
1842 false,
1843 true,
1844 )
1845 .await
1846 {
1847 Ok(InstallOutcome::Installed { hash })
1848 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
1849 manifest.upsert(app_entry(&assessment, hash, None));
1850 changed.insert(assessment.category.name.clone());
1851 if let Some(hint) = &assessment.file.restart_hint {
1852 report.restart_hints.insert(hint.clone());
1853 }
1854 report.lifecycle.push(LifecycleOutcomeV1::new(
1855 format!("app/{}", assessment.category.name),
1856 Some(assessment.file.source_rel.display().to_string()),
1857 LifecycleStatus::Changed,
1858 [
1859 LifecycleEffect::ResourceWritten,
1860 LifecycleEffect::ReceiptWritten,
1861 ],
1862 ));
1863 report.files.push(app_upgrade_file_report(
1864 &assessment,
1865 LifecycleStatus::Changed,
1866 AppFileAction::Installed,
1867 None,
1868 ));
1869 }
1870 Ok(InstallOutcome::AlreadyManaged | InstallOutcome::DryRun) => {
1871 report.skipped += 1;
1872 }
1873 Err(error) => {
1874 report.failed += 1;
1875 report.lifecycle.push(
1876 LifecycleOutcomeV1::new(
1877 format!("app/{}", assessment.category.name),
1878 Some(assessment.file.source_rel.display().to_string()),
1879 LifecycleStatus::Failed,
1880 [],
1881 )
1882 .with_diagnostic_code("app_install_failed"),
1883 );
1884 report.files.push(app_upgrade_file_report(
1885 &assessment,
1886 LifecycleStatus::Failed,
1887 AppFileAction::Failed,
1888 Some(format!("{error:#}")),
1889 ));
1890 }
1891 }
1892 }
1893 if !approved.action_irs.is_empty() {
1894 bail!("approved App update actions were not consumed by lifecycle execution");
1895 }
1896 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1897 let hooks = self
1898 .run_app_hooks(
1899 AppHookRequest {
1900 categories,
1901 changed: changed.clone(),
1902 phase: AppHookPhase::PostUpgrade,
1903 show_success: request.show_hook_success,
1904 },
1905 observer,
1906 )
1907 .await;
1908 report.lifecycle.outcomes.extend(hooks.outcomes);
1909 report.updated_categories = changed.into_iter().collect();
1910 Ok(report)
1911 }
1912
1913 pub async fn inspect_apps(
1916 &self,
1917 observer: &mut impl RuntimeObserver,
1918 ) -> Result<Vec<AppFileInspection>> {
1919 self.inspect_apps_with_options(AppInspectionOptions::default(), observer)
1920 .await
1921 }
1922
1923 pub async fn inspect_apps_with_options(
1924 &self,
1925 options: AppInspectionOptions,
1926 observer: &mut impl RuntimeObserver,
1927 ) -> Result<Vec<AppFileInspection>> {
1928 let selected = options.categories.iter().collect::<BTreeSet<_>>();
1929 let categories = self
1930 .app_categories(None)?
1931 .into_iter()
1932 .filter(|category| selected.is_empty() || selected.contains(&category.name))
1933 .collect::<Vec<_>>();
1934 let manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1935 let assessments = self
1936 .assess_app_files(
1937 &categories,
1938 AppAssessmentOptions {
1939 run_generators: options.run_generators,
1940 explicit_generators: options.run_generators,
1941 preserve_generator_errors: true,
1942 ..AppAssessmentOptions::default()
1943 },
1944 &manifest,
1945 observer,
1946 )
1947 .await?;
1948 let installed_categories = manifest
1949 .entries
1950 .iter()
1951 .filter_map(|entry| {
1952 app_source_parts(&entry.source).map(|(category, _)| category.to_string())
1953 })
1954 .collect::<BTreeSet<_>>();
1955 let mut files = Vec::new();
1956 for assessment in assessments {
1957 let source = format!(
1958 "app/{}/{}",
1959 assessment.category.name,
1960 assessment.file.source_rel.display()
1961 );
1962 let direct_entry = manifest.find_by_dest(&assessment.destination).cloned();
1963 let source_entry = manifest.find_by_source(&source).cloned();
1964 let entry = direct_entry.clone().or_else(|| source_entry.clone());
1965 let current_content = match entry.as_ref() {
1966 Some(entry) => match self.host.read(&entry.destination).await {
1967 Ok(bytes) => Some(bytes),
1968 Err(error) if error.is_not_found() => None,
1969 Err(error) => return Err(error.into_anyhow("reading installed App file")),
1970 },
1971 None => None,
1972 };
1973 let manual_generator = assessment
1974 .file
1975 .generator
1976 .as_ref()
1977 .is_some_and(|generator| !generator.auto);
1978 let mut changes = Vec::new();
1979 let status = if let Some(entry) = direct_entry.as_ref() {
1980 let current_hash = current_content
1981 .as_deref()
1982 .map(|bytes| installed_app_hash(&assessment.file, bytes))
1983 .transpose()?;
1984 match current_hash {
1985 None => InspectionFileStatus::Missing,
1986 Some(None) => InspectionFileStatus::Partial,
1987 Some(Some(hash)) if hash != entry.content_hash => {
1988 InspectionFileStatus::UserModified
1989 }
1990 Some(Some(_)) if assessment.generator_diagnostic.is_some() => {
1991 generator_inspection_status(assessment.generator_diagnostic)
1992 }
1993 Some(Some(_)) => {
1994 let desired = assessment
1995 .content
1996 .as_deref()
1997 .map(|content| desired_app_hash(&assessment.file, content))
1998 .transpose()?;
1999 if desired.is_some_and(|hash| hash != entry.content_hash) {
2000 changes.push(InspectionChange::ContentChanged);
2001 InspectionFileStatus::UpdateAvail
2002 } else {
2003 InspectionFileStatus::UpToDate
2004 }
2005 }
2006 }
2007 } else if let Some(entry) = source_entry.as_ref() {
2008 if assessment.generator_diagnostic.is_some() {
2009 generator_inspection_status(assessment.generator_diagnostic)
2010 } else if manual_generator && !options.run_generators {
2011 let current_hash = current_content
2012 .as_deref()
2013 .map(|bytes| installed_app_hash(&assessment.file, bytes))
2014 .transpose()?;
2015 match current_hash {
2016 None => InspectionFileStatus::Missing,
2017 Some(None) => InspectionFileStatus::Partial,
2018 Some(Some(hash)) if hash != entry.content_hash => {
2019 InspectionFileStatus::UserModified
2020 }
2021 Some(Some(_)) => InspectionFileStatus::UpToDate,
2022 }
2023 } else {
2024 changes.push(InspectionChange::DestinationRelocated {
2025 from: entry.destination.clone(),
2026 to: assessment.destination.clone(),
2027 });
2028 if assessment
2029 .content
2030 .as_deref()
2031 .map(|content| desired_app_hash(&assessment.file, content))
2032 .transpose()?
2033 .is_some_and(|hash| hash != entry.content_hash)
2034 {
2035 changes.push(InspectionChange::ContentChanged);
2036 }
2037 InspectionFileStatus::UpdateAvail
2038 }
2039 } else if installed_categories.contains(&assessment.category.name)
2040 && assessment.generator_diagnostic.is_some()
2041 {
2042 generator_inspection_status(assessment.generator_diagnostic)
2043 } else if installed_categories.contains(&assessment.category.name)
2044 && (!manual_generator || options.run_generators)
2045 && assessment.content.is_some()
2046 {
2047 changes.push(InspectionChange::NewFile {
2048 destination: assessment.destination.clone(),
2049 });
2050 InspectionFileStatus::UpdateAvail
2051 } else {
2052 InspectionFileStatus::NotInstalled
2053 };
2054 let desired_content = assessment.content.clone();
2055 files.push(AppFileInspection {
2056 category: assessment.category,
2057 file: assessment.file,
2058 destination: Some(assessment.destination),
2059 status,
2060 manifest_entry: entry,
2061 desired_content,
2062 current_content,
2063 changes,
2064 assessment_error: assessment.generator_error,
2065 assessment_diagnostic: assessment.generator_diagnostic,
2066 });
2067 }
2068 Ok(files)
2069 }
2070
2071 async fn assess_app_files(
2072 &self,
2073 categories: &[AppCategory],
2074 options: AppAssessmentOptions,
2075 manifest: &AppManifest,
2076 observer: &mut impl RuntimeObserver,
2077 ) -> Result<Vec<AssessedAppFile>> {
2078 let mut assessed = Vec::new();
2079 for category in categories {
2080 for file in &category.files {
2081 let destination = self.app_destination(category, file)?;
2082 let raw = if !options.run_generators && file.generator.is_some() {
2083 assessed.push(AssessedAppFile {
2084 category: category.clone(),
2085 file: file.clone(),
2086 destination,
2087 content: None,
2088 generator_error: None,
2089 generator_diagnostic: Some("app_generator_not_evaluated"),
2090 });
2091 continue;
2092 } else if options.dry_run || file.generator.is_none() {
2093 Some(
2094 self.app_source_bytes(category.name.as_str(), file)?
2095 .to_vec(),
2096 )
2097 } else {
2098 if options.preserve_generator_errors
2099 && let Err(error) =
2100 self.ensure_app_code_allowed(&category.name, "generator")
2101 {
2102 assessed.push(AssessedAppFile {
2103 category: category.clone(),
2104 file: file.clone(),
2105 destination,
2106 content: None,
2107 generator_error: Some(format!("{error:#}")),
2108 generator_diagnostic: Some("app_generator_trust_required"),
2109 });
2110 continue;
2111 }
2112 match self
2113 .run_app_generator(
2114 AppGeneratorRequest {
2115 category: category.name.clone(),
2116 source: file.source_rel.display().to_string(),
2117 generator: file.generator.clone().expect("checked generator"),
2118 explicit: options.explicit_generators,
2119 },
2120 observer,
2121 )
2122 .await
2123 {
2124 Ok(Some(bytes)) => Some(bytes),
2125 Ok(None) => Some(self.app_source_bytes(&category.name, file)?.to_vec()),
2126 Err(error)
2127 if options.preserve_generator_errors
2128 || (manifest.find_by_dest(&destination).is_some()
2129 && self.host.metadata(&destination).await.is_ok()) =>
2130 {
2131 assessed.push(AssessedAppFile {
2132 category: category.clone(),
2133 file: file.clone(),
2134 destination,
2135 content: None,
2136 generator_error: Some(format!("{error:#}")),
2137 generator_diagnostic: Some("app_generator_evaluation_failed"),
2138 });
2139 continue;
2140 }
2141 Err(error) => return Err(error),
2142 }
2143 };
2144 let content = raw
2145 .map(|bytes| {
2146 crate::install::transforms::apply(
2147 &file.transforms,
2148 &bytes,
2149 &self.context.env,
2150 )
2151 .with_context(|| {
2152 format!("transform failed: {}", file.transforms.join(", "))
2153 })
2154 })
2155 .transpose()?;
2156 assessed.push(AssessedAppFile {
2157 category: category.clone(),
2158 file: file.clone(),
2159 destination,
2160 content,
2161 generator_error: None,
2162 generator_diagnostic: None,
2163 });
2164 }
2165 }
2166 Ok(assessed)
2167 }
2168
2169 async fn install_app_content(
2170 &self,
2171 file: &AppFile,
2172 content: &[u8],
2173 destination: &Path,
2174 is_managed: bool,
2175 dry_run: bool,
2176 force: bool,
2177 ) -> Result<InstallOutcome> {
2178 match &file.install_strategy {
2179 AppInstallStrategy::Copy if file.requires_admin => {
2180 install_privileged_bytes(
2181 &self.host,
2182 content,
2183 destination,
2184 is_managed,
2185 dry_run,
2186 force,
2187 )
2188 .await
2189 }
2190 AppInstallStrategy::Copy => {
2191 install_bytes_with_host(
2192 &self.host,
2193 content,
2194 destination,
2195 is_managed,
2196 dry_run,
2197 force,
2198 )
2199 .await
2200 }
2201 AppInstallStrategy::JsonMerge { managed_keys } => {
2202 install_json_merge(&self.host, content, destination, dry_run, managed_keys).await
2203 }
2204 }
2205 }
2206
2207 async fn uninstall_app_entry(
2208 &self,
2209 entry: &AppEntry,
2210 dry_run: bool,
2211 force: bool,
2212 ) -> Result<UninstallOutcome> {
2213 match &entry.install_strategy {
2214 AppInstallStrategy::Copy if entry.requires_admin => {
2215 uninstall_privileged_entry(&self.host, entry, dry_run, force).await
2216 }
2217 AppInstallStrategy::Copy => {
2218 uninstall_entry_with_host(&self.host, entry, dry_run, force).await
2219 }
2220 AppInstallStrategy::JsonMerge { managed_keys } => {
2221 uninstall_json_merge(&self.host, entry, dry_run, force, managed_keys).await
2222 }
2223 }
2224 }
2225}
2226
2227fn validate_app_destinations<H>(
2228 runtime: &CoreRuntime<H>,
2229 categories: &[AppCategory],
2230) -> Result<()> {
2231 let mut destinations = BTreeMap::<PathBuf, String>::new();
2232 for category in categories {
2233 for file in &category.files {
2234 let destination = runtime.app_destination(category, file)?;
2235 let source = format!("app/{}/{}", category.name, file.source_rel.display());
2236 if let Some(previous) = destinations.insert(destination.clone(), source.clone()) {
2237 bail!(
2238 "app destinations conflict: {previous} and {source} both resolve to {}",
2239 destination.display()
2240 );
2241 }
2242 }
2243 }
2244 Ok(())
2245}
2246
2247fn app_entry(assessment: &AssessedAppFile, content_hash: u64, backup: Option<PathBuf>) -> AppEntry {
2248 AppEntry {
2249 source: format!(
2250 "app/{}/{}",
2251 assessment.category.name,
2252 assessment.file.source_rel.display()
2253 ),
2254 destination: assessment.destination.clone(),
2255 backup,
2256 content_hash,
2257 install_strategy: assessment.file.install_strategy.clone(),
2258 uses_env: assessment
2259 .file
2260 .transforms
2261 .iter()
2262 .any(|value| value == "template")
2263 || assessment.file.generator.is_some(),
2264 requires_admin: assessment.file.requires_admin,
2265 }
2266}
2267
2268fn app_source_parts(source: &str) -> Option<(&str, &str)> {
2269 let mut parts = source.splitn(3, '/');
2270 (parts.next()? == "app").then_some((parts.next()?, parts.next()?))
2271}
2272
2273fn app_upgrade_file_report(
2274 assessment: &AssessedAppFile,
2275 status: LifecycleStatus,
2276 action: AppFileAction,
2277 error: Option<String>,
2278) -> AppFileLifecycleReport {
2279 AppFileLifecycleReport {
2280 category: assessment.category.name.clone(),
2281 source: assessment.file.source_rel.clone(),
2282 destination: assessment.destination.clone(),
2283 transforms: assessment.file.transforms.clone(),
2284 backup: None,
2285 restart_hint: assessment.file.restart_hint.clone(),
2286 generator_error: (action == AppFileAction::GeneratorPreserved)
2287 .then(|| error.clone())
2288 .flatten(),
2289 error: (action != AppFileAction::GeneratorPreserved)
2290 .then_some(error)
2291 .flatten(),
2292 status,
2293 action,
2294 }
2295}
2296
2297pub(crate) fn desired_app_hash(file: &AppFile, content: &[u8]) -> Result<u64> {
2298 match &file.install_strategy {
2299 AppInstallStrategy::Copy => Ok(hash_content(content)),
2300 AppInstallStrategy::JsonMerge { managed_keys } => managed_json_hash(content, managed_keys),
2301 }
2302}
2303
2304pub(crate) fn installed_app_hash(file: &AppFile, content: &[u8]) -> Result<Option<u64>> {
2305 match &file.install_strategy {
2306 AppInstallStrategy::Copy => Ok(Some(hash_content(content))),
2307 AppInstallStrategy::JsonMerge { managed_keys } => {
2308 installed_json_hash(content, managed_keys)
2309 }
2310 }
2311}
2312
2313pub(crate) fn installed_app_entry_hash(
2314 entry: &crate::install::AppEntry,
2315 content: &[u8],
2316) -> Result<Option<u64>> {
2317 match &entry.install_strategy {
2318 AppInstallStrategy::Copy => Ok(Some(hash_content(content))),
2319 AppInstallStrategy::JsonMerge { managed_keys } => {
2320 installed_json_hash(content, managed_keys)
2321 }
2322 }
2323}
2324
2325async fn install_privileged_bytes<H>(
2326 host: &H,
2327 content: &[u8],
2328 destination: &Path,
2329 is_managed: bool,
2330 dry_run: bool,
2331 force: bool,
2332) -> Result<InstallOutcome>
2333where
2334 H: FileSystemHost + PrivilegedFileSystemHost,
2335{
2336 if dry_run {
2337 return Ok(InstallOutcome::DryRun);
2338 }
2339 let _guard = host.acquire_privileged_operation().await?;
2340 let hash = hash_content(content);
2341 let exists = match host.metadata(destination).await {
2342 Ok(_) => true,
2343 Err(error) if error.is_not_found() => false,
2344 Err(error) => return Err(error.into_anyhow("inspecting privileged App destination")),
2345 };
2346 if exists && is_managed && !force {
2347 let current = host
2348 .read(destination)
2349 .await
2350 .map_err(|error| error.into_anyhow("reading privileged App destination"))?;
2351 if hash_content(¤t) == hash {
2352 return Ok(InstallOutcome::AlreadyManaged);
2353 }
2354 }
2355 let backup = if exists && !is_managed {
2356 let backup = crate::install::file_ops::backup_path(destination);
2357 host.move_privileged(destination, &backup).await?;
2358 Some(backup)
2359 } else {
2360 None
2361 };
2362 if let Err(error) = host.write_privileged(destination, content).await {
2363 if let Some(backup) = &backup {
2364 let _ = host.move_privileged(backup, destination).await;
2365 }
2366 return Err(error);
2367 }
2368 Ok(match backup {
2369 Some(backup) => InstallOutcome::BackedUpAndInstalled { backup, hash },
2370 None => InstallOutcome::Installed { hash },
2371 })
2372}
2373
2374async fn uninstall_privileged_entry<H>(
2375 host: &H,
2376 entry: &AppEntry,
2377 dry_run: bool,
2378 force: bool,
2379) -> Result<UninstallOutcome>
2380where
2381 H: FileSystemHost + PrivilegedFileSystemHost,
2382{
2383 if dry_run {
2384 return Ok(UninstallOutcome::DryRun);
2385 }
2386 let _guard = host.acquire_privileged_operation().await?;
2387 let current = match host.read(&entry.destination).await {
2388 Ok(bytes) => bytes,
2389 Err(error) if error.is_not_found() => return Ok(UninstallOutcome::NotFound),
2390 Err(error) => return Err(error.into_anyhow("reading privileged App destination")),
2391 };
2392 let user_modified = hash_content(¤t) != entry.content_hash;
2393 if user_modified && !force {
2394 return Ok(UninstallOutcome::UserModified);
2395 }
2396 host.remove_privileged(&entry.destination).await?;
2397 if let Some(backup) = &entry.backup
2398 && host.metadata(backup).await.is_ok()
2399 {
2400 host.move_privileged(backup, &entry.destination).await?;
2401 return Ok(if user_modified {
2402 UninstallOutcome::ForceRestoredBackup {
2403 backup: backup.clone(),
2404 }
2405 } else {
2406 UninstallOutcome::RestoredBackup {
2407 backup: backup.clone(),
2408 }
2409 });
2410 }
2411 Ok(if user_modified {
2412 UninstallOutcome::ForceRemoved
2413 } else {
2414 UninstallOutcome::Removed
2415 })
2416}
2417
2418async fn install_json_merge(
2419 host: &impl FileSystemHost,
2420 source: &[u8],
2421 destination: &Path,
2422 dry_run: bool,
2423 managed_keys: &[String],
2424) -> Result<InstallOutcome> {
2425 if dry_run {
2426 return Ok(InstallOutcome::DryRun);
2427 }
2428 let managed = managed_json_payload(source, managed_keys)?;
2429 let hash = hash_content(&serialize_json_object(&managed)?);
2430 let mut destination_object = match host.read(destination).await {
2431 Ok(existing) => {
2432 if installed_json_hash(&existing, managed_keys)? == Some(hash) {
2433 return Ok(InstallOutcome::AlreadyManaged);
2434 }
2435 parse_json_object(&existing, "json-merge: destination must be a JSON object")?
2436 }
2437 Err(error) if error.is_not_found() => JsonMap::new(),
2438 Err(error) => return Err(error.into_anyhow("reading App JSON destination")),
2439 };
2440 for (key, value) in managed {
2441 destination_object.insert(key, value);
2442 }
2443 host.write_atomic(destination, &serialize_json_object(&destination_object)?)
2444 .await
2445 .map_err(|error| error.into_anyhow("writing merged App JSON"))?;
2446 Ok(InstallOutcome::Installed { hash })
2447}
2448
2449async fn uninstall_json_merge(
2450 host: &impl FileSystemHost,
2451 entry: &AppEntry,
2452 dry_run: bool,
2453 force: bool,
2454 managed_keys: &[String],
2455) -> Result<UninstallOutcome> {
2456 if dry_run {
2457 return Ok(UninstallOutcome::DryRun);
2458 }
2459 let existing = match host.read(&entry.destination).await {
2460 Ok(bytes) => bytes,
2461 Err(error) if error.is_not_found() => return Ok(UninstallOutcome::NotFound),
2462 Err(error) => return Err(error.into_anyhow("reading App JSON destination")),
2463 };
2464 let Some(current_hash) = installed_json_hash(&existing, managed_keys)? else {
2465 return Ok(UninstallOutcome::NotFound);
2466 };
2467 let user_modified = current_hash != entry.content_hash;
2468 if user_modified && !force {
2469 return Ok(UninstallOutcome::UserModified);
2470 }
2471 let mut root = parse_json_object(&existing, "json-merge: destination must be a JSON object")?;
2472 for key in managed_keys {
2473 root.remove(key);
2474 }
2475 host.write_atomic(&entry.destination, &serialize_json_object(&root)?)
2476 .await
2477 .map_err(|error| error.into_anyhow("writing App JSON destination"))?;
2478 Ok(if user_modified {
2479 UninstallOutcome::ForceRemoved
2480 } else {
2481 UninstallOutcome::Removed
2482 })
2483}
2484
2485pub(crate) fn managed_json_hash(source: &[u8], managed_keys: &[String]) -> Result<u64> {
2486 Ok(hash_content(&serialize_json_object(
2487 &managed_json_payload(source, managed_keys)?,
2488 )?))
2489}
2490
2491pub(crate) fn merge_managed_json_bytes(
2492 current: Option<&[u8]>,
2493 source: &[u8],
2494 managed_keys: &[String],
2495) -> Result<Vec<u8>> {
2496 let managed = managed_json_payload(source, managed_keys)?;
2497 let mut root = current
2498 .map(|bytes| parse_json_object(bytes, "json-merge: destination must be a JSON object"))
2499 .transpose()?
2500 .unwrap_or_default();
2501 for (key, value) in managed {
2502 root.insert(key, value);
2503 }
2504 serialize_json_object(&root)
2505}
2506
2507pub(crate) fn remove_managed_json_bytes(
2508 current: &[u8],
2509 managed_keys: &[String],
2510) -> Result<Vec<u8>> {
2511 let mut root = parse_json_object(current, "json-merge: destination must be a JSON object")?;
2512 for key in managed_keys {
2513 root.remove(key);
2514 }
2515 serialize_json_object(&root)
2516}
2517
2518pub(crate) fn restore_managed_json_bytes(
2519 current: &[u8],
2520 original: &[u8],
2521 managed_keys: &[String],
2522) -> Result<Vec<u8>> {
2523 let mut root = parse_json_object(current, "json-merge: destination must be a JSON object")?;
2524 let original = parse_json_object(original, "json-merge: rollback must be a JSON object")?;
2525 for key in managed_keys {
2526 if let Some(value) = original.get(key) {
2527 root.insert(key.clone(), value.clone());
2528 } else {
2529 root.remove(key);
2530 }
2531 }
2532 serialize_json_object(&root)
2533}
2534
2535pub(crate) fn managed_json_keys_match(
2536 left: &[u8],
2537 right: &[u8],
2538 managed_keys: &[String],
2539) -> Result<bool> {
2540 let left = parse_json_object(left, "json-merge: destination must be a JSON object")?;
2541 let right = parse_json_object(right, "json-merge: comparison must be a JSON object")?;
2542 Ok(managed_keys
2543 .iter()
2544 .all(|key| left.get(key) == right.get(key)))
2545}
2546
2547pub(crate) fn managed_json_keys_absent(bytes: &[u8], managed_keys: &[String]) -> Result<bool> {
2548 let root = parse_json_object(bytes, "json-merge: destination must be a JSON object")?;
2549 Ok(managed_keys.iter().all(|key| !root.contains_key(key)))
2550}
2551
2552pub(crate) fn managed_json_payload(
2553 source: &[u8],
2554 managed_keys: &[String],
2555) -> Result<JsonMap<String, JsonValue>> {
2556 let source = parse_json_object(source, "json-merge: source must be a JSON object")?;
2557 managed_keys
2558 .iter()
2559 .map(|key| {
2560 source
2561 .get(key)
2562 .cloned()
2563 .map(|value| (key.clone(), value))
2564 .with_context(|| format!("json-merge: source missing managed key `{key}`"))
2565 })
2566 .collect()
2567}
2568
2569pub(crate) fn installed_json_hash(bytes: &[u8], managed_keys: &[String]) -> Result<Option<u64>> {
2570 let current = parse_json_object(bytes, "json-merge: destination must be a JSON object")?;
2571 let managed = managed_keys
2572 .iter()
2573 .filter_map(|key| current.get(key).cloned().map(|value| (key.clone(), value)))
2574 .collect::<JsonMap<_, _>>();
2575 if managed.is_empty() {
2576 Ok(None)
2577 } else {
2578 Ok(Some(hash_content(&serialize_json_object(&managed)?)))
2579 }
2580}
2581
2582pub(crate) fn parse_json_object(
2583 bytes: &[u8],
2584 context: &'static str,
2585) -> Result<JsonMap<String, JsonValue>> {
2586 let value: JsonValue = serde_json::from_slice(bytes).context(context)?;
2587 let JsonValue::Object(object) = value else {
2588 bail!("{context}");
2589 };
2590 Ok(object)
2591}
2592
2593pub(crate) fn serialize_json_object(object: &JsonMap<String, JsonValue>) -> Result<Vec<u8>> {
2594 let mut bytes =
2595 serde_json::to_vec_pretty(object).context("json-merge: serialization failed")?;
2596 if bytes.last() != Some(&b'\n') {
2597 bytes.push(b'\n');
2598 }
2599 Ok(bytes)
2600}
2601
2602impl<H: FileSystemHost + ProcessHost> CoreRuntime<H> {
2603 pub fn validate_app_category_snapshot(&self, category: &str) -> Result<bool> {
2604 let metadata = format!("app/{category}/shine.toml");
2605 let has_metadata = self.presets.file(&metadata).is_some();
2606 let categories = self.app_categories(Some(category))?;
2607 validate_app_destinations(self, &categories)?;
2608 for category in &categories {
2609 if let Some(artifact) = &category.artifact
2610 && artifact.runtime == ArtifactRuntime::Bun
2611 {
2612 self.bun_dependency_arg(&format!("app/{}/{}", category.name, artifact.script))?;
2613 }
2614 for file in &category.files {
2615 if let Some(generator) = &file.generator
2616 && generator.runtime == ArtifactRuntime::Bun
2617 {
2618 self.bun_dependency_arg(&format!(
2619 "app/{}/{}",
2620 category.name,
2621 generator.script.display()
2622 ))?;
2623 }
2624 }
2625 }
2626 Ok(has_metadata)
2627 }
2628
2629 pub async fn run_app_generator(
2630 &self,
2631 request: AppGeneratorRequest,
2632 observer: &mut impl RuntimeObserver,
2633 ) -> Result<Option<Vec<u8>>> {
2634 if !self.context.env.contains_key(&request.generator.when_env) {
2635 return Ok(None);
2636 }
2637 if !request.explicit && !request.generator.auto {
2638 return Ok(None);
2639 }
2640 self.ensure_app_code_allowed(&request.category, "generator")?;
2641 let logical = format!(
2642 "app/{}/{}",
2643 request.category,
2644 request.generator.script.display()
2645 );
2646 let prepared = self
2647 .prepare_app_script(
2648 &request.category,
2649 &logical,
2650 request.generator.runtime,
2651 false,
2652 )
2653 .await?;
2654 let mut env = BTreeMap::new();
2655 for spec in &request.generator.env {
2656 let value = self.context.env.get(&spec.source).ok_or_else(|| {
2657 anyhow::anyhow!(
2658 "app '{}' generator requires config env '{}'",
2659 request.category,
2660 spec.source
2661 )
2662 })?;
2663 env.insert(spec.target.clone(), value.clone());
2664 }
2665 env.extend(self.fixed_app_contract_env(&request.category, &prepared.category_root));
2666 let output = self
2667 .host
2668 .run(ProcessRequest {
2669 program: prepared.program,
2670 args: prepared.args,
2671 cwd: Some(prepared.category_root),
2672 env,
2673 timeout: Some(GENERATOR_TIMEOUT),
2674 stdout_limit: Some(GENERATOR_STDOUT_LIMIT),
2675 stderr_limit: Some(GENERATOR_STDERR_LIMIT),
2676 ..ProcessRequest::default()
2677 })
2678 .await
2679 .with_context(|| format!("running app '{}' generator", request.category))?;
2680 if output.exit_code != Some(0) {
2681 bail!(
2682 "app '{}' generator exited with {} (details redacted)",
2683 request.category,
2684 display_exit_code(output.exit_code)
2685 );
2686 }
2687 let content = String::from_utf8(output.stdout)
2688 .with_context(|| format!("app '{}' generator output is not UTF-8", request.category))?;
2689 let note = String::from_utf8_lossy(&output.stderr).trim().to_string();
2690 if !note.is_empty() {
2691 observer.emit(RuntimeEvent::ProcessOutput {
2692 code: "app_generator_note",
2693 target: format!("app/{}", request.category),
2694 stream: "stderr",
2695 text: note,
2696 });
2697 }
2698 Ok(Some(content.into_bytes()))
2699 }
2700
2701 pub async fn run_app_hooks(
2702 &self,
2703 request: AppHookRequest,
2704 observer: &mut impl RuntimeObserver,
2705 ) -> AppHookReport {
2706 let categories = request
2707 .categories
2708 .into_iter()
2709 .map(|category| (category.name.clone(), category))
2710 .collect::<BTreeMap<_, _>>();
2711 let mut report = AppHookReport::default();
2712 for category_name in request.changed {
2713 let Some(category) = categories.get(&category_name) else {
2714 continue;
2715 };
2716 let hooks = match request.phase {
2717 AppHookPhase::PostInstall => &category.post_install,
2718 AppHookPhase::PostUpgrade => &category.post_upgrade,
2719 };
2720 if hooks.is_empty() {
2721 continue;
2722 }
2723 let resource = match request.phase {
2724 AppHookPhase::PostInstall => "hook:post-install",
2725 AppHookPhase::PostUpgrade => "hook:post-upgrade",
2726 };
2727 if let Err(error) = self.ensure_app_code_allowed(&category_name, resource) {
2728 observer.emit(RuntimeEvent::Warning {
2729 code: "app_hook_permission_required",
2730 target: Some(format!("app/{category_name}")),
2731 detail: error.to_string(),
2732 });
2733 report.outcomes.push(
2734 LifecycleOutcomeV1::new(
2735 format!("app/{category_name}"),
2736 Some(resource),
2737 LifecycleStatus::Skipped,
2738 [],
2739 )
2740 .with_diagnostic_code("app_hook_permission_required"),
2741 );
2742 continue;
2743 }
2744 let mut completed = true;
2745 let mut notes = Vec::new();
2746 for hook in hooks {
2747 let env = hook
2748 .env
2749 .iter()
2750 .filter_map(|spec| {
2751 self.context
2752 .env
2753 .get(&spec.source)
2754 .map(|value| (spec.target.clone(), value.clone()))
2755 })
2756 .collect();
2757 let output = self
2758 .host
2759 .run(ProcessRequest {
2760 program: hook.command.clone(),
2761 args: hook.args.clone(),
2762 env,
2763 ..ProcessRequest::default()
2764 })
2765 .await;
2766 match output {
2767 Ok(output) if output.exit_code == Some(0) => {
2768 if request.show_success && hook.show_output {
2769 let note = String::from_utf8_lossy(&output.stdout).trim().to_string();
2770 if !note.is_empty() {
2771 notes.push(note);
2772 }
2773 }
2774 }
2775 Ok(output) => {
2776 observer.emit(RuntimeEvent::Warning {
2777 code: "app_hook_failed",
2778 target: Some(format!("app/{category_name}")),
2779 detail: format!(
2780 "{} exited with {}{}",
2781 hook.command,
2782 display_exit_code(output.exit_code),
2783 process_detail(&output)
2784 ),
2785 });
2786 completed = false;
2787 break;
2788 }
2789 Err(error) => {
2790 observer.emit(RuntimeEvent::Warning {
2791 code: "app_hook_failed",
2792 target: Some(format!("app/{category_name}")),
2793 detail: format!("{}: {error}", hook.command),
2794 });
2795 completed = false;
2796 break;
2797 }
2798 }
2799 }
2800 if request.show_success && completed {
2801 observer.emit(RuntimeEvent::Progress {
2802 code: "app_hook_completed",
2803 target: format!("app/{category_name}"),
2804 });
2805 }
2806 for note in ¬es {
2807 observer.emit(RuntimeEvent::ProcessOutput {
2808 code: "app_hook_note",
2809 target: format!("app/{category_name}"),
2810 stream: "stdout",
2811 text: note.clone(),
2812 });
2813 }
2814 report.notes.extend(notes);
2815 report.outcomes.push(if completed {
2816 LifecycleOutcomeV1::new(
2817 format!("app/{category_name}"),
2818 Some(resource),
2819 LifecycleStatus::Changed,
2820 [LifecycleEffect::CodeExecuted],
2821 )
2822 } else {
2823 LifecycleOutcomeV1::new(
2824 format!("app/{category_name}"),
2825 Some(resource),
2826 LifecycleStatus::Failed,
2827 [],
2828 )
2829 .with_diagnostic_code("app_hook_failed")
2830 });
2831 }
2832 report
2833 }
2834
2835 pub(crate) async fn run_app_artifact(
2836 &self,
2837 request: AppArtifactRequest,
2838 observer: &mut impl RuntimeObserver,
2839 ) -> Result<LifecycleOutcomeV1> {
2840 let (script, resource) = match request.action {
2841 AppArtifactAction::Apply => (&request.artifact.script, "artifact:apply"),
2842 AppArtifactAction::Remove => (
2843 request.artifact.teardown.as_ref().ok_or_else(|| {
2844 anyhow::anyhow!(
2845 "app '{}' does not define an artifact teardown script",
2846 request.category
2847 )
2848 })?,
2849 "artifact:teardown",
2850 ),
2851 };
2852 if request.dry_run {
2853 return Ok(LifecycleOutcomeV1::new(
2854 format!("app/{}", request.category),
2855 Some(resource),
2856 LifecycleStatus::Previewed,
2857 [LifecycleEffect::CodeExecutionPreviewed],
2858 ));
2859 }
2860 if request.implicit
2861 && let Err(error) = self.ensure_app_code_allowed(&request.category, resource)
2862 {
2863 observer.emit(RuntimeEvent::Warning {
2864 code: "app_artifact_permission_required",
2865 target: Some(format!("app/{}", request.category)),
2866 detail: error.to_string(),
2867 });
2868 return Ok(LifecycleOutcomeV1::new(
2869 format!("app/{}", request.category),
2870 Some(resource),
2871 LifecycleStatus::Skipped,
2872 [],
2873 )
2874 .with_diagnostic_code("app_artifact_permission_required"));
2875 }
2876 if !request.implicit {
2877 self.ensure_app_code_allowed(&request.category, resource)?;
2878 }
2879 let logical = format!("app/{}/{script}", request.category);
2880 let prepared = self
2881 .prepare_app_script(&request.category, &logical, request.artifact.runtime, true)
2882 .await?;
2883 for directory in [
2884 self.context
2885 .shine_dir
2886 .join("http")
2887 .join("app")
2888 .join(&request.category),
2889 self.context
2890 .cache_dir
2891 .join("shine")
2892 .join("app")
2893 .join(&request.category),
2894 self.context
2895 .shine_dir
2896 .join("state")
2897 .join("app")
2898 .join(&request.category),
2899 ] {
2900 self.host
2901 .create_dir_all(&directory)
2902 .await
2903 .map_err(|error| error.into_anyhow("creating App artifact directory"))?;
2904 }
2905 let output = self
2906 .host
2907 .run(ProcessRequest {
2908 program: prepared.program,
2909 args: prepared.args,
2910 cwd: Some(prepared.category_root.clone()),
2911 env: self.app_artifact_env(
2912 &request.category,
2913 &prepared.category_root,
2914 &request.artifact.env,
2915 ),
2916 io: if request.implicit {
2917 ProcessIo::Captured
2918 } else {
2919 ProcessIo::Inherit
2920 },
2921 ..ProcessRequest::default()
2922 })
2923 .await;
2924 match output {
2925 Ok(output) if output.exit_code == Some(0) => Ok(LifecycleOutcomeV1::new(
2926 format!("app/{}", request.category),
2927 Some(resource),
2928 LifecycleStatus::Changed,
2929 [LifecycleEffect::CodeExecuted],
2930 )),
2931 Ok(output) if request.implicit => {
2932 observer.emit(RuntimeEvent::Warning {
2933 code: "app_artifact_teardown_failed",
2934 target: Some(format!("app/{}", request.category)),
2935 detail: format!(
2936 "artifact script exited with {}",
2937 display_exit_code(output.exit_code)
2938 ),
2939 });
2940 Ok(LifecycleOutcomeV1::new(
2941 format!("app/{}", request.category),
2942 Some(resource),
2943 LifecycleStatus::Failed,
2944 [],
2945 )
2946 .with_diagnostic_code("app_artifact_teardown_failed"))
2947 }
2948 Ok(output) => bail!(
2949 "artifact script for '{}' exited with {}",
2950 request.category,
2951 display_exit_code(output.exit_code)
2952 ),
2953 Err(error) if request.implicit => {
2954 observer.emit(RuntimeEvent::Warning {
2955 code: "app_artifact_teardown_failed",
2956 target: Some(format!("app/{}", request.category)),
2957 detail: error.to_string(),
2958 });
2959 Ok(LifecycleOutcomeV1::new(
2960 format!("app/{}", request.category),
2961 Some(resource),
2962 LifecycleStatus::Failed,
2963 [],
2964 )
2965 .with_diagnostic_code("app_artifact_teardown_failed"))
2966 }
2967 Err(error) => Err(error),
2968 }
2969 }
2970
2971 fn ensure_app_code_allowed(&self, category: &str, capability: &str) -> Result<()> {
2972 let category = self
2973 .app_categories(Some(category))?
2974 .into_iter()
2975 .next()
2976 .with_context(|| format!("app preset category not found: {category}"))?;
2977 let trust_capability = if capability.starts_with("hook:") {
2978 TrustCapabilityV1::AppHook
2979 } else if capability.starts_with("artifact:") {
2980 TrustCapabilityV1::AppArtifact
2981 } else {
2982 TrustCapabilityV1::AppGenerator
2983 };
2984 if !self.app_capability_trusted(&category, trust_capability)? {
2985 bail!(
2986 "app '{}' {capability} requires scoped external-code trust; run `shine trust grant app/{}` after reviewing the active code and permissions",
2987 category.name,
2988 category.name,
2989 );
2990 }
2991 Ok(())
2992 }
2993
2994 async fn prepare_app_script(
2995 &self,
2996 category: &str,
2997 logical: &str,
2998 runtime: ArtifactRuntime,
2999 materialize_category: bool,
3000 ) -> Result<PreparedScript> {
3001 let file = self
3002 .presets
3003 .file(logical)
3004 .with_context(|| format!("app script is missing: {logical}"))?;
3005 let script_path = if let Some(path) = &file.origin.physical_path {
3006 path.clone()
3007 } else if materialize_category {
3008 let prefix = format!("app/{category}/");
3009 for (path, bytes) in self
3010 .presets
3011 .files()
3012 .iter()
3013 .filter(|(path, _)| path.starts_with(&prefix))
3014 {
3015 self.host
3016 .write_atomic(&self.context.presets_dir.join(path), bytes)
3017 .await
3018 .map_err(|error| error.into_anyhow("materializing App artifact category"))?;
3019 }
3020 self.context.presets_dir.join(logical)
3021 } else {
3022 let file_name = Path::new(logical)
3023 .file_name()
3024 .context("app script has no file name")?;
3025 let path = self
3026 .context
3027 .shine_dir
3028 .join("runtime")
3029 .join("app")
3030 .join(category)
3031 .join(file_name);
3032 self.host
3033 .write_atomic(&path, &file.bytes)
3034 .await
3035 .map_err(|error| error.into_anyhow("materializing app script"))?;
3036 path
3037 };
3038 let category_root = (materialize_category && file.origin.physical_path.is_none())
3039 .then(|| self.context.presets_dir.join("app").join(category))
3040 .or_else(|| file.origin.category_root.clone())
3041 .or_else(|| script_path.parent().map(Path::to_path_buf))
3042 .context("app script has no category root")?;
3043 let (program, args) = match runtime {
3044 ArtifactRuntime::Native => (script_path.display().to_string(), Vec::new()),
3045 ArtifactRuntime::Bun => {
3046 let mut args = vec![self.bun_dependency_arg(logical)?];
3047 args.push(script_path.display().to_string());
3048 ("bun".to_string(), args)
3049 }
3050 };
3051 Ok(PreparedScript {
3052 program,
3053 args,
3054 category_root,
3055 })
3056 }
3057
3058 pub(crate) fn bun_dependency_arg(&self, logical: &str) -> Result<String> {
3059 let script = self
3060 .presets
3061 .file(logical)
3062 .context("Bun script disappeared from preset snapshot")?;
3063 if script.origin.source_kind == crate::runtime::PresetSourceKind::Embedded {
3064 return Ok("--no-install".to_string());
3065 }
3066 let mut parts = logical.split('/');
3067 let kind = parts.next().unwrap_or_default();
3068 let category = parts.next().unwrap_or_default();
3069 let package_key = format!("{kind}/{category}/package.json");
3070 let lock_key = format!("{kind}/{category}/bun.lock");
3071 let package = self
3072 .presets
3073 .file(&package_key)
3074 .filter(|candidate| candidate.origin.source_kind == script.origin.source_kind);
3075 let lock = self
3076 .presets
3077 .file(&lock_key)
3078 .filter(|candidate| candidate.origin.source_kind == script.origin.source_kind);
3079 match (package, lock) {
3080 (None, None) => Ok("--no-install".to_string()),
3081 (Some(_), None) | (None, Some(_)) => {
3082 bail!("external Bun preset requires both package.json and bun.lock")
3083 }
3084 (Some(package), Some(_)) => {
3085 let value: serde_json::Value = serde_json::from_slice(&package.bytes)
3086 .context("invalid external Bun package.json")?;
3087 if value.get("trustedDependencies").is_some() {
3088 bail!("external Bun preset must not declare trustedDependencies");
3089 }
3090 Ok("--install=fallback".to_string())
3091 }
3092 }
3093 }
3094
3095 fn app_artifact_env(
3096 &self,
3097 category: &str,
3098 app_dir: &Path,
3099 specs: &[EnvVarSpec],
3100 ) -> BTreeMap<String, String> {
3101 let mut env = BTreeMap::new();
3102 for spec in specs {
3103 if let Some(value) = self.context.env.get(&spec.source) {
3104 env.insert(spec.target.clone(), value.clone());
3105 }
3106 }
3107 env.extend(self.fixed_app_contract_env(category, app_dir));
3108 env
3109 }
3110
3111 fn fixed_app_contract_env(&self, category: &str, app_dir: &Path) -> BTreeMap<String, String> {
3112 let mut env = BTreeMap::new();
3113 let source_dir = self.context.presets_dir.join("app").join(category);
3114 let cache_dir = self
3115 .context
3116 .cache_dir
3117 .join("shine")
3118 .join("app")
3119 .join(category);
3120 let state_dir = self
3121 .context
3122 .shine_dir
3123 .join("state")
3124 .join("app")
3125 .join(category);
3126 let http_dir = self
3127 .context
3128 .shine_dir
3129 .join("http")
3130 .join("app")
3131 .join(category);
3132 for (key, value) in [
3133 ("SHINE_APP_ID", category.to_string()),
3134 ("SHINE_APP_DIR", app_dir.display().to_string()),
3135 ("SHINE_APP_SOURCE_DIR", source_dir.display().to_string()),
3136 ("SHINE_APP_HTTP_DIR", http_dir.display().to_string()),
3137 (
3138 "SHINE_CONFIG_DIR",
3139 self.context.shine_dir.display().to_string(),
3140 ),
3141 ("SHINE_CACHE_DIR", cache_dir.display().to_string()),
3142 ("SHINE_STATE_DIR", state_dir.display().to_string()),
3143 ] {
3144 env.insert(key.to_string(), value);
3145 }
3146 if let Some(overlay) = &self.context.overlay_dir {
3147 env.insert(
3148 "SHINE_APP_OVERLAY_DIR".to_string(),
3149 overlay.join("app").join(category).display().to_string(),
3150 );
3151 }
3152 env
3153 }
3154}
3155
3156impl<H: FileSystemHost> CoreRuntime<H> {
3157 pub async fn reconcile_app_cache(
3158 &self,
3159 request: AppCacheRequest,
3160 ) -> Result<LifecycleOutcomeV1> {
3161 let target = request.prefix.trim_end_matches('/');
3162 let mut changed = false;
3163 let mut receipt_removed = false;
3164 for (logical, bytes) in
3165 self.presets.files().iter().filter(|(logical, _)| {
3166 *logical == target || logical.starts_with(&format!("{target}/"))
3167 })
3168 {
3169 let destination = self.context.presets_dir.join(logical);
3170 if request.remove {
3171 match self.host.metadata(&destination).await {
3172 Ok(_) => {
3173 changed = true;
3174 if !request.dry_run {
3175 self.host
3176 .remove_file(&destination)
3177 .await
3178 .map_err(|error| error.into_anyhow("removing app preset cache"))?;
3179 }
3180 }
3181 Err(error) if error.is_not_found() => {}
3182 Err(error) => return Err(error.into_anyhow("inspecting app preset cache")),
3183 }
3184 } else {
3185 let current = self.host.read(&destination).await;
3186 let differs = match current {
3187 Ok(_) if !request.overwrite => false,
3188 Ok(current) => current != *bytes,
3189 Err(error) if error.is_not_found() => true,
3190 Err(error) => return Err(error.into_anyhow("reading app preset cache")),
3191 };
3192 if differs {
3193 changed = true;
3194 if !request.dry_run {
3195 self.host
3196 .write_atomic(&destination, bytes)
3197 .await
3198 .map_err(|error| error.into_anyhow("writing app preset cache"))?;
3199 }
3200 }
3201 }
3202 }
3203 if request.remove && request.purge {
3204 let root = self.context.presets_dir.join(target);
3205 match self.host.metadata(&root).await {
3206 Ok(_) => {
3207 changed = true;
3208 if !request.dry_run {
3209 self.host
3210 .remove_dir_all(&root)
3211 .await
3212 .map_err(|error| error.into_anyhow("purging app preset cache"))?;
3213 }
3214 }
3215 Err(error) if error.is_not_found() => {}
3216 Err(error) => return Err(error.into_anyhow("inspecting app preset cache root")),
3217 }
3218 if target == "app" {
3219 let manifest = self.context.shine_dir.join("app-manifest.toml");
3220 match self.host.metadata(&manifest).await {
3221 Ok(_) => {
3222 changed = true;
3223 receipt_removed = true;
3224 if !request.dry_run {
3225 self.host
3226 .remove_file(&manifest)
3227 .await
3228 .map_err(|error| error.into_anyhow("purging App manifest"))?;
3229 }
3230 }
3231 Err(error) if error.is_not_found() => {}
3232 Err(error) => return Err(error.into_anyhow("inspecting App manifest")),
3233 }
3234 }
3235 }
3236 let status = match (request.dry_run, changed) {
3237 (true, true) => LifecycleStatus::Previewed,
3238 (false, true) => LifecycleStatus::Changed,
3239 _ => LifecycleStatus::Unchanged,
3240 };
3241 let mut effects = match (request.purge, request.remove, request.dry_run, changed) {
3242 (_, _, _, false) => Vec::new(),
3243 (true, true, true, true) => vec![LifecycleEffect::CacheRemovePreviewed],
3244 (true, true, false, true) => vec![LifecycleEffect::CachePurged],
3245 (false, true, true, true) => vec![LifecycleEffect::CacheRemovePreviewed],
3246 (false, true, false, true) => vec![LifecycleEffect::CacheRemoved],
3247 (_, false, true, true) => vec![LifecycleEffect::CacheWritePreviewed],
3248 (_, false, false, true) => vec![LifecycleEffect::CacheWritten],
3249 };
3250 if receipt_removed {
3251 effects.push(if request.dry_run {
3252 LifecycleEffect::ReceiptRemovePreviewed
3253 } else {
3254 LifecycleEffect::ReceiptRemoved
3255 });
3256 }
3257 Ok(LifecycleOutcomeV1::new(
3258 target.to_string(),
3259 Some(if request.purge {
3260 "purge"
3261 } else {
3262 "preset-cache"
3263 }),
3264 status,
3265 effects,
3266 ))
3267 }
3268}
3269
3270struct PreparedScript {
3271 program: String,
3272 args: Vec<String>,
3273 category_root: PathBuf,
3274}
3275
3276fn display_exit_code(code: Option<i32>) -> String {
3277 code.map_or_else(|| "signal".to_string(), |code| code.to_string())
3278}
3279
3280fn process_detail(output: &crate::runtime::ProcessOutput) -> String {
3281 let stderr = String::from_utf8_lossy(&output.stderr);
3282 let stdout = String::from_utf8_lossy(&output.stdout);
3283 let detail = if stderr.trim().is_empty() {
3284 stdout.trim()
3285 } else {
3286 stderr.trim()
3287 };
3288 if detail.is_empty() {
3289 String::new()
3290 } else {
3291 format!(": {detail}")
3292 }
3293}
3294
3295async fn load_manifest(
3296 host: &impl FileSystemHost,
3297 shine_dir: &std::path::Path,
3298) -> Result<AppManifest> {
3299 let path = shine_dir.join("app-manifest.toml");
3300 let mut manifest = match host.read(&path).await {
3301 Ok(bytes) => toml::from_slice(&bytes).context("failed to parse app manifest")?,
3302 Err(error) if error.is_not_found() => AppManifest::default(),
3303 Err(error) => return Err(error.into_anyhow("failed to read app manifest")),
3304 };
3305 match manifest.schema_version {
3306 0 => manifest.schema_version = crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION,
3307 crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION => {}
3308 version => bail!(
3309 "app manifest schema version {version} is newer than this Shine supports ({})",
3310 crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION
3311 ),
3312 }
3313 Ok(manifest)
3314}
3315
3316async fn save_manifest(
3317 host: &impl FileSystemHost,
3318 shine_dir: &std::path::Path,
3319 manifest: &AppManifest,
3320) -> Result<()> {
3321 let content = toml::to_string_pretty(manifest).context("failed to serialize app manifest")?;
3322 host.write_atomic(&shine_dir.join("app-manifest.toml"), content.as_bytes())
3323 .await
3324 .map_err(|error| error.into_anyhow("failed to write app manifest"))
3325}
3326
3327#[cfg(test)]
3328mod lifecycle_tests {
3329 use super::*;
3330 use crate::runtime::{
3331 FileSystemObservationHost, HostOperation, InMemoryHost, NullObserver, PresetSnapshot,
3332 PresetSourceKind, RuntimeContext, RuntimePlatform,
3333 };
3334 use std::future::Future;
3335 use std::path::Path;
3336 use std::pin::Pin;
3337
3338 fn runtime() -> CoreRuntime<InMemoryHost> {
3339 let home_dir = std::env::temp_dir().join("shine-core-app-lifecycle");
3340 let shine_dir = home_dir.join(".shine");
3341 let presets = PresetSnapshot::builder(PresetSourceKind::External)
3342 .file(
3343 "app/demo/shine.toml",
3344 b"dest = \"~/.config/demo\"\n[[files]]\nsource = \"config\"\n".to_vec(),
3345 )
3346 .file("app/demo/config", b"one".to_vec())
3347 .build();
3348 let mut context = RuntimeContext::isolated(
3349 home_dir,
3350 shine_dir.clone(),
3351 shine_dir.join("presets"),
3352 shine_dir.join("bin"),
3353 RuntimePlatform::Linux,
3354 );
3355 context.is_external_presets = true;
3356 CoreRuntime::new(InMemoryHost::new(), context, presets)
3357 }
3358
3359 struct Interaction;
3360 impl RuntimeInteraction for Interaction {
3361 fn confirm(&mut self, _code: &'static str, default: bool) -> Result<bool> {
3362 Ok(default)
3363 }
3364 fn authorize_admin<'a>(
3365 &'a mut self,
3366 _count: usize,
3367 ) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'a>> {
3368 Box::pin(async { Ok(true) })
3369 }
3370 fn select_many(
3371 &mut self,
3372 _code: &'static str,
3373 _choices: &[String],
3374 defaults: &[String],
3375 ) -> Result<Vec<String>> {
3376 Ok(defaults.to_vec())
3377 }
3378 }
3379
3380 #[test]
3381 fn artifact_env_allowlist_cannot_override_fixed_contract_values() {
3382 let mut runtime = runtime();
3383 runtime
3384 .context_mut_for_cli()
3385 .env
3386 .insert("USER_VALUE".to_string(), "override".to_string());
3387 let env = runtime.app_artifact_env(
3388 "demo",
3389 Path::new("/preset/app/demo"),
3390 &[EnvVarSpec {
3391 source: "USER_VALUE".to_string(),
3392 target: "SHINE_APP_ID".to_string(),
3393 }],
3394 );
3395 assert_eq!(env.get("SHINE_APP_ID").map(String::as_str), Some("demo"));
3396 }
3397
3398 #[tokio::test]
3399 async fn privileged_app_transaction_acquires_host_lock_before_mutation() {
3400 let host = InMemoryHost::new();
3401 install_privileged_bytes(
3402 &host,
3403 b"managed",
3404 Path::new("/etc/demo"),
3405 false,
3406 false,
3407 false,
3408 )
3409 .await
3410 .unwrap();
3411
3412 let operations = host.operations();
3413 let lock = operations
3414 .iter()
3415 .position(|operation| matches!(operation, HostOperation::AcquirePrivilegedOperation))
3416 .unwrap();
3417 let write = operations
3418 .iter()
3419 .position(|operation| matches!(operation, HostOperation::Write(path) if path == Path::new("/etc/demo")))
3420 .unwrap();
3421 assert!(lock < write);
3422 }
3423
3424 #[tokio::test]
3425 async fn app_executor_roundtrip_and_target_isolation_use_in_memory_host() {
3426 let runtime = runtime();
3427 let home_dir = runtime.context().home_dir.clone();
3428 let mut observer = NullObserver;
3429 let mut interaction = Interaction;
3430 let installed = runtime
3431 .install_apps(
3432 AppLifecycleRequest {
3433 target: Some("demo".into()),
3434 dry_run: false,
3435 force: false,
3436 },
3437 &mut observer,
3438 &mut interaction,
3439 )
3440 .await
3441 .unwrap();
3442 assert_eq!(installed.lifecycle.summary().changed, 1);
3443 let unchanged = runtime
3444 .install_apps(
3445 AppLifecycleRequest {
3446 target: Some("demo".into()),
3447 dry_run: false,
3448 force: false,
3449 },
3450 &mut observer,
3451 &mut interaction,
3452 )
3453 .await
3454 .unwrap();
3455 assert_eq!(unchanged.lifecycle.summary().unchanged, 1);
3456
3457 runtime
3458 .host()
3459 .put_file(home_dir.join("other"), b"other".to_vec());
3460 let removed = runtime
3461 .uninstall_apps(
3462 AppUninstallLifecycleRequest {
3463 target: Some("demo".into()),
3464 dry_run: false,
3465 force: false,
3466 purge: false,
3467 },
3468 &mut observer,
3469 &mut interaction,
3470 )
3471 .await
3472 .unwrap();
3473 assert_eq!(removed.lifecycle.summary().changed, 1);
3474 assert!(
3475 runtime
3476 .host()
3477 .read(&home_dir.join(".config/demo/config"))
3478 .await
3479 .is_err()
3480 );
3481 assert_eq!(
3482 runtime.host().read(&home_dir.join("other")).await.unwrap(),
3483 b"other"
3484 );
3485 }
3486}