1use serde::Deserialize;
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Deserialize, Default)]
6pub struct GlobalConfig {
7 pub shield: Option<ShieldConfig>,
8 pub env: Option<EnvConfig>,
10 pub filesystem: Option<FilesystemConfig>,
11}
12
13#[derive(Debug, Clone, Deserialize, Default)]
14pub struct ShieldConfig {
15 pub aliases: Option<HashMap<String, ShieldAlias>>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
22#[serde(untagged)]
23pub enum ShieldAlias {
24 Simple(String),
25 Detailed(ShieldAliasDetailed),
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ShieldAliasDetailed {
30 pub profile: String,
31 pub subcommands: Option<Vec<String>>,
32}
33
34impl ShieldAlias {
35 pub fn profile(&self) -> &str {
36 match self {
37 ShieldAlias::Simple(p) => p,
38 ShieldAlias::Detailed(d) => &d.profile,
39 }
40 }
41
42 pub fn subcommands(&self) -> Option<&Vec<String>> {
43 match self {
44 ShieldAlias::Simple(_) => None, ShieldAlias::Detailed(d) => d.subcommands.as_ref(),
46 }
47 }
48}
49
50impl GlobalConfig {
51 pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
52 toml::from_str(text)
53 }
54}
55
56#[derive(Debug, Clone, Deserialize, Default)]
66pub struct CustomProfileEntry {
67 pub description: Option<String>,
68 pub network: Option<NetworkConfig>,
69 pub filesystem: Option<FilesystemConfig>,
70 pub env: Option<EnvConfig>,
71}
72
73impl CustomProfileEntry {
74 pub fn into_profile(self, name: &str) -> Profile {
76 Profile {
77 meta: Some(ProfileMeta {
78 name: Some(name.to_string()),
79 description: self.description,
80 profile: None,
81 }),
82 network: self.network,
83 filesystem: self.filesystem,
84 env: self.env,
85 }
86 }
87}
88
89pub fn parse_custom_profiles(
91 text: &str,
92) -> Result<HashMap<String, CustomProfileEntry>, toml::de::Error> {
93 toml::from_str(text)
94}
95
96#[derive(Debug, Clone, Deserialize, Default)]
97pub struct Profile {
98 pub meta: Option<ProfileMeta>,
99 pub network: Option<NetworkConfig>,
100 pub filesystem: Option<FilesystemConfig>,
101 pub env: Option<EnvConfig>,
102}
103
104#[derive(Debug, Clone, Deserialize, Default)]
105pub struct ProfileMeta {
106 pub name: Option<String>,
107 pub description: Option<String>,
108 pub profile: Option<String>,
109}
110
111#[derive(Debug, Clone, Deserialize, Default)]
112pub struct NetworkConfig {
113 #[serde(default)]
114 pub allow: Vec<String>,
115}
116
117#[derive(Debug, Clone, Deserialize, Default)]
118pub struct FilesystemConfig {
119 #[serde(default)]
120 pub allow_write: Vec<String>,
121 #[serde(default)]
122 pub deny_read: Vec<String>,
123}
124
125#[derive(Debug, Clone, Deserialize, Default)]
126pub struct EnvConfig {
127 #[serde(default)]
128 pub scrub: Vec<String>,
129 #[serde(default)]
130 pub pass: Vec<String>,
131}
132
133impl Profile {
134 pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
136 toml::from_str(text)
137 }
138
139 pub fn merge(&mut self, other: &Profile) {
141 if let Some(ref net) = other.network {
142 let self_net = self.network.get_or_insert_with(Default::default);
143 for domain in &net.allow {
144 if !self_net.allow.contains(domain) {
145 self_net.allow.push(domain.clone());
146 }
147 }
148 }
149
150 if let Some(ref fs) = other.filesystem {
151 let self_fs = self.filesystem.get_or_insert_with(Default::default);
152 for path in &fs.allow_write {
153 if !self_fs.allow_write.contains(path) {
154 self_fs.allow_write.push(path.clone());
155 }
156 }
157 for path in &fs.deny_read {
158 if !self_fs.deny_read.contains(path) {
159 self_fs.deny_read.push(path.clone());
160 }
161 }
162 }
163
164 if let Some(ref env) = other.env {
165 let self_env = self.env.get_or_insert_with(Default::default);
166 for pat in &env.scrub {
167 if !self_env.scrub.contains(pat) {
168 self_env.scrub.push(pat.clone());
169 }
170 }
171 for pat in &env.pass {
172 if !self_env.pass.contains(pat) {
173 self_env.pass.push(pat.clone());
174 }
175 }
176 }
177 }
178
179 pub fn merge_restrictive(&mut self, other: &Profile) {
185 if let Some(ref fs) = other.filesystem {
190 let self_fs = self.filesystem.get_or_insert_with(Default::default);
191 for path in &fs.deny_read {
192 if !self_fs.deny_read.contains(path) {
193 self_fs.deny_read.push(path.clone());
194 }
195 }
196 }
197
198 if let Some(ref env) = other.env {
200 let self_env = self.env.get_or_insert_with(Default::default);
201 for pat in &env.scrub {
202 if !self_env.scrub.contains(pat) {
203 self_env.scrub.push(pat.clone());
204 }
205 }
206 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 const NPM_TOML: &str = r#"
216[meta]
217name = "npm"
218description = "Node.js package manager"
219
220[network]
221allow = ["registry.npmjs.org", "*.npmjs.org", "github.com"]
222
223[filesystem]
224allow_write = ["./node_modules", "./package-lock.json", "/tmp"]
225deny_read = ["~/.aws", "~/.ssh"]
226
227[env]
228scrub = ["*_KEY", "*_SECRET", "*_TOKEN"]
229pass = ["PATH", "HOME", "NODE_ENV"]
230"#;
231
232 #[test]
233 fn parse_profile() {
234 let profile = Profile::from_toml(NPM_TOML).unwrap();
235 let meta = profile.meta.unwrap();
236 assert_eq!(meta.name.unwrap(), "npm");
237
238 let net = profile.network.unwrap();
239 assert_eq!(net.allow.len(), 3);
240 assert!(net.allow.contains(&"registry.npmjs.org".to_string()));
241
242 let fs = profile.filesystem.unwrap();
243 assert_eq!(fs.allow_write.len(), 3);
244 assert_eq!(fs.deny_read.len(), 2);
245
246 let env = profile.env.unwrap();
247 assert_eq!(env.scrub.len(), 3);
248 assert_eq!(env.pass.len(), 3);
249 }
250
251 #[test]
252 fn merge_union_semantics() {
253 let mut base = Profile::from_toml(NPM_TOML).unwrap();
254
255 let overlay = Profile::from_toml(
256 r#"
257[network]
258allow = ["custom.registry.com", "github.com"]
259
260[filesystem]
261deny_read = ["~/.gnupg"]
262
263[env]
264scrub = ["*_PASSWORD"]
265pass = ["CI"]
266"#,
267 )
268 .unwrap();
269
270 base.merge(&overlay);
271
272 let net = base.network.unwrap();
273 assert_eq!(net.allow.len(), 4); assert!(net.allow.contains(&"custom.registry.com".to_string()));
275 assert!(net.allow.contains(&"github.com".to_string()));
276
277 let fs = base.filesystem.unwrap();
278 assert_eq!(fs.deny_read.len(), 3); let env = base.env.unwrap();
281 assert_eq!(env.scrub.len(), 4); assert_eq!(env.pass.len(), 4); }
284
285 #[test]
286 fn merge_into_empty_profile() {
287 let mut base = Profile::default();
288 let overlay = Profile::from_toml(NPM_TOML).unwrap();
289 base.merge(&overlay);
290
291 let net = base.network.unwrap();
292 assert_eq!(net.allow.len(), 3);
293 }
294
295 #[test]
296 fn merge_with_empty_overlay() {
297 let mut base = Profile::from_toml(NPM_TOML).unwrap();
298 let overlay = Profile::default();
299 base.merge(&overlay);
300
301 let net = base.network.unwrap();
302 assert_eq!(net.allow.len(), 3); }
304
305 #[test]
306 fn merge_restrictive_blocks_network_allow() {
307 let mut base = Profile::from_toml(NPM_TOML).unwrap();
308
309 let malicious = Profile::from_toml(
311 r#"
312[network]
313allow = ["evil.com"]
314"#,
315 )
316 .unwrap();
317
318 base.merge_restrictive(&malicious);
319
320 let net = base.network.unwrap();
321 assert_eq!(net.allow.len(), 3); assert!(!net.allow.contains(&"evil.com".to_string()));
323 }
324
325 #[test]
326 fn merge_restrictive_blocks_env_pass() {
327 let mut base = Profile::from_toml(NPM_TOML).unwrap();
328
329 let malicious = Profile::from_toml(
331 r#"
332[env]
333pass = ["*_SECRET", "AWS_*"]
334"#,
335 )
336 .unwrap();
337
338 base.merge_restrictive(&malicious);
339
340 let env = base.env.unwrap();
341 assert_eq!(env.pass.len(), 3); assert!(!env.pass.contains(&"*_SECRET".to_string()));
343 }
344
345 #[test]
346 fn merge_restrictive_blocks_allow_write() {
347 let mut base = Profile::from_toml(NPM_TOML).unwrap();
348
349 let malicious = Profile::from_toml(
351 r#"
352[filesystem]
353allow_write = ["~/.ssh", "/etc"]
354"#,
355 )
356 .unwrap();
357
358 base.merge_restrictive(&malicious);
359
360 let fs = base.filesystem.unwrap();
361 assert_eq!(fs.allow_write.len(), 3); assert!(!fs.allow_write.contains(&"~/.ssh".to_string()));
363 }
364
365 #[test]
366 fn merge_restrictive_allows_adding_deny_read() {
367 let mut base = Profile::from_toml(NPM_TOML).unwrap();
368
369 let extra_deny = Profile::from_toml(
371 r#"
372[filesystem]
373deny_read = ["~/.config/company-secrets"]
374"#,
375 )
376 .unwrap();
377
378 base.merge_restrictive(&extra_deny);
379
380 let fs = base.filesystem.unwrap();
381 assert_eq!(fs.deny_read.len(), 3); assert!(fs
383 .deny_read
384 .contains(&"~/.config/company-secrets".to_string()));
385 }
386
387 #[test]
388 fn merge_restrictive_allows_adding_scrub() {
389 let mut base = Profile::from_toml(NPM_TOML).unwrap();
390
391 let extra_scrub = Profile::from_toml(
393 r#"
394[env]
395scrub = ["COMPANY_*"]
396"#,
397 )
398 .unwrap();
399
400 base.merge_restrictive(&extra_scrub);
401
402 let env = base.env.unwrap();
403 assert_eq!(env.scrub.len(), 4); assert!(env.scrub.contains(&"COMPANY_*".to_string()));
405 }
406
407 #[test]
408 fn parse_minimal_profile() {
409 let minimal = r#"
410[meta]
411name = "minimal"
412description = "Maximum isolation"
413
414[network]
415allow = []
416
417[filesystem]
418allow_write = [".", "/tmp"]
419deny_read = ["~/.aws", "~/.ssh", "~/.gnupg"]
420
421[env]
422scrub = ["*_KEY", "*_SECRET"]
423pass = ["PATH", "HOME"]
424"#;
425 let profile = Profile::from_toml(minimal).unwrap();
426 let net = profile.network.unwrap();
427 assert!(net.allow.is_empty());
428 }
429}