Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
360 linesCodeBlameRaw
1//! The runner's folders, kept to the account that runs it. A runner's
2//! folder holds its credential (`config.json`), a Windows job's script
3//! while the job runs, and a sandbox's disk; anyone else on the computer
4//! who could read them could act as the runner, or look at a job.
5//!
6//! On Unix a folder is `0700` and its files `0600`. On Windows a folder's
7//! permissions come from the folder it is made in: in the user's profile
8//! (the default, `~/.g1t-runner`) only its owner, SYSTEM and Administrators
9//! can read it, but one made at `C:\runner` (`--dir`, `G1T_RUNNER_DIR`,
10//! `--state-dir`) is readable by every user of the computer. So the folder
11//! is made private when it is not, and the runner refuses to use one that
12//! stays readable by others.
13
14use std::path::Path;
15
16use anyhow::{Context, Result, bail};
17
18/// Writes a file only its owner can read, through a temporary file, so a
19/// crash never leaves half of it. On Windows its permissions are its
20/// folder's ([`folder`]).
21pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
22 let temp = path.with_extension("new");
23 std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
24 #[cfg(unix)]
25 {
26 use std::os::unix::fs::PermissionsExt;
27 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
28 }
29 std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
30 Ok(())
31}
32
33/// Makes `dir` if it is not there, and makes sure only this account (and,
34/// on Windows, SYSTEM and Administrators) can read it: tightened if it is
35/// open to others, and refused if it stays so. `option` is how a person
36/// picks another folder (`--dir`, `--state-dir`).
37pub fn folder(dir: &Path, option: &str) -> Result<()> {
38 std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?;
39 let open = readers(dir);
40 if open.is_empty() {
41 return Ok(());
42 }
43 let tightened = tighten(dir);
44 let still = readers(dir);
45 if still.is_empty() {
46 return Ok(());
47 }
48 let why = tightened.err().map(|e| format!(" ({e:#})")).unwrap_or_default();
49 bail!(
50 "{} can be read by {}, and could not be made private to this account{why}. It holds the runner's credential: {}, or pick a folder in your own profile with {option}",
51 dir.display(),
52 still.join(", "),
53 fix(dir)
54 )
55}
56
57/// Who other than this account can read `dir`, as a person names them;
58/// empty when no one.
59pub fn readers(dir: &Path) -> Vec<String> {
60 #[cfg(unix)]
61 {
62 use std::os::unix::fs::PermissionsExt;
63 let Ok(meta) = std::fs::metadata(dir) else { return Vec::new() };
64 unix_readers(meta.permissions().mode())
65 }
66 #[cfg(windows)]
67 {
68 match windows::sddl(dir) {
69 Some(sddl) => broad_readers(&sddl),
70 None => Vec::new(),
71 }
72 }
73}
74
75#[cfg(unix)]
76fn tighten(dir: &Path) -> Result<()> {
77 use std::os::unix::fs::PermissionsExt;
78 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(|| format!("could not chmod {}", dir.display()))
79}
80
81#[cfg(windows)]
82fn tighten(dir: &Path) -> Result<()> {
83 let Some(user) = windows::user_sid() else { bail!("could not tell which account this is") };
84 for args in icacls_args(&dir.display().to_string(), &user) {
85 let mut command = std::process::Command::new("icacls");
86 command.args(&args).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());
87 super::native::hide_window(&mut command);
88 let output = command.output().context("could not run icacls")?;
89 if !output.status.success() {
90 bail!("icacls: {}", String::from_utf8_lossy(&output.stderr).trim());
91 }
92 }
93 Ok(())
94}
95
96fn fix(dir: &Path) -> String {
97 if cfg!(windows) {
98 format!("in {}'s Properties, Security, Advanced, turn off inheritance and leave only yourself, SYSTEM and Administrators", dir.display())
99 } else {
100 format!("run `chmod 700 {}` as its owner", dir.display())
101 }
102}
103
104/// Who a Unix folder's mode lets in besides its owner.
105#[cfg_attr(not(unix), allow(dead_code))]
106fn unix_readers(mode: u32) -> Vec<String> {
107 let mut who = Vec::new();
108 if mode & 0o040 != 0 {
109 who.push("its group".to_owned());
110 }
111 if mode & 0o004 != 0 {
112 who.push("every user".to_owned());
113 }
114 who
115}
116
117/// The well-known groups that mean every (or any) user of a Windows
118/// computer, by SID and by SDDL's short name.
119#[cfg_attr(not(windows), allow(dead_code))]
120const BROAD: &[(&str, &str, &str)] = &[
121 ("S-1-1-0", "WD", "Everyone"),
122 ("S-1-5-32-545", "BU", "Users"),
123 ("S-1-5-11", "AU", "Authenticated Users"),
124 ("S-1-5-4", "IU", "Interactive users"),
125 ("S-1-5-7", "AN", "Anonymous"),
126 ("S-1-5-32-546", "BG", "Guests"),
127];
128
129/// Who of [`BROAD`] a folder's DACL, in SDDL (`D:PAI(A;OICI;FA;;;SY)…`),
130/// lets read it. A folder with no DACL at all lets everyone in.
131#[cfg_attr(not(windows), allow(dead_code))]
132fn broad_readers(sddl: &str) -> Vec<String> {
133 let Some(start) = sddl.find("D:") else { return vec!["Everyone".to_owned()] };
134 let dacl = &sddl[start + 2..];
135 // The SACL, if it was asked for, follows the DACL.
136 let dacl = dacl.find("S:").map_or(dacl, |end| &dacl[..end]);
137 if dacl.starts_with("NO_ACCESS_CONTROL") {
138 return vec!["Everyone".to_owned()];
139 }
140 let mut who = Vec::new();
141 for ace in dacl.split('(').skip(1).filter_map(|rest| rest.split(')').next()) {
142 let fields: Vec<&str> = ace.split(';').collect();
143 let [kind, _flags, rights, _, _, sid, ..] = fields[..] else { continue };
144 if !matches!(kind, "A" | "XA") || !reads(rights) {
145 continue;
146 }
147 if let Some((_, _, name)) = BROAD.iter().find(|(long, short, _)| sid.eq_ignore_ascii_case(long) || sid == *short)
148 && !who.iter().any(|w| w == name)
149 {
150 who.push((*name).to_owned());
151 }
152 }
153 who
154}
155
156/// Whether an ACE's rights, in SDDL, include reading a file's contents.
157#[cfg_attr(not(windows), allow(dead_code))]
158fn reads(rights: &str) -> bool {
159 // FILE_READ_DATA, GENERIC_ALL, GENERIC_READ.
160 const READ: u32 = 0x1 | 0x1000_0000 | 0x8000_0000;
161 if let Some(hex) = rights.strip_prefix("0x").or_else(|| rights.strip_prefix("0X")) {
162 return u32::from_str_radix(hex, 16).is_ok_and(|mask| mask & READ != 0);
163 }
164 if let Ok(mask) = rights.parse::<u32>() {
165 return mask & READ != 0;
166 }
167 // Two letters a right: `FA` all, `FR` read, `GA`/`GR` generic all and
168 // read, `CC` (bit 0, which for a file is reading its data).
169 rights.as_bytes().chunks(2).any(|code| matches!(code, b"FA" | b"FR" | b"GA" | b"GR" | b"KA" | b"KR" | b"CC"))
170}
171
172/// The `icacls` commands that leave a folder to `user`, SYSTEM and
173/// Administrators only, inherited by what is in it: no permissions from
174/// the folder above, and none for the groups that mean everyone.
175#[cfg_attr(not(windows), allow(dead_code))]
176fn icacls_args(dir: &str, user: &str) -> Vec<Vec<String>> {
177 let mut grant = vec![dir.to_owned(), "/inheritance:r".into(), "/grant:r".into()];
178 for sid in ["S-1-5-18", "S-1-5-32-544", user] {
179 grant.push(format!("*{sid}:(OI)(CI)F"));
180 }
181 let mut remove = vec![dir.to_owned(), "/remove:g".into()];
182 remove.extend(BROAD.iter().map(|(sid, _, _)| format!("*{sid}")));
183 vec![grant, remove]
184}
185
186#[cfg(windows)]
187mod windows {
188 //! Reading a folder's permissions and this process's account, from
189 //! Windows' own calls: SDDL and SIDs, which are the same in every
190 //! language, where `icacls`' listing names groups in the computer's.
191
192 use std::os::windows::ffi::OsStrExt;
193 use std::path::Path;
194 use std::ptr::null_mut;
195
196 type Handle = *mut core::ffi::c_void;
197
198 const SE_FILE_OBJECT: i32 = 1;
199 const DACL_SECURITY_INFORMATION: u32 = 4;
200 const SDDL_REVISION_1: u32 = 1;
201 const TOKEN_QUERY: u32 = 8;
202 const TOKEN_USER: i32 = 1;
203
204 #[link(name = "advapi32")]
205 unsafe extern "system" {
206 fn GetNamedSecurityInfoW(
207 name: *const u16,
208 object: i32,
209 info: u32,
210 owner: *mut Handle,
211 group: *mut Handle,
212 dacl: *mut Handle,
213 sacl: *mut Handle,
214 descriptor: *mut Handle,
215 ) -> u32;
216 fn ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor: Handle, revision: u32, info: u32, text: *mut *mut u16, length: *mut u32) -> i32;
217 fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
218 fn GetTokenInformation(token: Handle, class: i32, info: *mut core::ffi::c_void, length: u32, returned: *mut u32) -> i32;
219 fn ConvertSidToStringSidW(sid: Handle, text: *mut *mut u16) -> i32;
220 }
221
222 unsafe extern "system" {
223 fn LocalFree(memory: Handle) -> Handle;
224 fn GetCurrentProcess() -> Handle;
225 fn CloseHandle(handle: Handle) -> i32;
226 }
227
228 /// A string Windows allocated, read and freed.
229 unsafe fn take(text: *mut u16) -> String {
230 let mut length = 0;
231 while unsafe { *text.add(length) } != 0 {
232 length += 1;
233 }
234 let read = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) });
235 unsafe { LocalFree(text.cast()) };
236 read
237 }
238
239 /// A file or folder's DACL, in SDDL.
240 pub fn sddl(path: &Path) -> Option<String> {
241 let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
242 let mut descriptor: Handle = null_mut();
243 let error = unsafe {
244 GetNamedSecurityInfoW(wide.as_ptr(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, null_mut(), null_mut(), null_mut(), null_mut(), &mut descriptor)
245 };
246 if error != 0 || descriptor.is_null() {
247 return None;
248 }
249 let mut text: *mut u16 = null_mut();
250 let converted = unsafe { ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &mut text, null_mut()) };
251 unsafe { LocalFree(descriptor) };
252 (converted != 0 && !text.is_null()).then(|| unsafe { take(text) })
253 }
254
255 /// This process's account's SID: `S-1-5-21-…`.
256 pub fn user_sid() -> Option<String> {
257 let mut token: Handle = null_mut();
258 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
259 return None;
260 }
261 // TOKEN_USER and the SID after it; usize keeps its pointer aligned.
262 let mut buffer = vec![0usize; 64];
263 let mut returned = 0u32;
264 let ok = unsafe { GetTokenInformation(token, TOKEN_USER, buffer.as_mut_ptr().cast(), (buffer.len() * size_of::<usize>()) as u32, &mut returned) };
265 unsafe { CloseHandle(token) };
266 if ok == 0 {
267 return None;
268 }
269 // TOKEN_USER begins with the SID's pointer.
270 let sid = buffer[0] as Handle;
271 let mut text: *mut u16 = null_mut();
272 if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 || text.is_null() {
273 return None;
274 }
275 Some(unsafe { take(text) })
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn a_folder_open_to_every_user_is_found_by_its_groups() {
285 // C:\runner, made under C:\: Users read it, Authenticated Users change it.
286 let open = "D:AI(A;OICIID;FA;;;BA)(A;OICIID;FA;;;SY)(A;OICIID;0x1200a9;;;BU)(A;ID;0x1301bf;;;AU)(A;OICIIOID;SDGXGWGR;;;AU)";
287 assert_eq!(broad_readers(open), ["Users", "Authenticated Users"]);
288 // The user's profile, or a folder made private.
289 let private = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)";
290 assert!(broad_readers(private).is_empty());
291 // Everyone, by SID; and a right to list and traverse only, which reads nothing.
292 assert_eq!(broad_readers("D:(A;;0x120089;;;S-1-1-0)"), ["Everyone"]);
293 assert!(broad_readers("D:(A;;0x100020;;;WD)").is_empty());
294 // A deny is no way in, and no DACL at all is every way in.
295 assert!(broad_readers("D:(D;;FA;;;WD)").is_empty());
296 assert_eq!(broad_readers("D:NO_ACCESS_CONTROL"), ["Everyone"]);
297 assert_eq!(broad_readers("O:BAG:SY"), ["Everyone"]);
298 }
299
300 #[test]
301 fn rights_that_read_a_file_are_told_from_those_that_do_not() {
302 assert!(reads("FA") && reads("FR") && reads("GR") && reads("0x1200a9") && reads("0x80000000") && reads("1"));
303 assert!(!reads("FX") && !reads("SDGXGW") && !reads("0x100020") && !reads(""));
304 }
305
306 #[test]
307 fn a_folder_is_left_to_its_owner_system_and_administrators() {
308 let [grant, remove] = &icacls_args(r"C:\runner", "S-1-5-21-1-2-3-1001")[..] else { panic!() };
309 assert_eq!(grant, &[r"C:\runner", "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-21-1-2-3-1001:(OI)(CI)F"]);
310 assert_eq!(remove[..2], [r"C:\runner", "/remove:g"]);
311 for sid in ["*S-1-1-0", "*S-1-5-32-545", "*S-1-5-11"] {
312 assert!(remove.iter().any(|a| a == sid), "{sid}");
313 }
314 }
315
316 #[test]
317 fn a_unix_folder_is_open_when_its_group_or_others_may_read_it() {
318 assert!(unix_readers(0o700).is_empty());
319 assert!(unix_readers(0o711).is_empty());
320 assert_eq!(unix_readers(0o750), ["its group"]);
321 assert_eq!(unix_readers(0o755), ["its group", "every user"]);
322 }
323
324 #[test]
325 fn a_folder_the_runner_makes_is_private() {
326 let dir = std::env::temp_dir().join(format!("g1t-private-{}", std::process::id())).join("state");
327 folder(&dir, "--dir").unwrap();
328 assert!(readers(&dir).is_empty(), "{:?}", readers(&dir));
329 write_private(&dir.join("config.json"), b"{}").unwrap();
330 assert_eq!(std::fs::read(dir.join("config.json")).unwrap(), b"{}");
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
335 assert_eq!(std::fs::metadata(dir.join("config.json")).unwrap().permissions().mode() & 0o777, 0o600);
336 }
337 #[cfg(windows)]
338 assert!(windows::user_sid().is_some_and(|sid| sid.starts_with("S-1-")));
339 let _ = std::fs::remove_dir_all(dir.parent().unwrap());
340 }
341
342 /// Makes a folder every user can read and tightens it: run with
343 /// `cargo test -p g1t-runner -- --ignored a_folder_every_user_can_read`
344 /// on Windows (it uses `C:\Users\Public`).
345 #[test]
346 #[ignore]
347 #[cfg(windows)]
348 fn a_folder_every_user_can_read_is_made_private() {
349 let dir = std::path::PathBuf::from(format!(r"C:\Users\Public\g1t-private-{}", std::process::id()));
350 std::fs::create_dir_all(&dir).unwrap();
351 assert!(!readers(&dir).is_empty(), "Public is open to every user");
352 folder(&dir, "--dir").unwrap();
353 assert!(readers(&dir).is_empty(), "{:?}", windows::sddl(&dir));
354 let sddl = windows::sddl(&dir).unwrap();
355 assert!(sddl.starts_with("D:P"), "no permissions from the folder above: {sddl}");
356 std::fs::write(dir.join("config.json"), "{}").unwrap();
357 assert!(readers(&dir.join("config.json")).is_empty(), "{:?}", windows::sddl(&dir.join("config.json")));
358 std::fs::remove_dir_all(&dir).unwrap();
359 }
360}