runner_manager_platform/runner_root_access.rs
1// owner: b2-windows-root-acl
2
3//! Who may write inside the **platform default** runner root, and how that is
4//! established.
5//!
6//! [`runner_root`](crate::runner_root) decides *where* runner workspaces go and
7//! deliberately mutates nothing: `02-target-architecture.md` keeps "directory
8//! creation and the narrowly scoped default-root ACL operation" as explicit
9//! application steps that happen after validation passes. This module is that
10//! step, and it is the only place in the workspace that creates or
11//! re-permissions a runner root.
12//!
13//! # The threat, stated exactly
14//!
15//! `04-security-recovery.md` lists it in one line: *"`%SystemDrive%\rman` is
16//! writable by unrelated local users."* That is not hypothetical, it is the
17//! **default** on every Windows host. The security descriptor of `C:\` carries
18//! an inherit-only ACE of roughly this shape:
19//!
20//! ```text
21//! (A;OICIIO;SDGXGWGR;;;AU)
22//! ```
23//!
24//! — *Authenticated Users*, delete plus generic read/write/execute, inherited
25//! by every child of `C:\`. A directory created there with inheritance left on
26//! is therefore writable by every account that can log in, including the
27//! account a hostile workflow's own leftovers could be running as. Runner
28//! workspaces are executable content that a later job re-enters, so this is a
29//! code-execution boundary rather than a tidiness one.
30//!
31//! The control is one character: `P`, the `SE_DACL_PROTECTED` flag, which
32//! severs inheritance. Everything else in this module exists to apply it
33//! **without ever widening anything**, to prove afterwards that it took, and to
34//! refuse rather than adopt a directory that was already open.
35//!
36//! # What is admitted, and why that is the minimum
37//!
38//! | Trustee | Rights | Why it cannot be dropped |
39//! |---|---|---|
40//! | `SY` — LocalSystem | Full control, inherited | A boot registration runs as LocalSystem and must create, materialize and clean attempt directories |
41//! | `BA` — Administrators | Full control, inherited | `07-security.md` already places a local administrator outside this threat model; without it an operator cannot clean up after a service account they are not logged in as |
42//! | the selected account | [`ADMITTED_RIGHTS`], inherited | A login task or a foreground daemon runs as an ordinary user whose token contains neither of the above |
43//!
44//! The third row is load-bearing in a way that is easy to miss. A login-mode
45//! registration is a Task Scheduler task rendered with
46//! `RunLevel = LeastPrivilege` (see [`crate::service::windows_scheduled_task_xml`]),
47//! so it runs under the operator's **filtered** token — in which the
48//! Administrators group is present but *deny-only*. A DACL naming only `SY` and
49//! `BA` therefore grants such a task nothing at all, even when the operator is
50//! an administrator. The explicit per-account ACE is what makes login mode work,
51//! and it is also why a mode change has to reconcile it: the account admitted
52//! for login mode is not the account boot mode needs.
53//!
54//! # Why the account gets modify rather than full control
55//!
56//! `FA` is `FILE_ALL_ACCESS`, which includes `WRITE_DAC` and `WRITE_OWNER` — the
57//! two rights that would let the admitted account undo the protection this
58//! module exists to apply. [`ADMITTED_RIGHTS`] is read, write, execute and
59//! delete, which is everything "create a child, materialize a runner into it,
60//! and clean it up again" needs and nothing that can re-open the root. Deleting
61//! a whole attempt tree works because the inherited ACE grants `DELETE` on every
62//! entry below, which is what `remove_dir_all` actually requires; the parent's
63//! `FILE_DELETE_CHILD` is a convenience this deliberately does not grant.
64//!
65//! # Custom roots are read, never rewritten
66//!
67//! An operator's `host set-runtime-root` path is theirs. This module offers no
68//! public function that applies a security descriptor to a caller-chosen path:
69//! [`ensure_default_root`] resolves [`crate::runner_root::default_runner_root`]
70//! itself and takes no path at all, and [`report`] — the entry point for a
71//! configured custom root — only reads. That is the whole of "custom roots are
72//! never re-ACLed", enforced by the shape of the API rather than by a check
73//! somebody has to remember to write.
74//!
75//! # Everything decidable is decided purely
76//!
77//! [`default_root_sddl`], [`grants_broad_write`], [`admits_exactly`] and
78//! [`redact`] are pure functions over text with no `cfg`, no privileges and no
79//! filesystem, for the reason this crate gives everywhere else: a Linux CI leg
80//! can assert the exact descriptor a Windows host will write, and the one test
81//! that needs a real DACL is the privileged one that has a real machine.
82
83use std::collections::{BTreeMap, BTreeSet};
84use std::fmt;
85use std::io;
86use std::path::{Path, PathBuf};
87
88#[cfg(windows)]
89use runner_manager_domain::path::LocalAbsolutePath;
90
91use crate::paths::AppPaths;
92#[cfg(windows)]
93use crate::runner_root::RootPreflight;
94use crate::runner_root::{RootOwner, RunnerRootError};
95
96// ---------------------------------------------------------------------------
97// The descriptor
98// ---------------------------------------------------------------------------
99
100/// The rights the selected login or foreground account is admitted with.
101///
102/// `FR` `FW` `FX` `SD` — `FILE_GENERIC_READ`, `FILE_GENERIC_WRITE`,
103/// `FILE_GENERIC_EXECUTE` and `DELETE`. See this module's documentation for why
104/// this is deliberately not `FA`.
105pub const ADMITTED_RIGHTS: &str = "FRFWFXSD";
106
107/// The inheritance flags every ACE this module writes carries.
108///
109/// `OI` `CI` — object inherit and container inherit, with no `IO`: the ACE
110/// applies to the root itself *and* propagates to every file and directory
111/// created below it. Both halves are required. Without the propagation a
112/// service could create `<root>\<attempt>` and then be unable to write inside
113/// it; without the ACE applying to the root itself it could not create the
114/// child in the first place.
115pub const INHERITANCE: &str = "OICI";
116
117/// Which account, beyond the two constants, the root must admit.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum RootAdmission {
120 /// A boot registration. LocalSystem is already `SY`, so nothing is added.
121 LocalSystem,
122 /// A login registration or a foreground daemon, named by its SID.
123 ///
124 /// A SID rather than a name for the reason [`crate::secrets`] gives at
125 /// length: a DACL that describes an account and hopes the description still
126 /// fits is a DACL that stops granting what it was written to grant.
127 Account(String),
128}
129
130impl RootAdmission {
131 /// The account this process is running as.
132 ///
133 /// This is the foreground-daemon and login-mode answer:
134 /// `03-migration-rollout.md` has both of those "attempt ordinary creation",
135 /// and ordinary creation by an ordinary account is exactly the case the
136 /// third ACE exists for.
137 ///
138 /// # Errors
139 /// [`RootAccessError::Identity`] when this process's own token cannot be
140 /// read, which is the one failure that leaves nothing sensible to admit.
141 #[cfg(windows)]
142 pub fn of_this_account() -> Result<Self, RootAccessError> {
143 crate::process::current_user_sid()
144 .map(Self::Account)
145 .map_err(|source| RootAccessError::Identity { source })
146 }
147
148 /// The SID this admission adds, when it adds one.
149 #[must_use]
150 pub fn sid(&self) -> Option<&str> {
151 match self {
152 Self::LocalSystem => None,
153 Self::Account(sid) => Some(sid),
154 }
155 }
156
157 /// Who the resulting descriptor admits, in the vocabulary `service status`
158 /// already uses.
159 #[must_use]
160 pub fn admits(&self) -> Vec<AdmittedTrustee> {
161 let mut admitted = vec![
162 AdmittedTrustee::LocalSystem,
163 AdmittedTrustee::Administrators,
164 ];
165 if matches!(self, Self::Account(_)) {
166 admitted.push(AdmittedTrustee::SelectedAccount);
167 }
168 admitted
169 }
170}
171
172/// One trustee a runner root admits, named without naming an account.
173///
174/// The task's scope note is *"add privileged inspection output without exposing
175/// identities beyond what existing service status already reports"*, and what
176/// `service status` already reports is [`crate::service::ServiceAccount`] —
177/// `NT AUTHORITY\SYSTEM`, or the words "the invoking user". So this is an enum
178/// of the same three ideas rather than a list of SIDs, and no caller can print
179/// one by accident.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
181pub enum AdmittedTrustee {
182 /// `SY`, `NT AUTHORITY\SYSTEM`.
183 LocalSystem,
184 /// `BA`, the local Administrators group.
185 Administrators,
186 /// The login or foreground account the registration selected.
187 SelectedAccount,
188}
189
190impl fmt::Display for AdmittedTrustee {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 f.write_str(match self {
193 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
194 Self::Administrators => "the local Administrators group",
195 Self::SelectedAccount => "the invoking user",
196 })
197 }
198}
199
200/// The security descriptor the default runner root is created and reconciled
201/// with, in SDDL.
202///
203/// Split out and public for the same reason [`crate::secrets`] splits out its
204/// own: a test asserts the exact string rather than inferring it from a file,
205/// and a reviewer reads one line instead of three API calls.
206#[must_use]
207pub fn default_root_sddl(admission: &RootAdmission) -> String {
208 // `P` first, because it is the entire control. `D:P` discards whatever the
209 // volume root would otherwise inherit into this directory.
210 let mut sddl = format!("D:P(A;{INHERITANCE};FA;;;SY)(A;{INHERITANCE};FA;;;BA)");
211 if let Some(sid) = admission.sid()
212 && !already_admitted(sid)
213 {
214 sddl.push_str(&format!("(A;{INHERITANCE};{ADMITTED_RIGHTS};;;{sid})"));
215 }
216 sddl
217}
218
219/// Whether the two constant ACEs already cover this SID.
220///
221/// A daemon running as LocalSystem reads its own SID as `S-1-5-18` and would
222/// otherwise add a third ACE for the trustee the first one names. The same
223/// applies to a caller that hands over the Administrators group.
224fn already_admitted(sid: &str) -> bool {
225 const COVERED: [&str; 4] = ["SY", "BA", SID_LOCAL_SYSTEM, SID_ADMINISTRATORS];
226 COVERED.iter().any(|known| sid.eq_ignore_ascii_case(known))
227}
228
229/// `NT AUTHORITY\SYSTEM`.
230const SID_LOCAL_SYSTEM: &str = "S-1-5-18";
231/// `BUILTIN\Administrators`.
232const SID_ADMINISTRATORS: &str = "S-1-5-32-544";
233
234// ---------------------------------------------------------------------------
235// Reading a descriptor back
236// ---------------------------------------------------------------------------
237
238/// The access mask bits that amount to "may change what is in this directory,
239/// or who may".
240///
241/// Spelled as bits rather than as SDDL abbreviations because the abbreviations
242/// are not self-describing: `LC` and `DC` are the directory-service names for
243/// `0x4` and `0x2`, which on a filesystem object are `FILE_ADD_SUBDIRECTORY`
244/// and `FILE_ADD_FILE` — and those two are exactly how `C:\` grants ordinary
245/// users the ability to create things. Matching on the letters would have
246/// missed them.
247///
248/// | Bit | Right |
249/// |---|---|
250/// | `0x1000_0000` | `GENERIC_ALL` |
251/// | `0x4000_0000` | `GENERIC_WRITE` |
252/// | `0x0008_0000` | `WRITE_OWNER` |
253/// | `0x0004_0000` | `WRITE_DAC` |
254/// | `0x0001_0000` | `DELETE` |
255/// | `0x0000_0100` | `FILE_WRITE_ATTRIBUTES` |
256/// | `0x0000_0040` | `FILE_DELETE_CHILD` |
257/// | `0x0000_0010` | `FILE_WRITE_EA` |
258/// | `0x0000_0004` | `FILE_APPEND_DATA` / `FILE_ADD_SUBDIRECTORY` |
259/// | `0x0000_0002` | `FILE_WRITE_DATA` / `FILE_ADD_FILE` |
260pub const WRITE_MASK: u32 = 0x1000_0000
261 | 0x4000_0000
262 | 0x0008_0000
263 | 0x0004_0000
264 | 0x0001_0000
265 | 0x0000_0100
266 | 0x0000_0040
267 | 0x0000_0010
268 | 0x0000_0004
269 | 0x0000_0002;
270
271/// The trustees that mean "more or less anybody who can log in here", in both
272/// the alias form SDDL is written in and the raw form the converter may hand
273/// back instead.
274///
275/// The same list [`crate::process`] uses for the *read* question, plus Guests.
276/// `CO` (CREATOR OWNER) is deliberately absent: inherited, it grants each
277/// child's own creator access to that child, which is not a grant to an
278/// unrelated user.
279const BROAD_TRUSTEES: &[&str] = &[
280 "WD", // Everyone
281 "S-1-1-0", // Everyone
282 "AU", // Authenticated Users
283 "S-1-5-11", // Authenticated Users
284 "BU", // Builtin Users
285 "S-1-5-32-545", // Builtin Users
286 "BG", // Guests
287 "S-1-5-32-546", // Guests
288 "DU", // Domain Users
289 "IU", // Interactive
290 "S-1-5-4", // Interactive
291 "AN", // Anonymous
292 "S-1-5-7", // Anonymous
293 "WR", // Write Restricted
294 "LU", // Performance Log Users
295];
296
297/// One access-control entry, as SDDL spells it.
298///
299/// `(type;flags;rights;object;inherit_object;trustee)`, and the three fields
300/// this module has an opinion about.
301struct Ace<'a> {
302 kind: &'a str,
303 rights: &'a str,
304 trustee: &'a str,
305}
306
307/// Every ACE of the `D:` part of a security descriptor.
308///
309/// `None` when there is no `D:` at all, which Windows reads as "everyone, full
310/// control" and which therefore may never be confused with "an empty list of
311/// ACEs".
312fn aces(descriptor: &str) -> Option<Vec<Ace<'_>>> {
313 let body = descriptor.split("D:").nth(1)?;
314 // The other spelling of the same thing. `NO_ACCESS_CONTROL` is how SDDL
315 // renders a **NULL** DACL, which grants everyone everything — so it is `D:`
316 // present and no access control at all, not `D:` present with no entries.
317 // Read as a flags field it parses to zero ACEs, which would read back as
318 // the narrowest possible directory rather than the widest.
319 if body
320 .split('(')
321 .next()
322 .is_some_and(|flags| flags.contains("NO_ACCESS_CONTROL"))
323 {
324 return None;
325 }
326 Some(
327 body.split('(')
328 .skip(1)
329 .filter_map(|ace| {
330 let ace = ace.split(')').next()?;
331 let fields: Vec<&str> = ace.split(';').collect();
332 Some(Ace {
333 kind: fields.first()?.trim(),
334 rights: fields.get(2)?.trim(),
335 trustee: fields.get(5)?.trim(),
336 })
337 })
338 .collect(),
339 )
340}
341
342impl Ace<'_> {
343 /// Whether this entry grants rather than denies or audits.
344 ///
345 /// Every allow type starts with `A` (`A`, `OA`, `XA`), and so does the audit
346 /// type `AU`. Audit entries live in the `S:` part and cannot appear here,
347 /// but treating one as a grant if it somehow did is the direction that fails
348 /// closed, and it is the rule [`crate::process`] already applies to the read
349 /// question.
350 fn is_allow(&self) -> bool {
351 self.kind.starts_with('A') || (self.kind.starts_with('X') && self.kind.contains('A'))
352 }
353
354 /// Whether this entry grants anything in [`WRITE_MASK`].
355 ///
356 /// An unreadable rights field counts as granting write. A descriptor this
357 /// cannot parse is a descriptor this cannot vouch for, and the caller's
358 /// response to `true` is to refuse rather than to widen.
359 fn grants_write(&self) -> bool {
360 rights_mask(self.rights).is_none_or(|mask| mask & WRITE_MASK != 0)
361 }
362}
363
364/// The access mask an SDDL rights field denotes.
365///
366/// `None` for a field this does not recognise, which every caller treats as
367/// "assume the worst".
368fn rights_mask(field: &str) -> Option<u32> {
369 if field.is_empty() {
370 return Some(0);
371 }
372 if !field.is_ascii() {
373 return None;
374 }
375 if let Some(hex) = field
376 .strip_prefix("0x")
377 .or_else(|| field.strip_prefix("0X"))
378 {
379 return u32::from_str_radix(hex, 16).ok();
380 }
381 if !field.len().is_multiple_of(2) {
382 return None;
383 }
384 let mut mask = 0u32;
385 for index in (0..field.len()).step_by(2) {
386 let token = field[index..index + 2].to_ascii_uppercase();
387 mask |= match token.as_str() {
388 // Generic.
389 "GA" => 0x1000_0000,
390 "GR" => 0x8000_0000,
391 "GW" => 0x4000_0000,
392 "GX" => 0x2000_0000,
393 // Standard.
394 "SD" => 0x0001_0000,
395 "RC" => 0x0002_0000,
396 "WD" => 0x0004_0000,
397 "WO" => 0x0008_0000,
398 // Object-specific bits, under their directory-service names. On a
399 // filesystem object these are the FILE_* rights of the same value.
400 "CC" => 0x0000_0001,
401 "DC" => 0x0000_0002,
402 "LC" => 0x0000_0004,
403 "SW" => 0x0000_0008,
404 "RP" => 0x0000_0010,
405 "WP" => 0x0000_0020,
406 "DT" => 0x0000_0040,
407 "LO" => 0x0000_0080,
408 "CR" => 0x0000_0100,
409 // File and directory.
410 "FA" => 0x001F_01FF,
411 "FR" => 0x0012_0089,
412 "FW" => 0x0012_0116,
413 "FX" => 0x0012_00A0,
414 // Registry, which cannot name a directory but is cheap to accept.
415 "KA" => 0x000F_003F,
416 "KR" | "KX" => 0x0002_0019,
417 "KW" => 0x0002_0006,
418 _ => return None,
419 };
420 }
421 Some(mask)
422}
423
424/// Whether a DACL lets a local user unrelated to this product write inside the
425/// object it protects.
426///
427/// This is the security preflight `04-security-recovery.md` requires and the
428/// reason an existing root can fail an install. It is the *write* counterpart of
429/// the read question [`crate::process::permissions_summary`] answers, and the
430/// two differ in more than the mask: a directory whose DACL is merely readable
431/// is a diagnostic, while one that is writable is an execution boundary.
432///
433/// Inheritance flags are ignored on purpose. An inherit-only broad ACE grants
434/// nothing on the root and everything on the attempt directories created below
435/// it, which is the half that matters.
436#[must_use]
437pub fn grants_broad_write(descriptor: &str) -> bool {
438 let Some(aces) = aces(descriptor) else {
439 // No DACL is not an empty DACL. Windows treats an object with no
440 // discretionary access control as granting everyone everything, and
441 // this is not a question to be optimistic about.
442 return true;
443 };
444 aces.iter().any(|ace| {
445 ace.is_allow()
446 && BROAD_TRUSTEES
447 .iter()
448 .any(|broad| ace.trustee.eq_ignore_ascii_case(broad))
449 && ace.grants_write()
450 })
451}
452
453/// Whether a DACL carries `SE_DACL_PROTECTED`, so that nothing is inherited into
454/// it from the volume root.
455#[must_use]
456pub fn is_protected(descriptor: &str) -> bool {
457 descriptor.split("D:").nth(1).is_some_and(|body| {
458 body.chars()
459 .take_while(|character| *character != '(')
460 .any(|character| character == 'P')
461 })
462}
463
464/// Every trustee a DACL grants write access to, canonicalised.
465#[must_use]
466pub fn write_trustees(descriptor: &str) -> BTreeSet<String> {
467 write_grants(descriptor).into_keys().collect()
468}
469
470/// The same, with what each trustee is granted.
471///
472/// A rights field this cannot parse contributes `u32::MAX` rather than nothing,
473/// so a descriptor that cannot be read is a descriptor that never compares
474/// equal to the one this module writes — which costs a rewrite and cannot cost
475/// an under-reconciled root.
476fn write_grants(descriptor: &str) -> BTreeMap<String, u32> {
477 let mut grants: BTreeMap<String, u32> = BTreeMap::new();
478 for ace in aces(descriptor).unwrap_or_default() {
479 if ace.is_allow() && ace.grants_write() {
480 *grants.entry(canonical_trustee(ace.trustee)).or_default() |=
481 rights_mask(ace.rights).unwrap_or(u32::MAX);
482 }
483 }
484 grants
485}
486
487/// One spelling for the two trustees that have a fixed SID.
488///
489/// Only those two. An account SID is machine-specific and Windows renders some
490/// of them back as aliases it chose — the built-in Administrator's
491/// `S-1-5-21-…-500` reads back as `LA`, which is the round trip that already
492/// cost [`crate::secrets`] a bug — so no attempt is made to canonicalise one.
493/// [`admits_exactly`] is written to be safe when that comparison fails.
494fn canonical_trustee(trustee: &str) -> String {
495 let upper = trustee.to_ascii_uppercase();
496 match upper.as_str() {
497 "SY" => SID_LOCAL_SYSTEM.to_owned(),
498 "BA" => SID_ADMINISTRATORS.to_owned(),
499 _ => upper,
500 }
501}
502
503/// Whether a DACL is already exactly what [`default_root_sddl`] would write.
504///
505/// Used only to skip a rewrite that would change nothing. A false negative
506/// costs one `SetNamedSecurityInfoW` — which is why an alias Windows substituted
507/// for an account SID is allowed to produce one — and a false positive would
508/// leave the root under-reconciled after a mode change, which is why the
509/// comparison is equality rather than "contains what is needed".
510///
511/// The rights are compared as well as the trustees, and that is not
512/// fastidiousness. A root that already names `SY`, `BA` and the selected
513/// account but grants the third `FA` matches on trustees alone, and `FA`
514/// carries the `WRITE_DAC` and `WRITE_OWNER` that [`ADMITTED_RIGHTS`] exists to
515/// withhold — so accepting it would leave the admitted account able to undo the
516/// protection this module applied. Masks rather than text, because Windows
517/// renders `FRFWFXSD` back as `0x1301bf`.
518#[must_use]
519pub fn admits_exactly(descriptor: &str, admission: &RootAdmission) -> bool {
520 if !is_protected(descriptor) {
521 return false;
522 }
523 // Both constants are spellings `rights_mask` recognises, so neither
524 // fallback is reachable; they are the same fail-closed one `write_grants`
525 // documents, so an unreadable expectation could only ever cost a rewrite.
526 let full_control = rights_mask("FA").unwrap_or(u32::MAX);
527 let mut expected: BTreeMap<String, u32> = [
528 (SID_LOCAL_SYSTEM.to_owned(), full_control),
529 (SID_ADMINISTRATORS.to_owned(), full_control),
530 ]
531 .into_iter()
532 .collect();
533 if let Some(sid) = admission.sid() {
534 // `or_default` and `|=` rather than `insert`, because a daemon running
535 // as LocalSystem names a SID the first entry already carries, and
536 // `default_root_sddl` writes no second ACE for it.
537 *expected.entry(canonical_trustee(sid)).or_default() |=
538 rights_mask(ADMITTED_RIGHTS).unwrap_or(u32::MAX);
539 }
540 write_grants(descriptor) == expected
541}
542
543/// A security descriptor with account SIDs reduced to the fact that they are
544/// account SIDs.
545///
546/// `S-1-5-21-<machine or domain>-<rid>` identifies a machine and a user. The
547/// well-known trustees do not identify anything — `SY` and `BA` are the same
548/// two words on every host — so they survive, and what an operator sees is the
549/// *shape* of the access control without a new identity in it.
550#[must_use]
551pub fn redact(descriptor: &str) -> String {
552 /// The authority every machine-local and domain account SID starts with.
553 const PREFIX: &str = "S-1-5-21-";
554
555 let mut out = String::with_capacity(descriptor.len());
556 let mut rest = descriptor;
557 while let Some(start) = rest.find(PREFIX) {
558 out.push_str(&rest[..start]);
559 out.push_str("S-1-5-21-<account>");
560 // Past the prefix before looking for the end of the sub-authorities.
561 // Resuming at `start` would find `S` — neither a digit nor a dash —
562 // conclude that the SID is zero characters long, and match the same
563 // prefix again on the next pass, forever.
564 let tail = &rest[start + PREFIX.len()..];
565 let end = tail
566 .find(|character: char| !character.is_ascii_digit() && character != '-')
567 .unwrap_or(tail.len());
568 rest = &tail[end..];
569 }
570 out.push_str(rest);
571 out
572}
573
574// ---------------------------------------------------------------------------
575// Errors
576// ---------------------------------------------------------------------------
577
578/// Why the default runner root could not be created, inspected, or reconciled.
579///
580/// Every variant is printed straight at an operator, so each says what to do
581/// next. None carries an account SID: a message an operator pastes into an issue
582/// should not be the thing that publishes their machine's identifiers, which is
583/// what [`redact`] is applied for before a descriptor reaches one of these.
584#[derive(Debug, thiserror::Error)]
585pub enum RootAccessError {
586 /// The default root could not be resolved, or failed `b1`'s preflight.
587 #[error("{source}")]
588 Resolve {
589 /// What `b1` reported.
590 #[source]
591 source: Box<RunnerRootError>,
592 },
593
594 /// This process's own account could not be identified.
595 #[error(
596 "this account's identity could not be read, so the runner root cannot be given the \
597 access a login-mode registration needs: {source}"
598 )]
599 Identity {
600 /// What reading the process token reported.
601 #[source]
602 source: io::Error,
603 },
604
605 /// The root already exists and is open to ordinary local users.
606 ///
607 /// The refusal is the point. Tightening the directory instead would silently
608 /// adopt whatever is already inside one that any local account could have
609 /// created and filled, and a runner root's contents are executed.
610 #[error(
611 "{} already exists and grants write access to ordinary local users, so it is not a \
612 safe place to run jobs: its access control is {dacl}. This is what a directory \
613 created below {} with inheritance left on looks like, and it is refused rather \
614 than tightened because the contents of a directory anybody could write cannot be \
615 trusted. Remove or empty it and run this again, or point the runner root somewhere \
616 this account controls with `{remediation}`.",
617 path.display(),
618 volume.display()
619 )]
620 BroadExistingAccess {
621 /// The root.
622 path: PathBuf,
623 /// Its DACL, in SDDL, with account SIDs redacted.
624 dacl: String,
625 /// The volume the inherited grant would have come from.
626 volume: PathBuf,
627 /// The command that configures a different root.
628 remediation: String,
629 },
630
631 /// The root does not exist and could not be created.
632 #[error(
633 "the default runner root {} could not be created: {source}. Create it as an \
634 administrator, or configure a directory this account owns with `{remediation}`.",
635 path.display()
636 )]
637 Create {
638 /// The root.
639 path: PathBuf,
640 /// What the operating system reported.
641 #[source]
642 source: io::Error,
643 /// The command that configures a different root.
644 remediation: String,
645 },
646
647 /// The root exists but its access control could not be read.
648 #[error(
649 "the access control of the default runner root {} could not be read: {source}. \
650 Without it there is no way to tell whether unrelated local users can write there, \
651 so this fails closed. Read it as an administrator, or configure a directory this \
652 account owns with `{remediation}`.",
653 path.display()
654 )]
655 Inspect {
656 /// The root.
657 path: PathBuf,
658 /// What the operating system reported.
659 #[source]
660 source: io::Error,
661 /// The command that configures a different root.
662 remediation: String,
663 },
664
665 /// The root exists and is not open, but could not be reconciled.
666 #[error(
667 "the access control of the default runner root {} could not be applied: {source}. \
668 Changing a directory's access control needs WRITE_DAC, which this account has \
669 only as its owner or as an administrator — an elevated shell is the usual answer. \
670 Otherwise configure a directory this account owns with `{remediation}`.",
671 path.display()
672 )]
673 Apply {
674 /// The root.
675 path: PathBuf,
676 /// What the operating system reported.
677 #[source]
678 source: io::Error,
679 /// The command that configures a different root.
680 remediation: String,
681 },
682}
683
684impl RootAccessError {
685 /// The root the failure is about, when it is about one.
686 #[must_use]
687 pub fn path(&self) -> Option<&Path> {
688 match self {
689 Self::Resolve { .. } | Self::Identity { .. } => None,
690 Self::BroadExistingAccess { path, .. }
691 | Self::Create { path, .. }
692 | Self::Inspect { path, .. }
693 | Self::Apply { path, .. } => Some(path),
694 }
695 }
696}
697
698/// The command an operator runs to move the runner root elsewhere.
699///
700/// Every caller outside the tests is `reconcile`, which is Windows-only and is
701/// the only place that builds an error carrying one. Named without an intra-doc
702/// link for exactly that reason: off Windows there is no such item to link to.
703#[cfg_attr(
704 not(windows),
705 allow(
706 dead_code,
707 reason = "only `reconcile`, which is Windows-only, builds an error that carries a remedy"
708 )
709)]
710fn remediation() -> String {
711 RootOwner::Host.remediation()
712}
713
714// ---------------------------------------------------------------------------
715// What happened
716// ---------------------------------------------------------------------------
717
718/// What creating or reconciling the default runner root amounted to.
719///
720/// Carried out of `service install` and `service set-start-mode` so a command
721/// can print it. It names trustees by [`AdmittedTrustee`] rather than by SID, so
722/// printing the whole value adds no identity to the output.
723#[derive(Debug, Clone, PartialEq, Eq)]
724pub enum RootAccessSummary {
725 /// Not a Windows host. Nothing was created and nothing was re-permissioned.
726 ///
727 /// macOS and Linux keep the runner root the application-data runtime
728 /// directory has always been, and its permissions are the ones
729 /// [`AppPaths`] already establishes. There is no inherited-broad-grant
730 /// problem to solve, and inventing one would move live workspaces for no
731 /// reason (`02-target-architecture.md`, "Platform defaults").
732 NotApplicable,
733 /// The root did not exist and was created with its access control applied by
734 /// the call that created it.
735 Created {
736 /// The root.
737 path: PathBuf,
738 /// Who it admits.
739 admits: Vec<AdmittedTrustee>,
740 },
741 /// The root existed and already admitted exactly the right trustees.
742 AlreadyReconciled {
743 /// The root.
744 path: PathBuf,
745 /// Who it admits.
746 admits: Vec<AdmittedTrustee>,
747 },
748 /// The root existed and its access control was rewritten.
749 Reconciled {
750 /// The root.
751 path: PathBuf,
752 /// Who it admits now.
753 admits: Vec<AdmittedTrustee>,
754 },
755}
756
757impl RootAccessSummary {
758 /// The root, when there was one.
759 #[must_use]
760 pub fn path(&self) -> Option<&Path> {
761 match self {
762 Self::NotApplicable => None,
763 Self::Created { path, .. }
764 | Self::AlreadyReconciled { path, .. }
765 | Self::Reconciled { path, .. } => Some(path),
766 }
767 }
768
769 /// Whether this operation created the directory.
770 #[must_use]
771 pub const fn created(&self) -> bool {
772 matches!(self, Self::Created { .. })
773 }
774}
775
776impl fmt::Display for RootAccessSummary {
777 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778 let (verb, path, admits) = match self {
779 Self::NotApplicable => {
780 // Said without claiming *which* root it is about. This is both
781 // the macOS and Linux answer, where the root is the runtime
782 // directory `AppPaths` already permissions, and the Windows
783 // answer for an operation that moved nothing — and on Windows
784 // the runner root is emphatically not the runtime directory.
785 return f.write_str(
786 "The runner root's access control was not created or changed by this \
787 operation.",
788 );
789 }
790 Self::Created { path, admits } => ("was created admitting", path, admits),
791 Self::AlreadyReconciled { path, admits } => ("already admitted", path, admits),
792 Self::Reconciled { path, admits } => ("was reconciled to admit", path, admits),
793 };
794 let names: Vec<String> = admits.iter().map(ToString::to_string).collect();
795 write!(
796 f,
797 "The runner root {} {verb} {}, and inherits nothing from the volume above it, so \
798 unrelated local users cannot write there.",
799 path.display(),
800 names.join(", ")
801 )
802 }
803}
804
805/// A [`RootAccessSummary`] plus what it would take to undo.
806///
807/// The task requires directory and ACL work to be "transactional where current
808/// service installation rollback supports it". `service install` already rolls
809/// back a registration when the record cannot be written, so this is the same
810/// idea for the two effects this module has:
811///
812/// * a directory this call created can be removed again, and
813/// * a descriptor this call replaced can be written back, because the previous
814/// one was read first and kept.
815///
816/// What cannot be undone is a directory that was already there — and, per the
817/// same requirement, that is *reported* rather than pretended about. See
818/// [`Reversal`].
819#[derive(Debug, Clone)]
820pub struct RootAccessChange {
821 summary: RootAccessSummary,
822 /// Compiled where it is read rather than allowed where it is not.
823 ///
824 /// Only [`Self::revert`]'s Windows arm ever reads this, and only
825 /// [`reconcile`] ever fills it. On a platform with no descriptor to put
826 /// back the field is not there to be dead, so there is nothing to allow.
827 /// [`crate::process`] states the rule for this shape of problem: an
828 /// allowance leaves the lint's premise true and silences the report, while
829 /// a `cfg` makes the premise false instead.
830 #[cfg(windows)]
831 previous_dacl: Option<String>,
832}
833
834impl RootAccessChange {
835 /// The nothing that happens on a platform without this problem.
836 #[must_use]
837 pub const fn not_applicable() -> Self {
838 Self {
839 summary: RootAccessSummary::NotApplicable,
840 #[cfg(windows)]
841 previous_dacl: None,
842 }
843 }
844
845 /// What happened, in a form a command can print.
846 #[must_use]
847 pub const fn summary(&self) -> &RootAccessSummary {
848 &self.summary
849 }
850
851 /// Undoes as much of this change as can be undone, and says what could not
852 /// be.
853 ///
854 /// Deliberately infallible in the type: a rollback runs while another
855 /// failure is already being reported, and a rollback that could itself
856 /// return `Err` would either hide that failure or replace it. The
857 /// [`Reversal`] says what happened and the caller folds it into the message
858 /// it was already writing.
859 #[must_use]
860 pub fn revert(&self) -> Reversal {
861 #[cfg(windows)]
862 {
863 match &self.summary {
864 RootAccessSummary::NotApplicable | RootAccessSummary::AlreadyReconciled { .. } => {
865 Reversal::NothingToUndo
866 }
867 RootAccessSummary::Created { path, .. } => match std::fs::remove_dir(path) {
868 Ok(()) => Reversal::Removed { path: path.clone() },
869 Err(source) if source.kind() == io::ErrorKind::NotFound => {
870 Reversal::NothingToUndo
871 }
872 Err(source) => Reversal::Retained {
873 path: path.clone(),
874 detail: format!(
875 "the directory this operation created could not be removed again \
876 ({source}); it is empty unless something else has written to it, \
877 and removing it by hand is safe"
878 ),
879 },
880 },
881 RootAccessSummary::Reconciled { path, .. } => {
882 let Some(previous) = self.previous_dacl.as_deref() else {
883 return Reversal::NothingToUndo;
884 };
885 match sys::write_dacl(path, previous) {
886 Ok(()) => Reversal::Restored { path: path.clone() },
887 Err(source) => Reversal::Retained {
888 path: path.clone(),
889 detail: format!(
890 "this directory existed before this operation and could not be \
891 removed by it; its previous access control could not be put \
892 back either ({source}), so it now carries the access control \
893 this operation applied"
894 ),
895 },
896 }
897 }
898 }
899 }
900 #[cfg(not(windows))]
901 {
902 Reversal::NothingToUndo
903 }
904 }
905}
906
907/// What undoing a [`RootAccessChange`] achieved.
908#[derive(Debug, Clone, PartialEq, Eq)]
909pub enum Reversal {
910 /// There was nothing to undo, or nothing had been changed.
911 NothingToUndo,
912 /// A directory this operation created was removed again.
913 Removed {
914 /// The directory that is gone.
915 path: PathBuf,
916 },
917 /// A descriptor this operation replaced was written back.
918 Restored {
919 /// The directory whose access control is as it was.
920 path: PathBuf,
921 },
922 /// Something is left behind, and here is exactly what.
923 ///
924 /// This is the "report any non-reversible existing directory state
925 /// explicitly" half of the requirement. It is never silent.
926 Retained {
927 /// The directory that remains.
928 path: PathBuf,
929 /// What about it could not be undone.
930 detail: String,
931 },
932}
933
934impl fmt::Display for Reversal {
935 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
936 match self {
937 Self::NothingToUndo => f.write_str("the runner root was left as it was found"),
938 Self::Removed { path } => {
939 write!(f, "the runner root {} was removed again", path.display())
940 }
941 Self::Restored { path } => write!(
942 f,
943 "the previous access control of {} was restored",
944 path.display()
945 ),
946 Self::Retained { path, detail } => write!(f, "{}: {detail}", path.display()),
947 }
948 }
949}
950
951// ---------------------------------------------------------------------------
952// Reading a root without touching it
953// ---------------------------------------------------------------------------
954
955/// What a runner root's access control amounts to, said without naming an
956/// account.
957///
958/// The read-only half of this module, and the whole of what a **custom**
959/// operator root ever gets: `03-migration-rollout.md` refuses to move or
960/// re-permission a directory the operator chose, so a configured root is
961/// preflighted and described, never rewritten.
962#[derive(Debug, Clone, PartialEq, Eq)]
963pub enum RootAccessReport {
964 /// Not a Windows host, so there is no DACL to describe.
965 NotApplicable,
966 /// The directory is not there.
967 Absent,
968 /// The directory is there and its access control could not be read.
969 Unreadable {
970 /// Why not.
971 detail: String,
972 },
973 /// The directory is there and this is what it grants.
974 Present {
975 /// Its DACL in SDDL, with account SIDs redacted by [`redact`].
976 dacl: String,
977 /// Whether it inherits nothing from the volume above it.
978 protected: bool,
979 /// Whether ordinary local users can write inside it.
980 broad_write: bool,
981 },
982}
983
984impl fmt::Display for RootAccessReport {
985 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
986 match self {
987 Self::NotApplicable => f.write_str("no Windows access control applies"),
988 Self::Absent => f.write_str("does not exist yet"),
989 Self::Unreadable { detail } => {
990 write!(f, "exists, but its access control cannot be read: {detail}")
991 }
992 Self::Present {
993 dacl,
994 protected,
995 broad_write,
996 } => write!(
997 f,
998 "{}; {}; {dacl}",
999 if *broad_write {
1000 "ordinary local users can write there"
1001 } else {
1002 "no ordinary local user can write there"
1003 },
1004 if *protected {
1005 "inherits nothing from the volume above"
1006 } else {
1007 "inherits from the volume above"
1008 }
1009 ),
1010 }
1011 }
1012}
1013
1014/// Describes a runner root's access control **without changing it**.
1015///
1016/// This is what a configured custom root gets, and what `service status` reports
1017/// about the default one. It creates nothing, permissions nothing, and is safe
1018/// to call on a path this product does not own.
1019#[must_use]
1020pub fn report(path: &Path) -> RootAccessReport {
1021 #[cfg(windows)]
1022 {
1023 // `Path::exists` answers "absent" to every question it cannot answer,
1024 // including "this account may not traverse the parent". That is the one
1025 // answer this must not give for a directory that is there, because
1026 // `Absent` reads as "nothing to worry about yet" while the truthful
1027 // outcome is `Unreadable`, which says so.
1028 match std::fs::symlink_metadata(path) {
1029 Ok(_) => {}
1030 Err(source) if source.kind() == io::ErrorKind::NotFound => {
1031 return RootAccessReport::Absent;
1032 }
1033 Err(source) => {
1034 return RootAccessReport::Unreadable {
1035 detail: source.to_string(),
1036 };
1037 }
1038 }
1039 // The reader `crate::process` already carries, rather than a second
1040 // descriptor round trip of this module's own.
1041 match crate::process::permissions_summary(path) {
1042 Ok(summary) => RootAccessReport::Present {
1043 protected: is_protected(&summary.description),
1044 broad_write: grants_broad_write(&summary.description),
1045 dacl: redact(&summary.description),
1046 },
1047 Err(source) => RootAccessReport::Unreadable {
1048 detail: source.to_string(),
1049 },
1050 }
1051 }
1052 #[cfg(not(windows))]
1053 {
1054 let _ = path;
1055 RootAccessReport::NotApplicable
1056 }
1057}
1058
1059// ---------------------------------------------------------------------------
1060// The operation
1061// ---------------------------------------------------------------------------
1062
1063/// Creates or reconciles **the platform default** runner root.
1064///
1065/// Takes no path, and that is the design: there is no argument through which a
1066/// caller could aim this at an operator's configured directory. See this
1067/// module's documentation.
1068///
1069/// The order is the contract:
1070///
1071/// 1. resolve the platform default;
1072/// 2. run `b1`'s operational preflight, which mutates nothing;
1073/// 3. if the leaf is missing, create it **with its descriptor applied by the
1074/// call that creates it**, so there is no window in which it exists carrying
1075/// the volume's inherited grants;
1076/// 4. if it is already there, read its DACL and refuse if ordinary local users
1077/// can write inside it;
1078/// 5. otherwise reconcile the descriptor to admit exactly `SY`, `BA` and the
1079/// selected account, skipping the write when it already does.
1080///
1081/// # Errors
1082/// Any [`RootAccessError`]. In particular [`RootAccessError::BroadExistingAccess`]
1083/// when the directory is already open, which is a refusal rather than a repair.
1084pub fn ensure_default_root(
1085 paths: &AppPaths,
1086 admission: &RootAdmission,
1087) -> Result<RootAccessChange, RootAccessError> {
1088 #[cfg(windows)]
1089 {
1090 let root = crate::runner_root::default_runner_root(paths).map_err(|source| {
1091 RootAccessError::Resolve {
1092 source: Box::new(source),
1093 }
1094 })?;
1095 reconcile(paths, &root, admission)
1096 }
1097 #[cfg(not(windows))]
1098 {
1099 let _ = (paths, admission);
1100 Ok(RootAccessChange::not_applicable())
1101 }
1102}
1103
1104/// [`ensure_default_root`] against a root the caller names.
1105///
1106/// `pub(crate)` and nothing more, so no consumer of this crate can reach it,
1107/// and Windows-only because every caller is: [`ensure_default_root`] and
1108/// [`crate::service::ServiceOperations`] both reach it from a Windows arm, so
1109/// on the other two platforms it is not there to be dead rather than dead and
1110/// allowed. [`RootAccessChange::not_applicable`] is what those platforms
1111/// return instead.
1112///
1113/// The only caller that passes a path other than the platform default is
1114/// [`crate::service::ServiceOperations::with_runner_root`], whose override is
1115/// honoured for a [`crate::service::ServiceIdentity::fixture`] registration or
1116/// under `cfg(test)` and is otherwise ignored in favour of
1117/// [`ensure_default_root`]. A shipped binary therefore honours the fixture name
1118/// alone, and a fixture name cannot be the product's — which is what keeps a
1119/// released `service install` pointed at the platform default whatever it is
1120/// handed. A privileged smoke test uses it to exercise a directory it owns
1121/// instead of the real `C:\rman`.
1122#[cfg(windows)]
1123pub(crate) fn reconcile(
1124 paths: &AppPaths,
1125 root: &LocalAbsolutePath,
1126 admission: &RootAdmission,
1127) -> Result<RootAccessChange, RootAccessError> {
1128 let checked = RootPreflight::new(paths)
1129 .check(&RootOwner::Host, root)
1130 .map_err(|source| RootAccessError::Resolve {
1131 source: Box::new(source),
1132 })?;
1133 let desired = default_root_sddl(admission);
1134 let path = root.as_path().to_path_buf();
1135
1136 if checked.leaf_to_create().is_some() {
1137 match sys::create_with_dacl(&path, &desired) {
1138 Ok(()) => {
1139 return Ok(RootAccessChange {
1140 summary: RootAccessSummary::Created {
1141 path,
1142 admits: admission.admits(),
1143 },
1144 previous_dacl: None,
1145 });
1146 }
1147 // Another process created it between the preflight and here.
1148 // Fall through and treat it as the pre-existing directory it now
1149 // is, which applies the same refusal to it as to any other.
1150 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
1151 Err(source) => {
1152 return Err(RootAccessError::Create {
1153 path,
1154 source,
1155 remediation: remediation(),
1156 });
1157 }
1158 }
1159 }
1160
1161 let current = sys::read_dacl(&path).map_err(|source| RootAccessError::Inspect {
1162 path: path.clone(),
1163 source,
1164 remediation: remediation(),
1165 })?;
1166
1167 if grants_broad_write(¤t) {
1168 return Err(RootAccessError::BroadExistingAccess {
1169 dacl: redact(¤t),
1170 volume: volume_of(&path),
1171 path,
1172 remediation: remediation(),
1173 });
1174 }
1175
1176 if admits_exactly(¤t, admission) {
1177 return Ok(RootAccessChange {
1178 summary: RootAccessSummary::AlreadyReconciled {
1179 path,
1180 admits: admission.admits(),
1181 },
1182 previous_dacl: None,
1183 });
1184 }
1185
1186 sys::write_dacl(&path, &desired).map_err(|source| RootAccessError::Apply {
1187 path: path.clone(),
1188 source,
1189 remediation: remediation(),
1190 })?;
1191 Ok(RootAccessChange {
1192 summary: RootAccessSummary::Reconciled {
1193 path,
1194 admits: admission.admits(),
1195 },
1196 previous_dacl: Some(current),
1197 })
1198}
1199
1200/// Creates a directory carrying an exact descriptor, for this crate's own tests
1201/// only.
1202///
1203/// [`crate::service`]'s unit tests need a runner root that is deliberately open
1204/// to ordinary local users, and no temporary directory is: `%TEMP%` is
1205/// per-account, so everything created below it is already narrow. Building the
1206/// case by hand is the only way to reach the refusal that matters, and it is
1207/// the same call [`reconcile`] makes.
1208#[cfg(all(test, windows))]
1209pub(crate) fn create_with_descriptor_for_tests(path: &Path, sddl: &str) -> io::Result<()> {
1210 sys::create_with_dacl(path, sddl)
1211}
1212
1213/// The volume a path sits on, for the message that explains where an inherited
1214/// grant came from.
1215///
1216/// Windows-only because that message is: [`reconcile`] is the only caller, and
1217/// it is the item this file compiles where it is used rather than allows where
1218/// it is not.
1219#[cfg(windows)]
1220fn volume_of(path: &Path) -> PathBuf {
1221 path.ancestors()
1222 .last()
1223 .map_or_else(|| path.to_path_buf(), Path::to_path_buf)
1224}
1225
1226// ---------------------------------------------------------------------------
1227// Windows
1228// ---------------------------------------------------------------------------
1229
1230#[cfg(windows)]
1231mod sys {
1232 //! The three calls this module makes, and nothing else.
1233 //!
1234 //! Every one of them goes through SDDL rather than through
1235 //! `SetEntriesInAclW` and a hand-built ACL. That is the same choice
1236 //! [`crate::secrets`] and [`crate::process`] made, for the same two reasons:
1237 //! the descriptor a reviewer reads is the descriptor the host applies, and
1238 //! the unsafe surface is one conversion instead of an allocation protocol.
1239
1240 use std::ffi::OsStr;
1241 use std::io;
1242 use std::os::windows::ffi::OsStrExt;
1243 use std::path::Path;
1244
1245 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
1246 use windows::Win32::Security::Authorization::{
1247 ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, SE_FILE_OBJECT,
1248 SetNamedSecurityInfoW,
1249 };
1250 use windows::Win32::Security::{
1251 ACL, DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl,
1252 PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
1253 UNPROTECTED_DACL_SECURITY_INFORMATION,
1254 };
1255 use windows::Win32::Storage::FileSystem::CreateDirectoryW;
1256 use windows::core::PCWSTR;
1257
1258 /// A NUL-terminated wide string, as every `…W` entry point wants one.
1259 fn to_wide(value: &OsStr) -> Vec<u16> {
1260 value.encode_wide().chain(std::iter::once(0)).collect()
1261 }
1262
1263 /// A `windows` error as the `io::Error` this module's callers report.
1264 ///
1265 /// The facility is unwrapped rather than passed through, and that is not
1266 /// cosmetic. `windows-rs` reports a Win32 failure as `HRESULT_FROM_WIN32`,
1267 /// so `ERROR_ALREADY_EXISTS` arrives as `0x8007_00B7` — and
1268 /// `io::Error::from_raw_os_error` classifies the *Win32* code, which means
1269 /// the wrapped form reads back as [`io::ErrorKind::Uncategorized`] where
1270 /// the bare `183` reads back as [`io::ErrorKind::AlreadyExists`].
1271 /// [`super::reconcile`] branches on exactly that kind to absorb a directory
1272 /// created between the preflight and the creation, so leaving the facility
1273 /// on would make that branch unreachable.
1274 fn io_error(error: &windows::core::Error) -> io::Error {
1275 /// The high half `HRESULT_FROM_WIN32` puts in front of a Win32 code.
1276 const FACILITY_WIN32: u32 = 0x8007_0000;
1277
1278 let hresult = error.code().0;
1279 let bits = hresult.cast_unsigned();
1280 if bits & 0xFFFF_0000 == FACILITY_WIN32 {
1281 io::Error::from_raw_os_error((bits & 0x0000_FFFF).cast_signed())
1282 } else {
1283 io::Error::from_raw_os_error(hresult)
1284 }
1285 }
1286
1287 /// A security descriptor built from SDDL, freed when it goes out of scope.
1288 ///
1289 /// A guard rather than a `LocalFree` at each exit: the two callers below
1290 /// both have several, and a leak on the error path is exactly the kind of
1291 /// thing that is never noticed.
1292 struct Descriptor(PSECURITY_DESCRIPTOR);
1293
1294 impl Descriptor {
1295 fn from_sddl(sddl: &str) -> io::Result<Self> {
1296 let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
1297 let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1298 // SAFETY: `wide` is NUL-terminated and outlives the call, which
1299 // fills `descriptor` with a LocalAlloc'd block this guard frees.
1300 unsafe {
1301 ConvertStringSecurityDescriptorToSecurityDescriptorW(
1302 PCWSTR(wide.as_ptr()),
1303 SDDL_REVISION_1,
1304 &mut descriptor,
1305 None,
1306 )
1307 }
1308 .map_err(|error| io_error(&error))?;
1309 Ok(Self(descriptor))
1310 }
1311
1312 /// The DACL inside it.
1313 ///
1314 /// The pointer is into the descriptor this guard owns, so it is only
1315 /// valid while `self` is.
1316 fn dacl(&self) -> io::Result<*const ACL> {
1317 let mut present = windows::core::BOOL(0);
1318 let mut acl: *mut ACL = std::ptr::null_mut();
1319 let mut defaulted = windows::core::BOOL(0);
1320 // SAFETY: `self.0` is a valid descriptor for the lifetime of `self`;
1321 // the three out-parameters are live locals.
1322 unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut acl, &mut defaulted) }
1323 .map_err(|error| io_error(&error))?;
1324 if !present.as_bool() || acl.is_null() {
1325 return Err(io::Error::new(
1326 io::ErrorKind::InvalidData,
1327 "the security descriptor built from SDDL carries no DACL",
1328 ));
1329 }
1330 Ok(acl.cast_const())
1331 }
1332 }
1333
1334 impl Drop for Descriptor {
1335 fn drop(&mut self) {
1336 // SAFETY: LocalAlloc'd by the conversion above, freed exactly once.
1337 unsafe {
1338 let _ = LocalFree(Some(HLOCAL(self.0.0)));
1339 }
1340 }
1341 }
1342
1343 /// Creates a directory that carries its access control from the moment it
1344 /// exists.
1345 ///
1346 /// Not `create_dir` followed by a descriptor write. The gap between those
1347 /// two is a directory sitting on `C:\` with the volume's inherited grants,
1348 /// and a local account that loses that race gets a workspace root it can
1349 /// write into. The same reasoning `crate::process::RestrictiveHandoff`
1350 /// applies to the JIT configuration file applies here.
1351 pub(super) fn create_with_dacl(path: &Path, sddl: &str) -> io::Result<()> {
1352 let descriptor = Descriptor::from_sddl(sddl)?;
1353 let attributes = SECURITY_ATTRIBUTES {
1354 nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
1355 lpSecurityDescriptor: descriptor.0.0,
1356 bInheritHandle: windows::core::BOOL(0),
1357 };
1358 let wide = to_wide(path.as_os_str());
1359 // SAFETY: `wide` is NUL-terminated, and `attributes` points at a
1360 // descriptor that outlives the call.
1361 unsafe { CreateDirectoryW(PCWSTR(wide.as_ptr()), Some(&raw const attributes)) }
1362 .map_err(|error| io_error(&error))
1363 }
1364
1365 /// The DACL a directory carries, in SDDL.
1366 ///
1367 /// Goes through the reader [`crate::process`] already has rather than
1368 /// repeating its `GetNamedSecurityInfoW` round trip: one implementation of
1369 /// "read a DACL back" means one place where the descriptor is freed
1370 /// correctly, and it is already exercised by that module's own tests.
1371 pub(super) fn read_dacl(path: &Path) -> io::Result<String> {
1372 crate::process::permissions_summary(path)
1373 .map(|summary| summary.description)
1374 .map_err(|error| io::Error::other(error.to_string()))
1375 }
1376
1377 /// Replaces a directory's DACL, honouring whether the SDDL asks for
1378 /// protection.
1379 ///
1380 /// The flag is read from the descriptor rather than hard-coded, because this
1381 /// is also the call that puts back a DACL that was **not** protected when a
1382 /// reconciliation is rolled back. Writing that one back as protected would
1383 /// leave the directory in a third state that was never true.
1384 pub(super) fn write_dacl(path: &Path, sddl: &str) -> io::Result<()> {
1385 let descriptor = Descriptor::from_sddl(sddl)?;
1386 let acl = descriptor.dacl()?;
1387 let information = DACL_SECURITY_INFORMATION
1388 | if super::is_protected(sddl) {
1389 PROTECTED_DACL_SECURITY_INFORMATION
1390 } else {
1391 UNPROTECTED_DACL_SECURITY_INFORMATION
1392 };
1393 let wide = to_wide(path.as_os_str());
1394 // SAFETY: `wide` is NUL-terminated; `acl` points into `descriptor`,
1395 // which is still alive; the two SID parameters are deliberately absent,
1396 // so ownership is not touched.
1397 let status = unsafe {
1398 SetNamedSecurityInfoW(
1399 PCWSTR(wide.as_ptr()),
1400 SE_FILE_OBJECT,
1401 information,
1402 None,
1403 None,
1404 Some(acl),
1405 None,
1406 )
1407 };
1408 if status == ERROR_SUCCESS {
1409 Ok(())
1410 } else {
1411 Err(io::Error::from_raw_os_error(
1412 i32::try_from(status.0).unwrap_or(i32::MAX),
1413 ))
1414 }
1415 }
1416
1417 #[cfg(test)]
1418 mod tests {
1419 use std::io;
1420
1421 /// [`super::super::reconcile`] absorbs a directory created between the
1422 /// preflight and the creation by matching on
1423 /// [`io::ErrorKind::AlreadyExists`]. That branch is reachable only if
1424 /// [`super::io_error`] unwraps `HRESULT_FROM_WIN32`, so the mapping is
1425 /// pinned here against a failure Windows itself produced rather than
1426 /// against a constant this file chose.
1427 #[test]
1428 fn a_directory_that_already_exists_reads_back_as_already_exists() {
1429 let directory = tempfile::tempdir().expect("a temporary directory");
1430 let error = super::create_with_dacl(directory.path(), "D:P(A;OICI;FA;;;SY)")
1431 .expect_err("creating a directory that is already there fails");
1432 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists, "{error}");
1433 }
1434 }
1435}
1436
1437// ---------------------------------------------------------------------------
1438// Tests
1439// ---------------------------------------------------------------------------
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444
1445 /// The DACL of `C:\` on a stock Windows host, near enough. The ACE that
1446 /// matters is the last one: inherit-only, Authenticated Users, delete plus
1447 /// generic write. Everything created below `C:\` without protection gets it.
1448 const VOLUME_ROOT: &str = "D:PAI(A;;FA;;;SY)(A;OICIIO;GA;;;SY)(A;;FA;;;BA)(A;OICIIO;GA;;;BA)\
1449 (A;;0x1200a9;;;BU)(A;OICIIO;GXGR;;;BU)(A;;LC;;;BU)(A;CI;DC;;;BU)\
1450 (A;;0x1301bf;;;AU)(A;OICIIO;SDGXGWGR;;;AU)";
1451
1452 /// What a directory created below `C:\` with inheritance left on ends up
1453 /// carrying, as `GetNamedSecurityInfoW` renders it back.
1454 const INHERITED_FROM_VOLUME: &str =
1455 "D:AI(A;OICIID;GA;;;SY)(A;OICIID;GA;;;BA)(A;OICIID;GXGR;;;BU)(A;OICIID;SDGXGWGR;;;AU)";
1456
1457 fn account() -> RootAdmission {
1458 RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned())
1459 }
1460
1461 // -- the descriptor -----------------------------------------------------
1462
1463 #[test]
1464 fn a_boot_root_admits_system_and_administrators_and_nothing_else() {
1465 assert_eq!(
1466 default_root_sddl(&RootAdmission::LocalSystem),
1467 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"
1468 );
1469 }
1470
1471 #[test]
1472 fn a_login_root_admits_the_selected_account_with_modify_rather_than_full_control() {
1473 assert_eq!(
1474 default_root_sddl(&account()),
1475 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFWFXSD;;;S-1-5-21-1-2-3-1001)"
1476 );
1477 // The two rights that would let the admitted account undo the
1478 // protection are exactly the two it does not get.
1479 let mask = rights_mask(ADMITTED_RIGHTS).expect("the constant parses");
1480 assert_eq!(mask & 0x0004_0000, 0, "WRITE_DAC must not be granted");
1481 assert_eq!(mask & 0x0008_0000, 0, "WRITE_OWNER must not be granted");
1482 // And everything create-materialize-clean needs is.
1483 for (bit, name) in [
1484 (0x0000_0002, "FILE_WRITE_DATA"),
1485 (0x0000_0004, "FILE_APPEND_DATA"),
1486 (0x0001_0000, "DELETE"),
1487 (0x0000_0001, "FILE_READ_DATA"),
1488 ] {
1489 assert_ne!(mask & bit, 0, "{name} must be granted");
1490 }
1491 }
1492
1493 #[test]
1494 fn a_daemon_running_as_local_system_does_not_add_an_ace_for_itself() {
1495 // `current_user_sid()` under a boot service answers S-1-5-18, which the
1496 // first ACE already names.
1497 let as_system = RootAdmission::Account("S-1-5-18".to_owned());
1498 assert_eq!(
1499 default_root_sddl(&as_system),
1500 default_root_sddl(&RootAdmission::LocalSystem)
1501 );
1502 let as_administrators = RootAdmission::Account("s-1-5-32-544".to_owned());
1503 assert_eq!(
1504 default_root_sddl(&as_administrators),
1505 default_root_sddl(&RootAdmission::LocalSystem)
1506 );
1507 }
1508
1509 #[test]
1510 fn every_ace_the_default_writes_is_inherited_by_children() {
1511 // A service that could create `<root>\<attempt>` but not write inside it
1512 // would satisfy a descriptor review and fail the first job.
1513 for admission in [RootAdmission::LocalSystem, account()] {
1514 let sddl = default_root_sddl(&admission);
1515 for ace in sddl.split('(').skip(1) {
1516 let flags = ace.split(';').nth(1).expect("an ACE has a flags field");
1517 assert_eq!(flags, INHERITANCE, "in {sddl}");
1518 }
1519 }
1520 }
1521
1522 // -- the security preflight ---------------------------------------------
1523
1524 #[test]
1525 fn the_descriptor_this_module_writes_grants_no_broad_write() {
1526 for admission in [RootAdmission::LocalSystem, account()] {
1527 let sddl = default_root_sddl(&admission);
1528 assert!(!grants_broad_write(&sddl), "{sddl}");
1529 assert!(is_protected(&sddl), "{sddl}");
1530 }
1531 }
1532
1533 #[test]
1534 fn a_root_that_inherited_the_volumes_grants_is_broadly_writable() {
1535 // The whole reason this module exists, in one assertion.
1536 assert!(grants_broad_write(INHERITED_FROM_VOLUME));
1537 assert!(!is_protected(INHERITED_FROM_VOLUME));
1538 assert!(grants_broad_write(VOLUME_ROOT));
1539 }
1540
1541 #[test]
1542 fn the_directory_service_spellings_of_create_file_and_create_folder_are_caught() {
1543 // `LC` is 0x4 — FILE_ADD_SUBDIRECTORY — and `DC` is 0x2 —
1544 // FILE_ADD_FILE. Matching on the letters rather than the bits would
1545 // have read them as "list children" and "delete child" and missed the
1546 // grant entirely.
1547 assert!(grants_broad_write("D:P(A;OICI;LC;;;BU)"));
1548 assert!(grants_broad_write("D:P(A;OICI;DC;;;BU)"));
1549 // `CC` is 0x1, which on a directory is FILE_LIST_DIRECTORY: a read.
1550 assert!(!grants_broad_write("D:P(A;OICI;CC;;;BU)"));
1551 }
1552
1553 #[test]
1554 fn a_broad_read_only_grant_is_not_a_write_grant() {
1555 // Deliberately different from `process::permissions_summary`'s question.
1556 // A world-readable runner root is untidy; a world-writable one is a
1557 // code-execution boundary, and only the second refuses an install.
1558 assert!(!grants_broad_write("D:P(A;OICI;FA;;;SY)(A;OICI;FR;;;WD)"));
1559 assert!(!grants_broad_write("D:P(A;OICI;FA;;;SY)(A;OICI;GR;;;AU)"));
1560 assert!(!grants_broad_write("D:P(A;OICI;FA;;;SY)(A;OICI;FX;;;BU)"));
1561 }
1562
1563 #[test]
1564 fn a_hexadecimal_rights_field_is_read_as_bits() {
1565 // 0x1301bf is the "modify" mask Windows writes at `C:\` for
1566 // Authenticated Users; it contains FILE_WRITE_DATA.
1567 assert!(grants_broad_write("D:P(A;;0x1301bf;;;AU)"));
1568 // 0x1200a9 is read-and-execute, which is not a write grant.
1569 assert!(!grants_broad_write("D:P(A;;0x1200a9;;;BU)"));
1570 }
1571
1572 #[test]
1573 fn a_deny_ace_naming_everyone_is_a_tightening_not_a_leak() {
1574 assert!(!grants_broad_write("D:P(D;OICI;FA;;;WD)(A;OICI;FA;;;SY)"));
1575 }
1576
1577 #[test]
1578 fn an_unparseable_or_missing_descriptor_fails_closed() {
1579 assert!(
1580 grants_broad_write("O:BAG:BA"),
1581 "no DACL is not an empty DACL"
1582 );
1583 assert!(grants_broad_write("D:P(A;OICI;QQ;;;WD)"), "unknown rights");
1584 assert!(grants_broad_write("D:P(A;OICI;FAX;;;AU)"), "odd length");
1585 assert!(grants_broad_write("D:P(A;OICI;0xzz;;;AU)"), "bad hex");
1586 assert!(
1587 grants_broad_write("D:NO_ACCESS_CONTROL"),
1588 "a NULL DACL grants everyone everything; read as a flags field it would otherwise \
1589 parse to zero ACEs and be adopted as the narrowest directory on the machine"
1590 );
1591 assert!(!is_protected("D:NO_ACCESS_CONTROL"));
1592 assert!(write_trustees("D:NO_ACCESS_CONTROL").is_empty());
1593 }
1594
1595 #[test]
1596 fn a_creator_owner_grant_is_not_a_grant_to_an_unrelated_user() {
1597 // Inherited, `CO` gives each child's creator rights over that child.
1598 assert!(!grants_broad_write("D:P(A;OICI;FA;;;SY)(A;OICIIO;GA;;;CO)"));
1599 }
1600
1601 #[test]
1602 fn an_inherit_only_broad_ace_still_counts() {
1603 // It grants nothing on the root and everything on every attempt
1604 // directory created below it, which is the half that matters.
1605 assert!(grants_broad_write("D:P(A;OICIIO;GW;;;AU)"));
1606 }
1607
1608 // -- reconciliation ------------------------------------------------------
1609
1610 #[test]
1611 fn a_root_already_carrying_this_modules_descriptor_needs_no_rewrite() {
1612 for admission in [RootAdmission::LocalSystem, account()] {
1613 let sddl = default_root_sddl(&admission);
1614 assert!(admits_exactly(&sddl, &admission), "{sddl}");
1615 }
1616 }
1617
1618 #[test]
1619 fn a_mode_change_is_visible_as_a_descriptor_that_no_longer_matches() {
1620 // Boot to login must add the account, and login to boot must drop it:
1621 // `04-security-recovery.md` requires the selected identity to be
1622 // reconciled when service mode changes, in both directions.
1623 let boot = default_root_sddl(&RootAdmission::LocalSystem);
1624 let login = default_root_sddl(&account());
1625 assert!(!admits_exactly(&boot, &account()));
1626 assert!(!admits_exactly(&login, &RootAdmission::LocalSystem));
1627 }
1628
1629 #[test]
1630 fn a_root_that_admits_a_second_account_does_not_match() {
1631 let extra = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFWFXSD;;;S-1-5-21-1-2-3-1001)\
1632 (A;OICI;FRFWFXSD;;;S-1-5-21-1-2-3-1002)";
1633 assert!(!admits_exactly(extra, &account()));
1634 }
1635
1636 #[test]
1637 fn a_root_that_grants_the_account_full_control_is_reconciled_rather_than_accepted() {
1638 // The trustees are exactly right and the rights are not: `FA` carries
1639 // `WRITE_DAC` and `WRITE_OWNER`, the two the admitted account must not
1640 // have, because either one lets it undo the protection. Matching on
1641 // trustees alone would adopt this and leave the root re-openable by the
1642 // account it exists to constrain.
1643 let too_much = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)";
1644 assert!(!grants_broad_write(too_much), "no broad trustee is named");
1645 assert_eq!(
1646 write_trustees(too_much),
1647 write_trustees(&default_root_sddl(&account()))
1648 );
1649 assert!(!admits_exactly(too_much, &account()), "{too_much}");
1650 }
1651
1652 #[test]
1653 fn windows_own_spelling_of_the_admitted_rights_still_matches() {
1654 // `FRFWFXSD` is not a form the converter hands back; it renders the
1655 // same mask as `0x1301bf`. Comparing the text rather than the bits
1656 // would rewrite the descriptor on every single install.
1657 let rendered = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1301bf;;;S-1-5-21-1-2-3-1001)";
1658 assert!(admits_exactly(rendered, &account()), "{rendered}");
1659 }
1660
1661 #[test]
1662 fn an_unprotected_root_never_matches_however_narrow_it_looks() {
1663 let narrow = "D:AI(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)";
1664 assert!(!grants_broad_write(narrow), "nothing broad is granted");
1665 assert!(
1666 !admits_exactly(narrow, &RootAdmission::LocalSystem),
1667 "but it still inherits, so it is reconciled rather than accepted"
1668 );
1669 }
1670
1671 #[test]
1672 fn the_two_well_known_trustees_compare_equal_in_either_spelling() {
1673 let aliases = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
1674 let sids = "D:P(A;OICI;FA;;;S-1-5-18)(A;OICI;FA;;;S-1-5-32-544)";
1675 assert_eq!(write_trustees(aliases), write_trustees(sids));
1676 assert!(admits_exactly(sids, &RootAdmission::LocalSystem));
1677 }
1678
1679 #[test]
1680 fn an_account_alias_windows_substituted_is_reconciled_rather_than_trusted() {
1681 // Windows renders the built-in Administrator's S-1-5-21-…-500 back as
1682 // `LA` — the round trip that already cost the secret store a bug. This
1683 // module cannot resolve that, so it declines to claim a match and pays
1684 // for one redundant descriptor write instead.
1685 let substituted = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFWFXSD;;;LA)";
1686 assert!(!grants_broad_write(substituted));
1687 assert!(!admits_exactly(
1688 substituted,
1689 &RootAdmission::Account("S-1-5-21-1-2-3-500".to_owned())
1690 ));
1691 }
1692
1693 // -- reporting -----------------------------------------------------------
1694
1695 #[test]
1696 fn redaction_removes_the_machine_and_the_user_from_an_account_sid() {
1697 let redacted = redact(
1698 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFWFXSD;;;S-1-5-21-4004-77-9-1001)",
1699 );
1700 assert_eq!(
1701 redacted,
1702 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFWFXSD;;;S-1-5-21-<account>)"
1703 );
1704 assert!(!redacted.contains("4004"), "{redacted}");
1705 assert!(!redacted.contains("1001"), "{redacted}");
1706 }
1707
1708 #[test]
1709 fn redaction_keeps_the_well_known_trustees_that_identify_nobody() {
1710 // `SY` and `S-1-5-18` are the same two words on every host, and
1711 // `service status` already prints the first of them.
1712 let descriptor = "D:P(A;OICI;FA;;;S-1-5-18)(A;OICI;FA;;;S-1-5-32-544)";
1713 assert_eq!(redact(descriptor), descriptor);
1714 }
1715
1716 #[test]
1717 fn redaction_handles_several_accounts_and_a_trailing_one() {
1718 assert_eq!(
1719 redact("(A;;FA;;;S-1-5-21-1-2-3-1001)(A;;FA;;;S-1-5-21-9-8-7-1002)"),
1720 "(A;;FA;;;S-1-5-21-<account>)(A;;FA;;;S-1-5-21-<account>)"
1721 );
1722 assert_eq!(redact("S-1-5-21-1-2-3-1001"), "S-1-5-21-<account>");
1723 }
1724
1725 #[test]
1726 fn a_summary_names_trustees_without_naming_an_account() {
1727 let summary = RootAccessSummary::Created {
1728 path: PathBuf::from("C:\\rman"),
1729 admits: account().admits(),
1730 };
1731 let rendered = summary.to_string();
1732 assert!(rendered.contains("C:\\rman"), "{rendered}");
1733 assert!(rendered.contains("the invoking user"), "{rendered}");
1734 assert!(!rendered.contains("S-1-5-21"), "{rendered}");
1735 assert!(summary.created());
1736 }
1737
1738 #[test]
1739 fn a_boot_summary_does_not_claim_to_admit_an_invoking_user() {
1740 let rendered = RootAccessSummary::Reconciled {
1741 path: PathBuf::from("C:\\rman"),
1742 admits: RootAdmission::LocalSystem.admits(),
1743 }
1744 .to_string();
1745 assert!(!rendered.contains("the invoking user"), "{rendered}");
1746 assert!(rendered.contains("NT AUTHORITY\\SYSTEM"), "{rendered}");
1747 }
1748
1749 #[test]
1750 fn a_reversal_that_left_something_behind_says_so() {
1751 let retained = Reversal::Retained {
1752 path: PathBuf::from("C:\\rman"),
1753 detail: "it existed before this operation".to_owned(),
1754 };
1755 assert!(retained.to_string().contains("existed before"));
1756 assert_ne!(retained, Reversal::NothingToUndo);
1757 }
1758
1759 #[test]
1760 fn a_not_applicable_change_reverts_to_nothing() {
1761 let change = RootAccessChange::not_applicable();
1762 assert_eq!(change.summary(), &RootAccessSummary::NotApplicable);
1763 assert_eq!(change.revert(), Reversal::NothingToUndo);
1764 assert_eq!(change.summary().path(), None);
1765 }
1766
1767 #[test]
1768 fn the_broad_access_refusal_names_the_path_the_volume_and_the_remedy() {
1769 let error = RootAccessError::BroadExistingAccess {
1770 path: PathBuf::from("C:\\rman"),
1771 dacl: redact(INHERITED_FROM_VOLUME),
1772 volume: PathBuf::from("C:\\"),
1773 remediation: remediation(),
1774 };
1775 let message = error.to_string();
1776 assert!(message.contains("C:\\rman"), "{message}");
1777 assert!(message.contains("host set-runtime-root"), "{message}");
1778 assert!(
1779 message.contains("refused rather than tightened"),
1780 "an operator has to be told why it was not simply fixed: {message}"
1781 );
1782 assert_eq!(error.path(), Some(Path::new("C:\\rman")));
1783 }
1784
1785 // -- platform behaviour --------------------------------------------------
1786
1787 #[cfg(not(windows))]
1788 #[test]
1789 fn nothing_is_created_or_re_permissioned_off_windows() {
1790 let root = tempfile::tempdir().expect("a temporary directory");
1791 let paths = AppPaths::rooted_at(root.path());
1792 let change =
1793 ensure_default_root(&paths, &RootAdmission::LocalSystem).expect("a no-op succeeds");
1794 assert_eq!(change.summary(), &RootAccessSummary::NotApplicable);
1795 assert_eq!(report(root.path()), RootAccessReport::NotApplicable);
1796 }
1797
1798 #[cfg(windows)]
1799 #[test]
1800 fn a_directory_this_process_created_is_reported_as_narrow() {
1801 let root = tempfile::tempdir().expect("a temporary directory");
1802 let directory = root.path().join("narrow");
1803 let admission = RootAdmission::of_this_account().expect("this process has an account");
1804 let checked = LocalAbsolutePath::new(directory.to_str().expect("a unicode temp path"))
1805 .expect("a local absolute path");
1806 // A separate application-data tree, so `b1`'s overlap check has nothing
1807 // to object to.
1808 let elsewhere = tempfile::tempdir().expect("a second temporary directory");
1809 let app_paths = AppPaths::rooted_at(elsewhere.path());
1810
1811 let change = reconcile(&app_paths, &checked, &admission).expect("creation succeeds");
1812 assert!(change.summary().created(), "{:?}", change.summary());
1813
1814 match report(&directory) {
1815 RootAccessReport::Present {
1816 protected,
1817 broad_write,
1818 dacl,
1819 } => {
1820 assert!(protected, "{dacl}");
1821 assert!(!broad_write, "{dacl}");
1822 assert!(!dacl.contains("S-1-5-21-1"), "unredacted account: {dacl}");
1823 }
1824 other => panic!("expected a readable descriptor, got {other:?}"),
1825 }
1826 let after_creation = read_back(&directory);
1827
1828 // A second pass creates nothing, and reverting it removes nothing,
1829 // because there is nothing of this call's to remove.
1830 //
1831 // Whether it also *rewrites* the descriptor is a property of the host
1832 // rather than of this code, so it is deliberately not asserted here.
1833 // `admits_exactly` compares SDDL text, and Windows renders an account
1834 // whose RID has an alias back as that alias -- the built-in
1835 // administrator's `S-1-5-21-...-500` reads back as `LA`. This module
1836 // declines to claim a match it cannot resolve and pays for one
1837 // redundant write instead, which is what a CI host running as that
1838 // account did while a developer host running as an ordinary one did
1839 // not. Both outcomes are correct; only removing the directory would
1840 // not be.
1841 // `an_account_alias_windows_substituted_is_reconciled_rather_than_trusted`
1842 // pins that decision purely. What holds on every host, and is asserted
1843 // instead, is that neither outcome changes anything: the directory
1844 // survives and still carries the descriptor creation wrote.
1845 let again = reconcile(&app_paths, &checked, &admission).expect("a second pass succeeds");
1846 assert!(!again.summary().created(), "{:?}", again.summary());
1847 // Read back BEFORE reverting. A rewriting second pass keeps the
1848 // descriptor it read as `previous_dacl`, so reverting puts
1849 // `after_creation` back whatever it wrote — asserting only afterwards
1850 // would pass however wrong that write had been.
1851 //
1852 // Compared by what it grants rather than by its exact text, for the
1853 // reason `reverting_a_reconciliation_puts_the_previous_descriptor_back`
1854 // gives: `SetNamedSecurityInfoW` records that it ran the
1855 // auto-inheritance algorithm by adding `AI` to the control flags, so a
1856 // host that took the rewriting branch reads back as `D:PAI` where
1857 // `CreateDirectoryW` wrote `D:P`. Demanding the same characters would
1858 // fail on exactly the host the comment above describes.
1859 assert_same_grants(
1860 &read_back(&directory),
1861 &after_creation,
1862 "a second pass must leave the descriptor granting what creation wrote",
1863 );
1864 let reversal = again.revert();
1865 assert!(
1866 matches!(
1867 reversal,
1868 Reversal::NothingToUndo | Reversal::Restored { .. }
1869 ),
1870 "a second pass has nothing of its own to undo: {reversal:?}"
1871 );
1872 assert!(directory.is_dir(), "the second pass must not remove it");
1873 assert_same_grants(
1874 &read_back(&directory),
1875 &after_creation,
1876 "and reverting it must leave those grants alone",
1877 );
1878
1879 // The child a runner attempt would be, created and cleaned as this
1880 // account.
1881 let child = directory.join("s1");
1882 std::fs::create_dir(&child).expect("a child below the root");
1883 std::fs::write(child.join("marker"), b"job").expect("content inside the child");
1884 std::fs::remove_dir_all(&child).expect("the child is removable again");
1885 }
1886
1887 #[cfg(windows)]
1888 #[test]
1889 fn an_existing_broad_directory_is_refused_rather_than_tightened() {
1890 let root = tempfile::tempdir().expect("a temporary directory");
1891 let directory = root.path().join("open");
1892 // Explicitly broad, without depending on what the temp directory
1893 // happens to inherit on this machine.
1894 sys::create_with_dacl(&directory, "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)")
1895 .expect("a deliberately open directory");
1896 let before = read_back(&directory);
1897 assert!(grants_broad_write(&before), "{before}");
1898
1899 let elsewhere = tempfile::tempdir().expect("a second temporary directory");
1900 let app_paths = AppPaths::rooted_at(elsewhere.path());
1901 let checked = LocalAbsolutePath::new(directory.to_str().expect("a unicode temp path"))
1902 .expect("a local absolute path");
1903
1904 let error = reconcile(&app_paths, &checked, &RootAdmission::LocalSystem)
1905 .expect_err("an open directory is refused");
1906 assert!(
1907 matches!(error, RootAccessError::BroadExistingAccess { .. }),
1908 "{error}"
1909 );
1910 // And it was refused rather than repaired: the directory is untouched.
1911 assert_eq!(read_back(&directory), before);
1912 }
1913
1914 #[cfg(windows)]
1915 #[test]
1916 fn reverting_a_reconciliation_puts_the_previous_descriptor_back() {
1917 let root = tempfile::tempdir().expect("a temporary directory");
1918 let directory = root.path().join("narrow");
1919 let admission = RootAdmission::of_this_account().expect("this process has an account");
1920 let sid = admission
1921 .sid()
1922 .expect("an ordinary account has a SID")
1923 .to_owned();
1924 // Narrow enough to pass the preflight, but not what this module writes.
1925 // Named for this account so that an unelevated test run can still read
1926 // the descriptor back; the previous DACL is restorable either way,
1927 // because the owner of a directory implicitly holds WRITE_DAC.
1928 sys::create_with_dacl(&directory, &format!("D:P(A;OICI;FA;;;{sid})"))
1929 .expect("a narrow directory");
1930 let before = read_back(&directory);
1931 assert!(!grants_broad_write(&before), "{before}");
1932
1933 let elsewhere = tempfile::tempdir().expect("a second temporary directory");
1934 let app_paths = AppPaths::rooted_at(elsewhere.path());
1935 let checked = LocalAbsolutePath::new(directory.to_str().expect("a unicode temp path"))
1936 .expect("a local absolute path");
1937
1938 let change =
1939 reconcile(&app_paths, &checked, &admission).expect("a narrow directory is reconciled");
1940 assert!(matches!(
1941 change.summary(),
1942 RootAccessSummary::Reconciled { .. }
1943 ));
1944 assert_ne!(read_back(&directory), before, "it was actually rewritten");
1945
1946 assert_eq!(
1947 change.revert(),
1948 Reversal::Restored {
1949 path: directory.clone()
1950 }
1951 );
1952 // Compared by what it grants rather than by its exact text.
1953 // `SetNamedSecurityInfoW` records that it ran the auto-inheritance
1954 // algorithm by adding `AI` to the control flags, so a descriptor
1955 // written back through it reads as `D:PAI` where the original —
1956 // applied by `CreateDirectoryW` — read as `D:P`. The protection and
1957 // every ACE are identical, which is what "put back" has to mean.
1958 let after = read_back(&directory);
1959 assert_eq!(aces_of(&after), aces_of(&before), "{after} vs {before}");
1960 assert!(is_protected(&after), "{after}");
1961 assert!(
1962 directory.is_dir(),
1963 "a pre-existing directory is never removed"
1964 );
1965 }
1966
1967 /// A descriptor from its first ACE onwards, for a comparison that ignores
1968 /// the control flags Windows maintains for itself.
1969 #[cfg(windows)]
1970 fn aces_of(descriptor: &str) -> &str {
1971 descriptor
1972 .find('(')
1973 .map_or(descriptor, |start| &descriptor[start..])
1974 }
1975
1976 /// Two descriptors grant the same thing, whoever wrote them.
1977 ///
1978 /// The same comparison [`aces_of`] exists for: every ACE identical and the
1979 /// protection still in force, without demanding the `AI` control flag that
1980 /// `SetNamedSecurityInfoW` adds and `CreateDirectoryW` does not.
1981 #[cfg(windows)]
1982 fn assert_same_grants(actual: &str, expected: &str, context: &str) {
1983 assert_eq!(
1984 aces_of(actual),
1985 aces_of(expected),
1986 "{context}: {actual} vs {expected}"
1987 );
1988 assert!(is_protected(actual), "{context}: {actual}");
1989 }
1990
1991 #[cfg(windows)]
1992 #[test]
1993 fn reverting_a_creation_removes_the_directory_it_created() {
1994 let root = tempfile::tempdir().expect("a temporary directory");
1995 let directory = root.path().join("created");
1996 let elsewhere = tempfile::tempdir().expect("a second temporary directory");
1997 let app_paths = AppPaths::rooted_at(elsewhere.path());
1998 let checked = LocalAbsolutePath::new(directory.to_str().expect("a unicode temp path"))
1999 .expect("a local absolute path");
2000 // The login and foreground admission, which is the one an unelevated
2001 // caller can roll back: `ADMITTED_RIGHTS` carries `SD`, and creating a
2002 // directory does not by itself confer the right to delete it again.
2003 let admission = RootAdmission::of_this_account().expect("this process has an account");
2004
2005 let change = reconcile(&app_paths, &checked, &admission).expect("creation succeeds");
2006 assert!(directory.is_dir());
2007 assert_eq!(
2008 change.revert(),
2009 Reversal::Removed {
2010 path: directory.clone()
2011 }
2012 );
2013 assert!(!directory.exists(), "the rollback is a real rollback");
2014 }
2015
2016 #[cfg(windows)]
2017 #[test]
2018 fn a_rollback_that_cannot_finish_reports_what_it_left_behind() {
2019 // The boot admission names only `SY` and `BA`, so an unelevated caller
2020 // creating one cannot delete it again — the owner of a directory holds
2021 // WRITE_DAC implicitly but not DELETE. That is a real outcome rather
2022 // than a contrived one, and the requirement is that it is *reported*
2023 // rather than swallowed. An elevated run has `BA` and takes the
2024 // `Removed` branch above, so both are accepted here.
2025 let root = tempfile::tempdir().expect("a temporary directory");
2026 let directory = root.path().join("boot-owned");
2027 let elsewhere = tempfile::tempdir().expect("a second temporary directory");
2028 let app_paths = AppPaths::rooted_at(elsewhere.path());
2029 let checked = LocalAbsolutePath::new(directory.to_str().expect("a unicode temp path"))
2030 .expect("a local absolute path");
2031
2032 let change = reconcile(&app_paths, &checked, &RootAdmission::LocalSystem)
2033 .expect("creation succeeds");
2034 match change.revert() {
2035 Reversal::Removed { path } => assert_eq!(path, directory),
2036 Reversal::Retained { path, detail } => {
2037 assert_eq!(path, directory);
2038 assert!(
2039 detail.contains("removing it by hand is safe"),
2040 "a non-reversible state must say what to do about it: {detail}"
2041 );
2042 }
2043 other => panic!("expected a removal or an explicit retention, got {other:?}"),
2044 }
2045 }
2046
2047 #[cfg(windows)]
2048 #[test]
2049 fn a_custom_root_is_described_without_being_changed() {
2050 // `report` is the whole of what a configured operator root gets.
2051 let root = tempfile::tempdir().expect("a temporary directory");
2052 let directory = root.path().join("operators-own");
2053 sys::create_with_dacl(&directory, "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)")
2054 .expect("an operator directory this product did not create");
2055 let before = read_back(&directory);
2056
2057 match report(&directory) {
2058 RootAccessReport::Present { broad_write, .. } => assert!(broad_write),
2059 other => panic!("expected a readable descriptor, got {other:?}"),
2060 }
2061 assert_eq!(read_back(&directory), before, "reporting must not rewrite");
2062 }
2063
2064 #[cfg(windows)]
2065 #[test]
2066 fn an_absent_directory_reports_absent() {
2067 let root = tempfile::tempdir().expect("a temporary directory");
2068 assert_eq!(
2069 report(&root.path().join("nothing")),
2070 RootAccessReport::Absent
2071 );
2072 }
2073
2074 #[cfg(windows)]
2075 fn read_back(path: &Path) -> String {
2076 sys::read_dacl(path).expect("this process can read the descriptor it just wrote")
2077 }
2078}