1use seatbelt::{Filter, Operation, Profile, allow, deny};
2use std::{env, fs, io, path::Path};
3
4pub fn prepare_profile(workspace: &Path, sandbox: &Path) -> Result<Profile, io::Error> {
5 use Filter::*;
6 use Operation::*;
7
8 let path_items = if let Ok(path) = env::var("PATH") {
9 path.split(":")
10 .map(|p| Prefix(p.to_owned()))
11 .collect::<Vec<_>>()
12 } else {
13 vec![]
14 };
15
16 let mut rules = vec![
17 deny(Default, None),
18 allow(ProcessAll, None),
19 allow(SysctlRead, None),
20 allow(MachLookup, None),
21 allow(IpcPosixShmReadData, None),
22 allow(UserPreferenceRead, None),
23 allow(FileReadMetadata, vec![Prefix("/".into())]),
24 allow(FileIoctl, vec![Literal("/dev/dtracehelper".into())]),
25 allow(
26 FileWriteAll,
27 vec![
28 Literal("/dev/dtracehelper".into()),
29 Literal("/dev/null".into()),
30 Literal("/dev/tty".into()),
31 ],
32 ),
33 allow(FileReadAll, path_items),
35 allow(
36 FileReadAll,
37 vec![
38 Literal("/".into()), Literal("/dev/autofs_nowait".into()),
41 Literal("/dev/urandom".into()),
42 Literal("/dev/random".into()),
43 Literal("/dev/null".into()),
44 Literal("/dev/tty".into()),
45 Literal("/dev/dtracehelper".into()),
46 Prefix("/private/etc/".into()),
47 Prefix("/private/var/db/timezone/".into()),
48 Prefix("/Applications/Xcode.app/Contents/Developer".into()),
49 Prefix("/usr/lib/".into()),
50 Prefix("/private/var/db/dyld/".into()),
51 Prefix("/System/Library/".into()),
52 Prefix("/System/Volumes/Preboot/Cryptexes/OS".into()),
53 Prefix("/System/Cryptexes/OS/".into()),
54 Prefix("/Library/Preferences/".into()),
55 Regex("/.CFUserTextEncoding$".into()),
56 Regex("/Cargo.(lock|toml)$".into()),
57 Regex("/.cargo/config$".into()),
58 ],
59 ),
60 allow(
62 NetworkOutbound,
63 vec![
64 RemoteIp("*:80".into()),
65 RemoteIp("*:443".into()),
66 RemoteUnixSocket("/private/var/run/mDNSResponder".into()),
67 ],
68 ),
69 ];
70
71 if let Some(workspace_dir) = workspace.to_str().map(|s| s.to_owned()) {
73 rules.push(allow(
75 FileWriteCreate,
76 vec![Literal(format!("{}/Cargo.lock", &workspace_dir))],
77 ));
78 rules.push(allow(FileReadAll, vec![Prefix(workspace_dir)]));
79 }
80
81 if let Ok(home_dir) = env::var("HOME") {
83 rules.push(allow(
84 FileReadAll,
85 vec![Prefix(format!("{}/.rustup/", home_dir))],
86 ));
87 rules.push(deny(
88 FileReadMetadata,
89 vec![Prefix(format!("{}/.ssh/", home_dir))],
90 ));
91 }
92
93 let tmp_dir = env::temp_dir();
95 let real_tmp_path = fs::canonicalize(tmp_dir)?;
96 if let Some(tmp_dir) = real_tmp_path.to_str() {
97 rules.push(allow(FileReadAll, vec![Prefix(tmp_dir.to_owned())]));
98 rules.push(allow(FileWriteAll, vec![Prefix(tmp_dir.to_owned())]));
99 }
100
101 if let Some(sandbox_path) = sandbox.to_str().map(|p| p.to_owned()) {
103 rules.push(allow(
104 FileWriteAll,
105 vec![
106 Prefix(format!("{}/cargo", sandbox_path)),
107 Prefix(format!("{}/target", sandbox_path)),
108 ],
109 ));
110 }
111
112 Ok(Profile(rules))
113}
114
115mod seatbelt {
118 use std::fmt;
119
120 pub struct Profile(pub Vec<Rule>);
121
122 impl fmt::Display for Profile {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 writeln!(f, "(version 1)")?;
125 for rule in &self.0 {
126 writeln!(f, "{}", rule)?;
127 }
128 Ok(())
129 }
130 }
131
132 pub struct Rule(pub Action, pub Operation, pub Vec<Filter>);
133
134 impl fmt::Display for Rule {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 let Rule(action, op, filters) = &self;
137 if filters.is_empty() {
138 write!(f, "({} {})", action.as_str(), op.as_str())
139 } else {
140 writeln!(f, "({} {}", action.as_str(), op.as_str())?;
141 for filter in filters {
142 writeln!(f, " ({})", filter)?;
143 }
144 write!(f, ")")
145 }
146 }
147 }
148
149 pub enum Action {
150 Allow,
151 Deny,
152 }
153
154 impl Action {
155 pub fn as_str(&self) -> &'static str {
156 match self {
157 Action::Allow => "allow",
158 Action::Deny => "deny",
159 }
160 }
161 }
162
163 #[allow(unused)]
164 pub enum Operation {
165 Default,
166 FileAll,
167 FileWriteAll,
168 FileWriteCreate,
169 FileReadAll,
170 FileIoctl,
171 IpcPosixShmReadData,
172 UserPreferenceRead,
173 FileReadMetadata,
174 NetworkOutbound,
175 MachLookup,
176 IpcAll,
177 MachAll,
178 NetworkAll,
179 ProcessAll,
180 ProcessFork,
181 Signal,
182 SysctlAll,
183 SysctlRead,
184 SystemAll,
185 }
186
187 impl Operation {
188 pub fn as_str(&self) -> &'static str {
189 match self {
190 Operation::Default => "default",
191 Operation::FileAll => "file*",
192 Operation::FileWriteAll => "file-write*",
193 Operation::FileWriteCreate => "file-write-create",
194 Operation::FileReadAll => "file-read*",
195 Operation::FileIoctl => "file-ioctl",
196 Operation::IpcPosixShmReadData => "ipc-posix-shm-read-data",
197 Operation::UserPreferenceRead => "user-preference-read",
198 Operation::FileReadMetadata => "file-read-metadata",
199 Operation::NetworkOutbound => "network-outbound",
200 Operation::MachLookup => "mach-lookup",
201 Operation::IpcAll => "ipc*",
202 Operation::MachAll => "mach*",
203 Operation::NetworkAll => "network*",
204 Operation::ProcessAll => "process*",
205 Operation::ProcessFork => "process-fork",
206 Operation::Signal => "signal",
207 Operation::SysctlAll => "sysctl*",
208 Operation::SysctlRead => "sysctl-read",
209 Operation::SystemAll => "system*",
210 }
211 }
212 }
213
214 pub enum Filter {
215 Literal(String),
216 Prefix(String),
217 Regex(String),
218 RemoteIp(String),
219 RemoteUnixSocket(String),
220 }
221
222 impl fmt::Display for Filter {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 match self {
225 Filter::Literal(p) => write!(f, "literal \"{p}\""),
226 Filter::Prefix(p) => write!(f, r#"prefix "{p}""#),
227 Filter::Regex(r) => write!(f, r#"regex #"{r}""#),
228 Filter::RemoteIp(a) => write!(f, r#"remote ip "{a}""#),
229 Filter::RemoteUnixSocket(p) => {
230 write!(f, r#"remote unix-socket (path-literal "{p}")"#)
231 }
232 }
233 }
234 }
235
236 pub fn deny(op: Operation, filters: impl IntoIterator<Item = Filter>) -> Rule {
237 Rule(Action::Deny, op, filters.into_iter().collect())
238 }
239
240 pub fn allow(op: Operation, filters: impl IntoIterator<Item = Filter>) -> Rule {
241 Rule(Action::Allow, op, filters.into_iter().collect())
242 }
243
244 #[cfg(test)]
245 mod tests {
246 use super::*;
247 use Filter::*;
248 use Operation::*;
249 use indoc::indoc;
250
251 #[test]
252 fn check_file_prefix_filter() {
253 let profile = Profile(vec![allow(
254 FileAll,
255 vec![Prefix("/home".into()), Prefix("/bin".into())],
256 )]);
257
258 let expected_str = indoc! {r#"
259 (version 1)
260 (allow file*
261 (prefix "/home")
262 (prefix "/bin")
263 )
264 "#};
265 assert_eq!(profile.to_string(), expected_str);
266 }
267
268 #[test]
269 fn check_network_outbound() {
270 let profile = Profile(vec![deny(
271 NetworkOutbound,
272 vec![RemoteIp("192.168.1.1".into())],
273 )]);
274
275 let expected_str = indoc! {r#"
276 (version 1)
277 (deny network-outbound
278 (remote ip "192.168.1.1")
279 )
280 "#};
281 assert_eq!(profile.to_string(), expected_str);
282 }
283
284 #[test]
285 fn check_multiple_rules() {
286 let profile = Profile(vec![
287 allow(FileReadMetadata, vec![Prefix("/etc".into())]),
288 deny(ProcessFork, None),
289 ]);
290
291 let expected_str = indoc! {r#"
292 (version 1)
293 (allow file-read-metadata
294 (prefix "/etc")
295 )
296 (deny process-fork)
297 "#};
298 assert_eq!(profile.to_string(), expected_str);
299 }
300
301 #[test]
302 fn check_complex_filters() {
303 let profile = Profile(vec![allow(
304 IpcAll,
305 vec![
306 Regex(".*\\.shm".into()),
307 RemoteUnixSocket("/var/run/socket".into()),
308 ],
309 )]);
310
311 let expected_str = indoc! {r#"
312 (version 1)
313 (allow ipc*
314 (regex #".*\.shm")
315 (remote unix-socket (path-literal "/var/run/socket"))
316 )
317 "#};
318 assert_eq!(profile.to_string(), expected_str);
319 }
320 }
321}