1use 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 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}