Skip to main content

shine_core/runtime/
validation.rs

1//! Core-owned static preset discovery and validation contract.
2
3use 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}