| 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 | |
| 14 | use std::path::Path; |
| 15 | |
| 16 | use 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`]). |
| 21 | pub 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`). |
| 37 | pub 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. |
| 59 | pub 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)] |
| 76 | fn 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)] |
| 82 | fn 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 | |
| 96 | fn 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))] |
| 106 | fn 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))] |
| 120 | const 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))] |
| 132 | fn 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))] |
| 158 | fn 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))] |
| 176 | fn 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)] |
| 187 | mod 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)] |
| 280 | mod 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 | } |