Skip to main content

shine_core/
permission.rs

1//! Versioned authoring declarations for Preset permissions.
2//!
3//! These declarations describe reviewable capability identities. They do not
4//! grant execution permission and are deliberately separate from the
5//! snapshot-bound [`crate::plan::PlanV1`] wire contract.
6
7use crate::plan::{
8    EnvironmentSensitivityV1, FilesystemAccessV1, NetworkScopeV1, PermissionSetV1, PermissionV1,
9};
10use serde::Deserialize;
11use std::collections::BTreeSet;
12use std::fmt;
13
14pub const PERMISSION_DECLARATION_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct PermissionDeclarationV1 {
19    pub schema_version: u32,
20    #[serde(default)]
21    pub administrator: bool,
22    #[serde(default)]
23    pub filesystem: Vec<FilesystemPermissionDeclarationV1>,
24    #[serde(default)]
25    pub network: Vec<NetworkPermissionDeclarationV1>,
26    #[serde(default)]
27    pub commands: Vec<String>,
28    #[serde(default)]
29    pub environment: Vec<EnvironmentPermissionDeclarationV1>,
30    #[serde(default)]
31    pub system: Vec<SystemPermissionDeclarationV1>,
32}
33
34impl PermissionDeclarationV1 {
35    pub fn validate(&self) -> Result<(), PermissionDeclarationError> {
36        if self.schema_version != PERMISSION_DECLARATION_SCHEMA_VERSION {
37            return Err(PermissionDeclarationError::UnsupportedSchema(
38                self.schema_version,
39            ));
40        }
41
42        let mut filesystem = BTreeSet::new();
43        for declaration in &self.filesystem {
44            declaration.validate()?;
45            let path = normalize_declared_path(&declaration.path);
46            let mut access = BTreeSet::new();
47            for item in &declaration.access {
48                if !access.insert(*item) {
49                    return Err(PermissionDeclarationError::Duplicate(format!(
50                        "filesystem access `{}` for {}:{}",
51                        filesystem_access_name(*item),
52                        declaration.base.as_str(),
53                        declaration.path
54                    )));
55                }
56                if !filesystem.insert((declaration.base, path.clone(), *item)) {
57                    return Err(PermissionDeclarationError::Duplicate(format!(
58                        "filesystem permission `{}` for {}:{}",
59                        filesystem_access_name(*item),
60                        declaration.base.as_str(),
61                        declaration.path
62                    )));
63                }
64            }
65        }
66
67        let mut network = BTreeSet::new();
68        for declaration in &self.network {
69            declaration.validate()?;
70            let key = (
71                declaration.scope,
72                declaration.host.as_deref().map(normalize_host),
73            );
74            if !network.insert(key) {
75                return Err(PermissionDeclarationError::Duplicate(
76                    "network permission".to_string(),
77                ));
78            }
79        }
80
81        let mut commands = BTreeSet::new();
82        for command in &self.commands {
83            validate_program(command)?;
84            if !commands.insert(command) {
85                return Err(PermissionDeclarationError::Duplicate(format!(
86                    "command `{command}`"
87                )));
88            }
89        }
90
91        let mut environment = BTreeSet::new();
92        for declaration in &self.environment {
93            validate_env_name(&declaration.name)?;
94            if !environment.insert(&declaration.name) {
95                return Err(PermissionDeclarationError::Duplicate(format!(
96                    "environment variable `{}`",
97                    declaration.name
98                )));
99            }
100        }
101
102        let mut system = BTreeSet::new();
103        for declaration in &self.system {
104            declaration.validate()?;
105            if !system.insert((declaration.capability.clone(), declaration.resource.clone())) {
106                return Err(PermissionDeclarationError::Duplicate(format!(
107                    "system capability `{}`",
108                    declaration.capability
109                )));
110            }
111        }
112        Ok(())
113    }
114
115    /// Normalize one author declaration into the same sorted, duplicate-free
116    /// permission vocabulary used by a security Plan. Filesystem paths retain
117    /// their logical base and never contain a physical Preset checkout root.
118    pub fn permission_set(&self) -> Result<PermissionSetV1, PermissionDeclarationError> {
119        self.validate()?;
120        let mut permissions = Vec::new();
121        if self.administrator {
122            permissions.push(PermissionV1::Administrator);
123        }
124        for declaration in &self.filesystem {
125            let path = format!(
126                "{}:{}",
127                declaration.base.as_str(),
128                normalize_declared_path(&declaration.path)
129            );
130            permissions.extend(
131                declaration
132                    .access
133                    .iter()
134                    .map(|access| PermissionV1::Filesystem {
135                        access: *access,
136                        path: path.clone(),
137                    }),
138            );
139        }
140        for declaration in &self.network {
141            let scope = match declaration.scope {
142                DeclaredNetworkScopeV1::Any => NetworkScopeV1::Any,
143                DeclaredNetworkScopeV1::Host => NetworkScopeV1::Host(normalize_host(
144                    declaration.host.as_deref().expect("validated network host"),
145                )),
146            };
147            permissions.push(PermissionV1::Network { scope });
148        }
149        permissions.extend(
150            self.commands
151                .iter()
152                .cloned()
153                .map(|program| PermissionV1::Command { program }),
154        );
155        permissions.extend(
156            self.environment
157                .iter()
158                .map(|declaration| PermissionV1::Environment {
159                    name: declaration.name.clone(),
160                    sensitivity: declaration.sensitivity,
161                }),
162        );
163        permissions.extend(self.system.iter().map(|declaration| PermissionV1::System {
164            capability: declaration.capability.clone(),
165            resource: declaration.resource.clone(),
166        }));
167        Ok(PermissionSetV1::new(permissions))
168    }
169}
170
171#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
172#[serde(deny_unknown_fields)]
173pub struct FilesystemPermissionDeclarationV1 {
174    pub access: Vec<FilesystemAccessV1>,
175    pub base: PermissionPathBaseV1,
176    pub path: String,
177}
178
179impl FilesystemPermissionDeclarationV1 {
180    fn validate(&self) -> Result<(), PermissionDeclarationError> {
181        if self.access.is_empty() {
182            return Err(PermissionDeclarationError::Invalid(
183                "filesystem access must not be empty".to_string(),
184            ));
185        }
186        match self.base {
187            PermissionPathBaseV1::Absolute => validate_absolute_path(&self.path),
188            _ => validate_relative_path(&self.path),
189        }
190    }
191}
192
193#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
194#[serde(rename_all = "kebab-case")]
195pub enum PermissionPathBaseV1 {
196    Home,
197    Shine,
198    DataDir,
199    Preset,
200    Absolute,
201}
202
203impl PermissionPathBaseV1 {
204    pub const fn as_str(self) -> &'static str {
205        match self {
206            Self::Home => "home",
207            Self::Shine => "shine",
208            Self::DataDir => "data-dir",
209            Self::Preset => "preset",
210            Self::Absolute => "absolute",
211        }
212    }
213}
214
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
216#[serde(deny_unknown_fields)]
217pub struct NetworkPermissionDeclarationV1 {
218    pub scope: DeclaredNetworkScopeV1,
219    pub host: Option<String>,
220}
221
222impl NetworkPermissionDeclarationV1 {
223    fn validate(&self) -> Result<(), PermissionDeclarationError> {
224        match (self.scope, self.host.as_deref()) {
225            (DeclaredNetworkScopeV1::Any, None) => Ok(()),
226            (DeclaredNetworkScopeV1::Any, Some(_)) => Err(PermissionDeclarationError::Invalid(
227                "network scope `any` must not declare a host".to_string(),
228            )),
229            (DeclaredNetworkScopeV1::Host, Some(host)) => validate_host(host),
230            (DeclaredNetworkScopeV1::Host, None) => Err(PermissionDeclarationError::Invalid(
231                "network scope `host` requires a host".to_string(),
232            )),
233        }
234    }
235}
236
237#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
238#[serde(rename_all = "kebab-case")]
239pub enum DeclaredNetworkScopeV1 {
240    Any,
241    Host,
242}
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
245#[serde(deny_unknown_fields)]
246pub struct EnvironmentPermissionDeclarationV1 {
247    pub name: String,
248    pub sensitivity: EnvironmentSensitivityV1,
249}
250
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
252#[serde(deny_unknown_fields)]
253pub struct SystemPermissionDeclarationV1 {
254    pub capability: String,
255    pub resource: Option<String>,
256}
257
258impl SystemPermissionDeclarationV1 {
259    fn validate(&self) -> Result<(), PermissionDeclarationError> {
260        if !is_capability_identifier(&self.capability) {
261            return Err(PermissionDeclarationError::Invalid(format!(
262                "system capability `{}` must use lowercase letters, digits, `.`, `_`, or `-`",
263                self.capability
264            )));
265        }
266        if self
267            .resource
268            .as_deref()
269            .is_some_and(|resource| resource.is_empty() || contains_control(resource))
270        {
271            return Err(PermissionDeclarationError::Invalid(
272                "system resource must be a non-empty single-line identity".to_string(),
273            ));
274        }
275        Ok(())
276    }
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub enum PermissionDeclarationError {
281    UnsupportedSchema(u32),
282    Invalid(String),
283    Duplicate(String),
284}
285
286impl PermissionDeclarationError {
287    pub const fn diagnostic_code(&self) -> &'static str {
288        match self {
289            Self::UnsupportedSchema(_) => "unsupported_permission_schema",
290            Self::Invalid(_) => "invalid_permission_declaration",
291            Self::Duplicate(_) => "duplicate_permission",
292        }
293    }
294}
295
296impl fmt::Display for PermissionDeclarationError {
297    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match self {
299            Self::UnsupportedSchema(version) => {
300                write!(formatter, "unsupported permission schema version {version}")
301            }
302            Self::Invalid(message) => {
303                write!(formatter, "invalid permission declaration: {message}")
304            }
305            Self::Duplicate(permission) => {
306                write!(formatter, "duplicate permission declaration: {permission}")
307            }
308        }
309    }
310}
311
312impl std::error::Error for PermissionDeclarationError {}
313
314fn validate_relative_path(path: &str) -> Result<(), PermissionDeclarationError> {
315    if path == "." {
316        return Ok(());
317    }
318    if path.is_empty()
319        || path.starts_with(['/', '\\'])
320        || is_windows_drive_path(path)
321        || path.contains('\\')
322        || path
323            .split('/')
324            .any(|component| component.is_empty() || matches!(component, "." | ".."))
325        || contains_control(path)
326    {
327        return Err(PermissionDeclarationError::Invalid(format!(
328            "permission path `{path}` must be a normalized relative path"
329        )));
330    }
331    Ok(())
332}
333
334fn validate_absolute_path(path: &str) -> Result<(), PermissionDeclarationError> {
335    if path.is_empty()
336        || !is_portable_absolute_path(path)
337        || path.split(['/', '\\']).any(|component| component == "..")
338        || contains_control(path)
339    {
340        return Err(PermissionDeclarationError::Invalid(format!(
341            "permission path `{path}` must be a portable absolute path without `..`"
342        )));
343    }
344    Ok(())
345}
346
347fn validate_host(host: &str) -> Result<(), PermissionDeclarationError> {
348    if host.is_empty()
349        || host.contains("://")
350        || host.contains(['/', '@', '?', '#'])
351        || host.chars().any(char::is_whitespace)
352        || contains_control(host)
353    {
354        return Err(PermissionDeclarationError::Invalid(format!(
355            "network host `{host}` must not contain a URL scheme, path, credentials, or whitespace"
356        )));
357    }
358    Ok(())
359}
360
361fn validate_program(program: &str) -> Result<(), PermissionDeclarationError> {
362    if program.is_empty() || program.chars().any(char::is_whitespace) || contains_control(program) {
363        return Err(PermissionDeclarationError::Invalid(format!(
364            "command `{program}` must be one program identity without arguments"
365        )));
366    }
367    Ok(())
368}
369
370fn validate_env_name(name: &str) -> Result<(), PermissionDeclarationError> {
371    let mut characters = name.chars();
372    let valid_start = characters
373        .next()
374        .is_some_and(|character| character == '_' || character.is_ascii_alphabetic());
375    if !valid_start
376        || !characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
377    {
378        return Err(PermissionDeclarationError::Invalid(format!(
379            "environment variable `{name}` is not a portable variable name"
380        )));
381    }
382    Ok(())
383}
384
385fn is_capability_identifier(value: &str) -> bool {
386    value
387        .chars()
388        .next()
389        .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
390        && value.chars().all(|character| {
391            character.is_ascii_lowercase()
392                || character.is_ascii_digit()
393                || matches!(character, '.' | '_' | '-')
394        })
395}
396
397fn is_portable_absolute_path(path: &str) -> bool {
398    path.starts_with('/')
399        || path.starts_with("\\\\")
400        || (path.len() >= 3
401            && path.as_bytes()[0].is_ascii_alphabetic()
402            && path.as_bytes()[1] == b':'
403            && matches!(path.as_bytes()[2], b'/' | b'\\'))
404}
405
406fn is_windows_drive_path(path: &str) -> bool {
407    path.len() >= 2 && path.as_bytes()[0].is_ascii_alphabetic() && path.as_bytes()[1] == b':'
408}
409
410fn contains_control(value: &str) -> bool {
411    value.chars().any(char::is_control)
412}
413
414fn normalize_declared_path(path: &str) -> String {
415    if path == "." {
416        return path.to_string();
417    }
418    let path = path.replace('\\', "/");
419    let (prefix, remainder) = if let Some(remainder) = path.strip_prefix("//") {
420        ("//", remainder)
421    } else if let Some(remainder) = path.strip_prefix('/') {
422        ("/", remainder)
423    } else if path.len() >= 3 && path.as_bytes()[1] == b':' && path.as_bytes()[2] == b'/' {
424        (&path[..3], &path[3..])
425    } else {
426        ("", path.as_str())
427    };
428    let remainder = remainder
429        .split('/')
430        .filter(|component| !component.is_empty() && *component != ".")
431        .collect::<Vec<_>>()
432        .join("/");
433    format!("{prefix}{remainder}")
434}
435
436fn normalize_host(host: &str) -> String {
437    host.to_ascii_lowercase()
438}
439
440fn filesystem_access_name(access: FilesystemAccessV1) -> &'static str {
441    match access {
442        FilesystemAccessV1::Read => "read",
443        FilesystemAccessV1::Write => "write",
444        FilesystemAccessV1::Remove => "remove",
445        FilesystemAccessV1::Execute => "execute",
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    fn parse(value: &str) -> Result<PermissionDeclarationV1, toml::de::Error> {
454        toml::from_str(value)
455    }
456
457    #[test]
458    fn complete_declaration_normalizes_to_plan_permissions() {
459        let declaration = parse(
460            r#"
461schema_version = 1
462administrator = true
463filesystem = [
464  { access = ["write", "read"], base = "home", path = ".config/example" },
465  { access = ["execute"], base = "preset", path = "build.ts" },
466]
467network = [
468  { scope = "host", host = "api.example.com" },
469  { scope = "any" },
470]
471commands = ["bun"]
472environment = [{ name = "API_TOKEN", sensitivity = "secret" }]
473system = [{ capability = "split-dns", resource = "private-domain" }]
474"#,
475        )
476        .unwrap();
477
478        declaration.validate().unwrap();
479        let permissions = declaration.permission_set().unwrap();
480        assert_eq!(permissions.iter().count(), 9);
481        assert!(permissions.contains(&PermissionV1::Filesystem {
482            access: FilesystemAccessV1::Read,
483            path: "home:.config/example".to_string(),
484        }));
485        assert!(permissions.contains(&PermissionV1::Environment {
486            name: "API_TOKEN".to_string(),
487            sensitivity: EnvironmentSensitivityV1::Secret,
488        }));
489    }
490
491    #[test]
492    fn schema_and_unknown_fields_fail_closed() {
493        let declaration = parse("schema_version = 2\n").unwrap();
494        assert_eq!(
495            declaration.validate().unwrap_err().diagnostic_code(),
496            "unsupported_permission_schema"
497        );
498        assert!(parse("schema_version = 1\nsecret_value = 'nope'\n").is_err());
499    }
500
501    #[test]
502    fn duplicate_permissions_are_rejected() {
503        let declaration = parse(
504            r#"
505schema_version = 1
506commands = ["bun", "bun"]
507"#,
508        )
509        .unwrap();
510        assert_eq!(
511            declaration.validate().unwrap_err().diagnostic_code(),
512            "duplicate_permission"
513        );
514
515        for value in [
516            r#"
517schema_version = 1
518filesystem = [
519  { access = ["read"], base = "absolute", path = "C:/Data/config" },
520  { access = ["read"], base = "absolute", path = "C:\\Data\\config" },
521]
522"#,
523            r#"
524schema_version = 1
525network = [
526  { scope = "host", host = "API.EXAMPLE.COM" },
527  { scope = "host", host = "api.example.com" },
528]
529"#,
530        ] {
531            assert_eq!(
532                parse(value)
533                    .unwrap()
534                    .validate()
535                    .unwrap_err()
536                    .diagnostic_code(),
537                "duplicate_permission"
538            );
539        }
540    }
541
542    #[test]
543    fn path_bases_enforce_relative_and_absolute_contracts() {
544        for value in [
545            r#"schema_version = 1
546filesystem = [{ access = ["read"], base = "home", path = "../secret" }]
547"#,
548            r#"schema_version = 1
549filesystem = [{ access = ["write"], base = "absolute", path = "relative/file" }]
550"#,
551        ] {
552            assert_eq!(
553                parse(value)
554                    .unwrap()
555                    .validate()
556                    .unwrap_err()
557                    .diagnostic_code(),
558                "invalid_permission_declaration"
559            );
560        }
561        for path in [
562            "/etc/example",
563            r"C:\\ProgramData\\example",
564            r"\\\\server\\share",
565        ] {
566            validate_absolute_path(path).unwrap();
567        }
568    }
569
570    #[test]
571    fn host_program_environment_and_system_identities_are_payload_free() {
572        for value in [
573            r#"schema_version = 1
574network = [{ scope = "host", host = "https://api.example.com/path" }]
575"#,
576            r#"schema_version = 1
577commands = ["sh -c"]
578"#,
579            r#"schema_version = 1
580environment = [{ name = "TOKEN=value", sensitivity = "secret" }]
581"#,
582            r#"schema_version = 1
583system = [{ capability = "Split DNS" }]
584"#,
585        ] {
586            assert_eq!(
587                parse(value)
588                    .unwrap()
589                    .validate()
590                    .unwrap_err()
591                    .diagnostic_code(),
592                "invalid_permission_declaration"
593            );
594        }
595    }
596}