1use super::{
4 CoreRuntime, FileKind, FileSystemObservationHost, InMemoryHost, PresetSnapshot,
5 PresetSourceKind, RuntimeContext, RuntimePlatform, SysDriverKind, SysInstall,
6};
7use crate::permission::PermissionDeclarationV1;
8use schemars::JsonSchema;
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11
12pub const PRESET_VALIDATION_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Clone, Copy, Debug, Eq, JsonSchema, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum PresetDiagnosticSeverity {
17 Error,
18 Warning,
19}
20
21#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
22pub struct PresetDiagnostic {
23 pub severity: PresetDiagnosticSeverity,
24 pub code: String,
25 pub message: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub path: Option<PathBuf>,
28}
29
30#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
31pub struct PresetValidationSummary {
32 pub categories: usize,
33 pub errors: usize,
34 pub warnings: usize,
35}
36
37#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
38pub struct PresetCategoryValidation {
39 pub kind: String,
40 pub name: String,
41 pub path: PathBuf,
42 pub valid: bool,
43 pub diagnostics: Vec<PresetDiagnostic>,
44}
45
46#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
47pub struct PresetValidationReportV1 {
48 pub schema_version: u32,
49 pub valid: bool,
50 pub path: PathBuf,
51 pub summary: PresetValidationSummary,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub diagnostics: Vec<PresetDiagnostic>,
54 pub categories: Vec<PresetCategoryValidation>,
55}
56
57#[derive(Clone, Debug)]
58pub(super) struct CategoryPath {
59 pub(super) kind: &'static str,
60 pub(super) name: String,
61 pub(super) root: PathBuf,
62}
63
64#[derive(Clone)]
65pub(super) struct PresetSourceScope {
66 pub(super) canonical: PathBuf,
67 pub(super) categories: Vec<CategoryPath>,
68 pub(super) repository_root: PathBuf,
69 pub(super) snapshot: PresetSnapshot,
70}
71
72pub async fn validate_preset_path(
73 host: &impl FileSystemObservationHost,
74 cwd: &Path,
75 path: &Path,
76) -> PresetValidationReportV1 {
77 let display_path = absolute_path(cwd, path);
78 let scope = match load_preset_source_scope(host, cwd, path).await {
79 Ok(scope) => scope,
80 Err(diagnostic) => {
81 let report_path = diagnostic.path.clone().unwrap_or(display_path);
82 return finish(report_path, vec![diagnostic], Vec::new());
83 }
84 };
85 validate_preset_source_scope(&scope).await
86}
87
88pub(super) async fn load_preset_source_scope(
89 host: &impl FileSystemObservationHost,
90 cwd: &Path,
91 path: &Path,
92) -> Result<PresetSourceScope, PresetDiagnostic> {
93 let display_path = absolute_path(cwd, path);
94 let canonical = host
95 .canonicalize(&display_path)
96 .await
97 .map_err(|error_value| {
98 error(
99 "invalid_input",
100 format!(
101 "cannot resolve preset path {}: {:#}",
102 display_path.display(),
103 error_value.into_anyhow("canonicalizing preset path")
104 ),
105 &display_path,
106 )
107 })?;
108 let categories = discover_categories(host, &canonical).await?;
109 if categories.is_empty() {
110 return Err(error(
111 "invalid_input",
112 "no preset categories found directly under app/, shell/, or sys/",
113 &canonical,
114 ));
115 }
116 let repository_root = common_repository_root(&categories);
117 let snapshot = snapshot_tree(host, &repository_root).await?;
118 Ok(PresetSourceScope {
119 canonical,
120 categories,
121 repository_root,
122 snapshot,
123 })
124}
125
126pub(super) async fn validate_preset_source_scope(
127 scope: &PresetSourceScope,
128) -> PresetValidationReportV1 {
129 let validation_home = scope.repository_root.join(".shine-validation-home");
130 let mut reports = Vec::new();
131 for category in scope.categories.iter().cloned() {
132 let mut diagnostics = permission_declaration_diagnostics(&scope.snapshot, &category);
133 let permission_error = diagnostics
134 .iter()
135 .any(|diagnostic| diagnostic.severity == PresetDiagnosticSeverity::Error);
136 let mut diagnostic = if category.kind == "app" {
137 (!permission_error)
138 .then(|| validate_all_app_destination_branches(&scope.snapshot, &category))
139 .transpose()
140 .err()
141 .map(|error| error_diagnostic("app", &category.root, format!("{error:#}")))
142 } else {
143 None
144 };
145 let mut has_metadata = scope
146 .snapshot
147 .get(&format!("{}/{}/shine.toml", category.kind, category.name))
148 .is_some();
149 for platform in RuntimePlatform::ALL {
150 if permission_error || diagnostic.is_some() {
151 break;
152 }
153 let mut context = RuntimeContext::isolated(
154 validation_home.clone(),
155 validation_home.join(".shine"),
156 scope.repository_root.clone(),
157 validation_home.join(".shine/bin"),
158 platform,
159 );
160 context.shell = if platform == RuntimePlatform::Windows {
161 super::ShellType::PowerShell
162 } else {
163 super::ShellType::Zsh
164 };
165 context.is_external_presets = true;
166 let runtime = CoreRuntime::new(InMemoryHost::new(), context, scope.snapshot.clone());
167 let result = match category.kind {
168 "app" => runtime.validate_app_category_snapshot(&category.name),
169 "shell" => {
170 runtime
171 .validate_shell_category_snapshot(&category.name)
172 .await
173 }
174 "sys" => validate_sys_category(&runtime, &category).await,
175 _ => unreachable!(),
176 };
177 match result {
178 Ok(metadata) => has_metadata = metadata,
179 Err(error) => {
180 diagnostic = Some(error_diagnostic(
181 category.kind,
182 &category.root,
183 format!("{error:#} for {}", platform.as_str()),
184 ));
185 break;
186 }
187 }
188 }
189 diagnostics.extend(diagnostic);
190 if !has_metadata
191 && !diagnostics
192 .iter()
193 .any(|diagnostic| diagnostic.severity == PresetDiagnosticSeverity::Error)
194 {
195 diagnostics.push(PresetDiagnostic {
196 severity: PresetDiagnosticSeverity::Warning,
197 code: "legacy_metadata".to_string(),
198 message: format!(
199 "{}/{} has no shine.toml; compatibility auto-discovery is accepted, but explicit metadata is recommended",
200 category.kind, category.name
201 ),
202 path: Some(category.root.clone()),
203 });
204 }
205 diagnostics.sort_by_key(|diagnostic| match diagnostic.severity {
206 PresetDiagnosticSeverity::Error => 0,
207 PresetDiagnosticSeverity::Warning => 1,
208 });
209 let valid = diagnostics
210 .iter()
211 .all(|diagnostic| diagnostic.severity != PresetDiagnosticSeverity::Error);
212 reports.push(PresetCategoryValidation {
213 kind: category.kind.to_string(),
214 name: category.name,
215 path: category.root,
216 valid,
217 diagnostics,
218 });
219 }
220 finish(scope.canonical.clone(), Vec::new(), reports)
221}
222
223fn permission_declaration_diagnostics(
224 snapshot: &PresetSnapshot,
225 category: &CategoryPath,
226) -> Vec<PresetDiagnostic> {
227 let logical = format!("{}/{}/shine.toml", category.kind, category.name);
228 let Some(bytes) = snapshot.get(&logical) else {
229 return Vec::new();
230 };
231 let Ok(value) = toml::from_slice::<toml::Value>(bytes) else {
232 return Vec::new();
233 };
234 let Some(table) = value.as_table() else {
235 return Vec::new();
236 };
237 let path = category.root.join("shine.toml");
238 match category.kind {
239 "app" => validate_app_permissions(table, category, &path),
240 "shell" => validate_shell_permissions(table, category, &path),
241 "sys" => validate_sys_permissions(table, category, &path),
242 _ => unreachable!(),
243 }
244}
245
246fn validate_app_permissions(
247 table: &toml::Table,
248 category: &CategoryPath,
249 path: &Path,
250) -> Vec<PresetDiagnostic> {
251 let target = format!("app/{}", category.name);
252 let mut diagnostics = Vec::new();
253 match table.get("permissions") {
254 Some(value) => {
255 if let Some(diagnostic) = validate_permission_value(value, &target, path) {
256 diagnostics.push(diagnostic);
257 }
258 }
259 None => diagnostics.push(missing_permission_diagnostic(&target, path)),
260 }
261 if table
262 .get("files")
263 .and_then(toml::Value::as_array)
264 .is_some_and(|files| files.iter().any(|file| file.get("permissions").is_some()))
265 {
266 diagnostics.push(permission_error_diagnostic(
267 "invalid_permission_declaration",
268 format!(
269 "{target} must declare permissions at the App category root, not inside `[[files]]`"
270 ),
271 path,
272 ));
273 }
274 if let Some(artifact) = table.get("artifact").and_then(toml::Value::as_table) {
275 let declared = table
276 .get("permissions")
277 .and_then(toml::Value::as_table)
278 .and_then(|permissions| permissions.get("environment"))
279 .and_then(toml::Value::as_array)
280 .into_iter()
281 .flatten()
282 .filter_map(|entry| entry.get("name").and_then(toml::Value::as_str))
283 .collect::<std::collections::BTreeSet<_>>();
284 for source in artifact
285 .get("env")
286 .and_then(toml::Value::as_array)
287 .into_iter()
288 .flatten()
289 .filter_map(toml::Value::as_str)
290 .map(|spec| spec.split_once('=').map_or(spec, |(source, _)| source))
291 {
292 if !declared.contains(source) {
293 diagnostics.push(permission_error_diagnostic(
294 "undeclared_artifact_environment",
295 format!(
296 "{target} artifact environment source `{source}` must appear in `[permissions].environment`"
297 ),
298 path,
299 ));
300 }
301 }
302 }
303 diagnostics
304}
305
306fn validate_shell_permissions(
307 table: &toml::Table,
308 category: &CategoryPath,
309 path: &Path,
310) -> Vec<PresetDiagnostic> {
311 let mut diagnostics = Vec::new();
312 if table.contains_key("permissions") {
313 diagnostics.push(permission_error_diagnostic(
314 "invalid_permission_declaration",
315 format!(
316 "shell/{} must declare permissions inside each `[[files]]` entry",
317 category.name
318 ),
319 path,
320 ));
321 }
322 let Some(files) = table.get("files").and_then(toml::Value::as_array) else {
323 return diagnostics;
324 };
325 for (index, file) in files.iter().enumerate() {
326 let identity = file
327 .get("target")
328 .or_else(|| file.get("source"))
329 .and_then(toml::Value::as_str)
330 .unwrap_or("unknown");
331 let target = format!("shell/{}/{identity}", category.name);
332 match file.get("permissions") {
333 Some(value) => {
334 if let Some(diagnostic) = validate_permission_value(value, &target, path) {
335 diagnostics.push(diagnostic);
336 }
337 }
338 None => diagnostics.push(PresetDiagnostic {
339 severity: PresetDiagnosticSeverity::Warning,
340 code: "missing_permission_declaration".to_string(),
341 message: format!(
342 "{target} (`[[files]]` entry {}) has no versioned permission declaration; compatibility execution is unchanged",
343 index + 1
344 ),
345 path: Some(path.to_path_buf()),
346 }),
347 }
348 }
349 diagnostics
350}
351
352fn validate_sys_permissions(
353 table: &toml::Table,
354 category: &CategoryPath,
355 path: &Path,
356) -> Vec<PresetDiagnostic> {
357 let mut diagnostics = Vec::new();
358 if table.contains_key("permissions") {
359 diagnostics.push(permission_error_diagnostic(
360 "invalid_permission_declaration",
361 format!(
362 "sys/{} must declare permissions inside each `[[items]]` entry",
363 category.name
364 ),
365 path,
366 ));
367 }
368 let Some(items) = table.get("items").and_then(toml::Value::as_array) else {
369 return diagnostics;
370 };
371 for (index, item) in items.iter().enumerate() {
372 let identity = item
373 .get("id")
374 .and_then(toml::Value::as_str)
375 .unwrap_or("unknown");
376 let target = format!("sys/{identity}");
377 match item.get("permissions") {
378 Some(value) => {
379 if let Some(diagnostic) = validate_permission_value(value, &target, path) {
380 diagnostics.push(diagnostic);
381 }
382 }
383 None => diagnostics.push(PresetDiagnostic {
384 severity: PresetDiagnosticSeverity::Warning,
385 code: "missing_permission_declaration".to_string(),
386 message: format!(
387 "{target} (`[[items]]` entry {}) has no versioned permission declaration; compatibility execution is unchanged",
388 index + 1
389 ),
390 path: Some(path.to_path_buf()),
391 }),
392 }
393 }
394 diagnostics
395}
396
397fn validate_permission_value(
398 value: &toml::Value,
399 target: &str,
400 path: &Path,
401) -> Option<PresetDiagnostic> {
402 let declaration = match value.clone().try_into::<PermissionDeclarationV1>() {
403 Ok(declaration) => declaration,
404 Err(_) => {
405 return Some(permission_error_diagnostic(
406 "invalid_permission_declaration",
407 format!(
408 "{target} has malformed permission fields or fields unsupported by this schema"
409 ),
410 path,
411 ));
412 }
413 };
414 declaration.validate().err().map(|error| {
415 permission_error_diagnostic(error.diagnostic_code(), format!("{target}: {error}"), path)
416 })
417}
418
419fn missing_permission_diagnostic(target: &str, path: &Path) -> PresetDiagnostic {
420 PresetDiagnostic {
421 severity: PresetDiagnosticSeverity::Warning,
422 code: "missing_permission_declaration".to_string(),
423 message: format!(
424 "{target} has no versioned permission declaration; compatibility execution is unchanged"
425 ),
426 path: Some(path.to_path_buf()),
427 }
428}
429
430fn permission_error_diagnostic(code: &str, message: String, path: &Path) -> PresetDiagnostic {
431 PresetDiagnostic {
432 severity: PresetDiagnosticSeverity::Error,
433 code: code.to_string(),
434 message,
435 path: Some(path.to_path_buf()),
436 }
437}
438
439fn validate_all_app_destination_branches(
440 snapshot: &PresetSnapshot,
441 category: &CategoryPath,
442) -> anyhow::Result<()> {
443 let logical = format!("app/{}/shine.toml", category.name);
444 let Some(bytes) = snapshot.get(&logical) else {
445 return Ok(());
446 };
447 let value: toml::Value = toml::from_slice(bytes)?;
448 if let Some(destination) = value.get("dest") {
449 validate_destination_value(destination)?;
450 }
451 if let Some(files) = value.get("files").and_then(toml::Value::as_array) {
452 for file in files {
453 if let Some(destination) = file.get("dest") {
454 validate_destination_value(destination)?;
455 }
456 }
457 }
458 Ok(())
459}
460
461fn validate_destination_value(value: &toml::Value) -> anyhow::Result<()> {
462 match value {
463 toml::Value::String(path) => validate_destination_path(path),
464 toml::Value::Table(table) if table.contains_key("base") => {
465 let path = table
466 .get("path")
467 .and_then(toml::Value::as_str)
468 .ok_or_else(|| anyhow::anyhow!("data-dir destination requires path"))?;
469 if !is_portable_relative_path(path) {
470 anyhow::bail!(
471 "data-dir destination path must be relative and stay inside its root"
472 );
473 }
474 Ok(())
475 }
476 toml::Value::Table(table) => {
477 for (platform, value) in table {
478 let path = value.as_str().ok_or_else(|| {
479 anyhow::anyhow!("destination for {platform} must be a string")
480 })?;
481 validate_destination_path(path)
482 .map_err(|error| anyhow::anyhow!("invalid {platform} destination: {error}"))?;
483 }
484 Ok(())
485 }
486 _ => anyhow::bail!("destination must be a path string or platform table"),
487 }
488}
489
490fn validate_destination_path(path: &str) -> anyhow::Result<()> {
491 if !is_portable_absolute_destination(path) {
492 anyhow::bail!("destination root must be absolute after expansion");
493 }
494 if has_parent_segment(path) {
495 anyhow::bail!("destination root must not contain '..'");
496 }
497 Ok(())
498}
499
500fn is_portable_absolute_destination(path: &str) -> bool {
501 path == "~"
502 || path == "$HOME"
503 || path.starts_with("~/")
504 || path.starts_with("~\\")
505 || path.starts_with("$HOME/")
506 || path.starts_with('/')
507 || is_windows_absolute(path)
508}
509
510fn is_windows_absolute(path: &str) -> bool {
511 let bytes = path.as_bytes();
512 let drive = bytes.len() >= 3
513 && bytes[0].is_ascii_alphabetic()
514 && bytes[1] == b':'
515 && matches!(bytes[2], b'/' | b'\\');
516 drive || path.starts_with("\\\\")
517}
518
519fn is_portable_relative_path(path: &str) -> bool {
520 !path.is_empty()
521 && !path.starts_with('/')
522 && !path.starts_with('\\')
523 && !is_windows_drive_path(path)
524 && !has_parent_segment(path)
525}
526
527fn is_windows_drive_path(path: &str) -> bool {
528 let bytes = path.as_bytes();
529 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
530}
531
532fn has_parent_segment(path: &str) -> bool {
533 path.split(['/', '\\']).any(|part| part == "..")
534}
535
536async fn validate_sys_category(
537 runtime: &CoreRuntime<InMemoryHost>,
538 category: &CategoryPath,
539) -> anyhow::Result<bool> {
540 let logical = format!("sys/{}/shine.toml", category.name);
541 let bytes = runtime
542 .presets()
543 .get(&logical)
544 .ok_or_else(|| anyhow::anyhow!("sys/{} requires a readable shine.toml", category.name))?;
545 let text = std::str::from_utf8(bytes)?;
546 let manifest = super::parse_sys_manifest(text)?;
547 for item in &manifest.items {
548 if let Some(SysInstall::Script { path, .. }) = &item.install {
549 validate_snapshot_reference(runtime.presets(), &category.name, path, "install script")?;
550 }
551 for integration in &item.shell {
552 if let Some(fragment) = &integration.fragment {
553 let bytes = validate_snapshot_reference(
554 runtime.presets(),
555 &category.name,
556 fragment,
557 "profile fragment",
558 )?;
559 std::str::from_utf8(bytes).map_err(|error| {
560 anyhow::anyhow!("profile fragment must be valid UTF-8: {error}")
561 })?;
562 }
563 }
564 if item.driver == SysDriverKind::ManagedFile {
565 let source = item
566 .config
567 .get("source")
568 .and_then(toml::Value::as_str)
569 .ok_or_else(|| anyhow::anyhow!("managed-file requires source"))?;
570 validate_snapshot_reference(
571 runtime.presets(),
572 &category.name,
573 source,
574 "managed-file source",
575 )?;
576 if let Some(transforms) = item
577 .config
578 .get("transforms")
579 .and_then(toml::Value::as_array)
580 {
581 let specs = transforms
582 .iter()
583 .map(|value| {
584 value.as_str().map(str::to_string).ok_or_else(|| {
585 anyhow::anyhow!("managed-file transforms must be strings")
586 })
587 })
588 .collect::<anyhow::Result<Vec<_>>>()?;
589 crate::install::transforms::validate(&specs)?;
590 }
591 let target = item
592 .config
593 .get("target")
594 .and_then(toml::Value::as_str)
595 .ok_or_else(|| anyhow::anyhow!("managed-file requires target"))?;
596 if !is_portable_absolute_destination(target) {
597 anyhow::bail!("managed-file target must resolve to an absolute path");
598 }
599 }
600 }
601 Ok(true)
602}
603
604fn validate_snapshot_reference<'a>(
605 snapshot: &'a PresetSnapshot,
606 category: &str,
607 relative: &str,
608 label: &str,
609) -> anyhow::Result<&'a [u8]> {
610 if !is_portable_relative_path(relative) {
611 anyhow::bail!("{label} must be a file inside the preset category");
612 }
613 let path = Path::new(relative);
614 snapshot
615 .get(&format!("sys/{category}/{}", logical_path(path)))
616 .ok_or_else(|| anyhow::anyhow!("{label} is missing or unreadable"))
617}
618
619fn error_diagnostic(kind: &str, root: &Path, message: String) -> PresetDiagnostic {
620 let lower = message.to_ascii_lowercase();
621 let code = if lower.contains("references missing")
622 || lower.contains("is missing or unreadable")
623 || lower.contains("source file is missing")
624 {
625 "missing_reference"
626 } else if lower.contains("more than once") || lower.contains("duplicate command") {
627 "duplicate_command"
628 } else if lower.contains("destinations conflict")
629 || lower.contains("same effective destination")
630 {
631 "duplicate_target"
632 } else if lower.contains("bun") && (lower.contains("lock") || lower.contains("package")) {
633 "bun_dependency_policy"
634 } else if lower.contains("contains no") || lower.contains("no shell") {
635 "no_files"
636 } else if kind == "sys" && lower.contains("requires a readable shine.toml") {
637 "missing_metadata"
638 } else {
639 "invalid_metadata"
640 };
641 PresetDiagnostic {
642 severity: PresetDiagnosticSeverity::Error,
643 code: code.to_string(),
644 message,
645 path: Some(root.join("shine.toml")),
646 }
647}
648
649async fn discover_categories(
650 host: &impl FileSystemObservationHost,
651 path: &Path,
652) -> Result<Vec<CategoryPath>, PresetDiagnostic> {
653 let metadata = host.metadata(path).await.map_err(|error_value| {
654 error(
655 "invalid_input",
656 format!(
657 "cannot inspect {}: {:#}",
658 path.display(),
659 error_value.into_anyhow("inspecting preset path")
660 ),
661 path,
662 )
663 })?;
664 if metadata.kind == FileKind::File {
665 if path.file_name().and_then(|name| name.to_str()) != Some("shine.toml") {
666 return Err(error(
667 "invalid_input",
668 "preset manifest input must be named shine.toml",
669 path,
670 ));
671 }
672 return Ok(vec![category_from_root(
673 path.parent().expect("canonical file parent"),
674 )?]);
675 }
676 if metadata.kind != FileKind::Directory {
677 return Err(error(
678 "invalid_input",
679 "preset path must be a directory or shine.toml",
680 path,
681 ));
682 }
683 if path
684 .parent()
685 .and_then(Path::file_name)
686 .and_then(|name| name.to_str())
687 .is_some_and(is_kind)
688 {
689 return Ok(vec![category_from_root(path)?]);
690 }
691 let mut categories = Vec::new();
692 for kind in ["app", "shell", "sys"] {
693 let kind_root = path.join(kind);
694 let Ok(metadata) = host.metadata(&kind_root).await else {
695 continue;
696 };
697 if metadata.kind != FileKind::Directory {
698 continue;
699 }
700 let entries = host.read_dir(&kind_root).await.map_err(|error_value| {
701 error(
702 "read_failed",
703 format!(
704 "cannot read {}: {:#}",
705 kind_root.display(),
706 error_value.into_anyhow("reading preset category directory")
707 ),
708 &kind_root,
709 )
710 })?;
711 for entry in entries {
712 let entry_metadata = host.metadata(&entry).await.map_err(|error_value| {
713 error(
714 "read_failed",
715 format!(
716 "{:#}",
717 error_value.into_anyhow("inspecting preset category")
718 ),
719 &entry,
720 )
721 })?;
722 if entry_metadata.kind != FileKind::Directory {
723 continue;
724 }
725 categories.push(CategoryPath {
726 kind,
727 name: entry
728 .file_name()
729 .unwrap_or_default()
730 .to_string_lossy()
731 .to_string(),
732 root: host.canonicalize(&entry).await.map_err(|error_value| {
733 error(
734 "read_failed",
735 format!(
736 "{:#}",
737 error_value.into_anyhow("canonicalizing preset category")
738 ),
739 &kind_root,
740 )
741 })?,
742 });
743 }
744 }
745 categories.sort_by(|left, right| {
746 (left.kind, left.name.as_str()).cmp(&(right.kind, right.name.as_str()))
747 });
748 Ok(categories)
749}
750
751fn category_from_root(root: &Path) -> Result<CategoryPath, PresetDiagnostic> {
752 let kind = root
753 .parent()
754 .and_then(Path::file_name)
755 .and_then(|name| name.to_str())
756 .filter(|kind| is_kind(kind))
757 .ok_or_else(|| {
758 error(
759 "invalid_input",
760 "category directory must be app/<name>, shell/<name>, or sys/<name>",
761 root,
762 )
763 })?;
764 let name = root
765 .file_name()
766 .and_then(|name| name.to_str())
767 .filter(|name| !name.is_empty())
768 .ok_or_else(|| {
769 error(
770 "invalid_input",
771 "preset category name must be valid UTF-8",
772 root,
773 )
774 })?;
775 Ok(CategoryPath {
776 kind: match kind {
777 "app" => "app",
778 "shell" => "shell",
779 "sys" => "sys",
780 _ => unreachable!(),
781 },
782 name: name.to_string(),
783 root: root.to_path_buf(),
784 })
785}
786
787async fn snapshot_tree(
788 host: &impl FileSystemObservationHost,
789 root: &Path,
790) -> Result<PresetSnapshot, PresetDiagnostic> {
791 let mut builder =
792 PresetSnapshot::builder(PresetSourceKind::External).base_root(root.to_path_buf());
793 let mut pending = vec![root.to_path_buf()];
794 while let Some(directory) = pending.pop() {
795 for entry in host.read_dir(&directory).await.map_err(|error_value| {
796 error(
797 "read_failed",
798 format!("{:#}", error_value.into_anyhow("reading preset snapshot")),
799 &directory,
800 )
801 })? {
802 let kind = host.metadata(&entry).await.map_err(|error_value| {
803 error(
804 "read_failed",
805 format!(
806 "{:#}",
807 error_value.into_anyhow("inspecting preset snapshot entry")
808 ),
809 &entry,
810 )
811 })?;
812 if kind.kind == FileKind::Directory {
813 if entry.file_name().is_none_or(|name| name != "node_modules") {
814 pending.push(entry);
815 }
816 } else if kind.kind == FileKind::File {
817 let relative = entry
818 .strip_prefix(root)
819 .map_err(|error_value| error("read_failed", error_value.to_string(), &entry))?
820 .to_path_buf();
821 let bytes = host.read(&entry).await.map_err(|error_value| {
822 error(
823 "read_failed",
824 format!(
825 "{:#}",
826 error_value.into_anyhow("reading preset snapshot file")
827 ),
828 &entry,
829 )
830 })?;
831 builder = builder.file(logical_path(&relative), bytes);
832 }
833 }
834 }
835 Ok(builder.build())
836}
837
838fn common_repository_root(categories: &[CategoryPath]) -> PathBuf {
839 categories
840 .first()
841 .and_then(|category| category.root.parent())
842 .and_then(Path::parent)
843 .unwrap_or_else(|| Path::new("."))
844 .to_path_buf()
845}
846
847fn logical_path(path: &Path) -> String {
848 path.components()
849 .map(|part| part.as_os_str().to_string_lossy())
850 .collect::<Vec<_>>()
851 .join("/")
852}
853
854fn absolute_path(cwd: &Path, path: &Path) -> PathBuf {
855 if path.is_absolute() {
856 path.to_path_buf()
857 } else {
858 cwd.join(path)
859 }
860}
861
862fn is_kind(value: &str) -> bool {
863 matches!(value, "app" | "shell" | "sys")
864}
865
866fn error(code: &str, message: impl Into<String>, path: &Path) -> PresetDiagnostic {
867 PresetDiagnostic {
868 severity: PresetDiagnosticSeverity::Error,
869 code: code.to_string(),
870 message: message.into(),
871 path: Some(path.to_path_buf()),
872 }
873}
874
875fn finish(
876 path: PathBuf,
877 diagnostics: Vec<PresetDiagnostic>,
878 categories: Vec<PresetCategoryValidation>,
879) -> PresetValidationReportV1 {
880 let (errors, warnings) = diagnostics
881 .iter()
882 .chain(categories.iter().flat_map(|category| &category.diagnostics))
883 .fold((0, 0), |(errors, warnings), diagnostic| {
884 match diagnostic.severity {
885 PresetDiagnosticSeverity::Error => (errors + 1, warnings),
886 PresetDiagnosticSeverity::Warning => (errors, warnings + 1),
887 }
888 });
889 PresetValidationReportV1 {
890 schema_version: PRESET_VALIDATION_SCHEMA_VERSION,
891 valid: errors == 0,
892 path,
893 summary: PresetValidationSummary {
894 categories: categories.len(),
895 errors,
896 warnings,
897 },
898 diagnostics,
899 categories,
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906
907 #[test]
908 fn destination_validation_is_independent_of_host_path_syntax() {
909 for path in [
910 "~",
911 "~/.config/tool",
912 r"~\AppData\Roaming\tool",
913 "$HOME",
914 "$HOME/.config/tool",
915 "/etc/tool",
916 r"C:\ProgramData\tool",
917 r"\\server\share\tool",
918 ] {
919 validate_destination_path(path).unwrap_or_else(|error| {
920 panic!("expected {path:?} to be a portable absolute destination: {error:#}")
921 });
922 }
923
924 for path in ["relative/tool", r"C:relative\tool", r"\rooted\tool"] {
925 assert!(
926 validate_destination_path(path).is_err(),
927 "expected {path:?} to be rejected"
928 );
929 }
930 for path in ["~/../tool", r"C:\ProgramData\..\tool"] {
931 assert!(
932 validate_destination_path(path).is_err(),
933 "expected {path:?} to reject a parent segment"
934 );
935 }
936 }
937
938 #[test]
939 fn relative_validation_rejects_foreign_platform_roots() {
940 for path in ["nested/file", r"nested\file"] {
941 assert!(
942 is_portable_relative_path(path),
943 "expected {path:?} to be relative"
944 );
945 }
946 for path in [
947 "",
948 "/absolute/file",
949 r"\rooted\file",
950 r"C:\absolute\file",
951 r"C:drive-relative\file",
952 "../outside",
953 r"..\outside",
954 ] {
955 assert!(
956 !is_portable_relative_path(path),
957 "expected {path:?} to be rejected"
958 );
959 }
960 }
961
962 #[test]
963 fn permission_validation_uses_the_effective_overlay_manifest() {
964 let category = CategoryPath {
965 kind: "app",
966 name: "demo".to_string(),
967 root: PathBuf::from("app/demo"),
968 };
969 let snapshot = PresetSnapshot::builder(PresetSourceKind::External)
970 .file(
971 "app/demo/shine.toml",
972 b"dest = '~/.config/demo'\n[permissions]\nschema_version = 1\n".to_vec(),
973 )
974 .overlay_file("app/demo/shine.toml", b"dest = '~/.config/demo'\n".to_vec())
975 .build();
976
977 let diagnostics = permission_declaration_diagnostics(&snapshot, &category);
978 assert_eq!(diagnostics.len(), 1);
979 assert_eq!(diagnostics[0].code, "missing_permission_declaration");
980
981 let invalid = PresetSnapshot::builder(PresetSourceKind::External)
982 .file(
983 "app/demo/shine.toml",
984 b"dest = '~/.config/demo'\n[permissions]\nschema_version = 1\n".to_vec(),
985 )
986 .overlay_file(
987 "app/demo/shine.toml",
988 b"dest = '~/.config/demo'\n[permissions]\nschema_version = 2\n".to_vec(),
989 )
990 .build();
991 let diagnostics = permission_declaration_diagnostics(&invalid, &category);
992 assert_eq!(diagnostics[0].code, "unsupported_permission_schema");
993 }
994
995 #[test]
996 fn permission_declarations_use_each_domain_target_placement() {
997 let snapshot = PresetSnapshot::builder(PresetSourceKind::External)
998 .file(
999 "app/demo/shine.toml",
1000 b"dest = '~/.config/demo'\n[permissions]\nschema_version = 1\n".to_vec(),
1001 )
1002 .file(
1003 "shell/demo/shine.toml",
1004 b"[[files]]\nsource = 'tool.sh'\n[files.permissions]\nschema_version = 1\n"
1005 .to_vec(),
1006 )
1007 .file(
1008 "sys/demo/shine.toml",
1009 b"version = 2\n[[items]]\nid = 'tool'\nlabel = 'Tool'\n[items.permissions]\nschema_version = 1\n"
1010 .to_vec(),
1011 )
1012 .build();
1013
1014 for kind in ["app", "shell", "sys"] {
1015 let category = CategoryPath {
1016 kind,
1017 name: "demo".to_string(),
1018 root: PathBuf::from(format!("{kind}/demo")),
1019 };
1020 assert!(
1021 permission_declaration_diagnostics(&snapshot, &category).is_empty(),
1022 "{kind} declaration should be accepted at its target-local placement"
1023 );
1024 }
1025 }
1026
1027 #[test]
1028 fn app_artifact_environment_sources_require_permission_declarations() {
1029 let category = CategoryPath {
1030 kind: "app",
1031 name: "demo".to_string(),
1032 root: PathBuf::from("app/demo"),
1033 };
1034 let missing = PresetSnapshot::builder(PresetSourceKind::External)
1035 .file(
1036 "app/demo/shine.toml",
1037 b"dest = '~/.config/demo'\n[artifact]\nscript = 'build.sh'\nenv = ['TOKEN=API_TOKEN']\n[permissions]\nschema_version = 1\n"
1038 .to_vec(),
1039 )
1040 .build();
1041 let diagnostics = permission_declaration_diagnostics(&missing, &category);
1042 assert!(
1043 diagnostics
1044 .iter()
1045 .any(|diagnostic| diagnostic.code == "undeclared_artifact_environment")
1046 );
1047
1048 let declared = PresetSnapshot::builder(PresetSourceKind::External)
1049 .file(
1050 "app/demo/shine.toml",
1051 b"dest = '~/.config/demo'\n[artifact]\nscript = 'build.sh'\nenv = ['TOKEN=API_TOKEN']\n[permissions]\nschema_version = 1\nenvironment = [{ name = 'TOKEN', sensitivity = 'secret' }]\n"
1052 .to_vec(),
1053 )
1054 .build();
1055 assert!(permission_declaration_diagnostics(&declared, &category).is_empty());
1056 }
1057}