Skip to main content

lean_ctx_fs/
managed.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Reads the machine's managed configuration from its MDM carrier.
3//!
4//! An organisation preconfigures a machine through one carrier per platform
5//! (`leanctx.managed-config/v1`, `docs/contracts/managed-config-v1.md`):
6//!
7//! - macOS: a configuration profile, which `mdmclient` writes to
8//!   [`MACOS_PROFILE_PATH`];
9//! - Windows: the policy key [`WINDOWS_POLICY_KEY`] under `HKEY_LOCAL_MACHINE`;
10//! - Linux and other Unix: the file [`LINUX_FILE_PATH`].
11//!
12//! This module only turns the carrier into JSON members; the schema
13//! (`lean_ctx_protocol::hive::managed`) decides what they mean. Every reader
14//! takes its location as an argument so tests read a sandboxed copy; the
15//! machine location is fixed ([`system_carrier`]) and no environment variable
16//! moves it, so a user cannot point the engine at a file of their own.
17
18use std::path::{Path, PathBuf};
19
20use serde_json::{Map, Value};
21
22/// Where `mdmclient` installs the profile payload for the `com.leanctx.managed` domain.
23pub const MACOS_PROFILE_PATH: &str = "/Library/Managed Preferences/com.leanctx.managed.plist";
24/// The Windows policy key, below `HKEY_LOCAL_MACHINE`.
25pub const WINDOWS_POLICY_KEY: &str = r"SOFTWARE\Policies\LeanCTX\Managed";
26/// The managed file on Linux and other Unix systems.
27pub const LINUX_FILE_PATH: &str = "/etc/lean-ctx/managed.toml";
28
29/// Largest carrier the readers accept; a real one is well under 4 KiB.
30const MAX_BYTES: u64 = 64 * 1024;
31
32/// One carrier and its location.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Carrier {
35    /// A property list (a macOS configuration profile payload).
36    MacosProfile(PathBuf),
37    /// A registry key below `HKEY_LOCAL_MACHINE` (`Machine`) or, for tests,
38    /// `HKEY_CURRENT_USER` (`User`).
39    WindowsPolicy(RegistryRoot, String),
40    /// A TOML file.
41    File(PathBuf),
42}
43
44/// The registry root a [`Carrier::WindowsPolicy`] is read below.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum RegistryRoot {
47    Machine,
48    User,
49}
50
51/// Who must own a file carrier before it is read.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Owner {
54    /// Owned by root and writable by nobody else (the machine carrier: a
55    /// file anyone else can write could redirect the Hive trust root).
56    Root,
57    /// Any owner (sandboxed tests).
58    Any,
59}
60
61impl Carrier {
62    /// Where the carrier lives, as `hive status` names it.
63    pub fn location(&self) -> String {
64        match self {
65            Self::MacosProfile(path) | Self::File(path) => path.display().to_string(),
66            Self::WindowsPolicy(RegistryRoot::Machine, key) => format!(r"HKLM\{key}"),
67            Self::WindowsPolicy(RegistryRoot::User, key) => format!(r"HKCU\{key}"),
68        }
69    }
70}
71
72/// This platform's machine carrier.
73pub fn system_carrier() -> Carrier {
74    if cfg!(target_os = "macos") {
75        Carrier::MacosProfile(PathBuf::from(MACOS_PROFILE_PATH))
76    } else if cfg!(windows) {
77        Carrier::WindowsPolicy(RegistryRoot::Machine, WINDOWS_POLICY_KEY.to_string())
78    } else {
79        Carrier::File(PathBuf::from(LINUX_FILE_PATH))
80    }
81}
82
83/// The members `carrier` holds: `Ok(None)` when it is absent, `Err` when it
84/// exists but cannot be read or parsed (the caller fails closed).
85pub fn read(carrier: &Carrier, owner: Owner) -> Result<Option<Map<String, Value>>, String> {
86    match carrier {
87        Carrier::File(path) => read_toml_file(path, owner),
88        Carrier::MacosProfile(path) => read_plist(path, owner),
89        Carrier::WindowsPolicy(root, key) => read_registry(*root, key),
90    }
91}
92
93/// A TOML file as a JSON object.
94pub fn read_toml_file(path: &Path, owner: Owner) -> Result<Option<Map<String, Value>>, String> {
95    let Some(bytes) = read_owned(path, owner)? else {
96        return Ok(None);
97    };
98    let text = String::from_utf8(bytes).map_err(|_| format!("{} is not UTF-8", path.display()))?;
99    let table: toml::Table =
100        toml::from_str(&text).map_err(|error| format!("{}: {error}", path.display()))?;
101    let value =
102        serde_json::to_value(table).map_err(|error| format!("{}: {error}", path.display()))?;
103    match value {
104        Value::Object(map) => Ok(Some(map)),
105        _ => Err(format!("{}: not a table", path.display())),
106    }
107}
108
109/// A property list (XML or binary), converted by the system's `plutil`.
110/// Keys starting with `Payload` (profile bookkeeping) are dropped.
111///
112/// `plutil` converts the bytes `read_owned` checked, fed on stdin; it never
113/// opens the path itself, so the file cannot be swapped between the check
114/// and the conversion.
115pub fn read_plist(path: &Path, owner: Owner) -> Result<Option<Map<String, Value>>, String> {
116    use std::io::Write;
117    let Some(bytes) = read_owned(path, owner)? else {
118        return Ok(None);
119    };
120    let mut child = std::process::Command::new("/usr/bin/plutil")
121        .args(["-convert", "json", "-o", "-", "-"])
122        .stdin(std::process::Stdio::piped())
123        .stdout(std::process::Stdio::piped())
124        .stderr(std::process::Stdio::piped())
125        .spawn()
126        .map_err(|error| format!("{}: cannot run plutil: {error}", path.display()))?;
127    let stdin = child.stdin.take();
128    // Written from a second thread while this one collects the output, so a
129    // full pipe on either side cannot stall the other.
130    let output = std::thread::scope(|scope| {
131        let writer = scope.spawn(move || stdin.map(|mut stdin| stdin.write_all(&bytes)));
132        let output = child.wait_with_output();
133        // A failed write shows as a failed conversion below.
134        let _ = writer.join();
135        output
136    })
137    .map_err(|error| format!("{}: cannot run plutil: {error}", path.display()))?;
138    if !output.status.success() {
139        let reason = String::from_utf8_lossy(&output.stderr);
140        return Err(format!("{}: {}", path.display(), reason.trim()));
141    }
142    match serde_json::from_slice::<Value>(&output.stdout) {
143        Ok(Value::Object(mut map)) => {
144            map.retain(|key, _| !key.starts_with("Payload"));
145            Ok(Some(map))
146        }
147        Ok(_) => Err(format!("{}: not a dictionary", path.display())),
148        Err(error) => Err(format!("{}: {error}", path.display())),
149    }
150}
151
152/// The file's bytes, `None` when it (or its directory) does not exist;
153/// refused when it is not a regular file, too large, or (with
154/// [`Owner::Root`]) it or its directory is not root-owned.
155///
156/// Every check runs on the opened handle and the bytes come from that same
157/// handle, so a file swapped after the check is never read. The directory
158/// check keeps anyone but root from replacing the file in the first place.
159fn read_owned(path: &Path, owner: Owner) -> Result<Option<Vec<u8>>, String> {
160    use std::io::Read;
161    if owner == Owner::Root {
162        let Some(parent) = path
163            .parent()
164            .filter(|parent| !parent.as_os_str().is_empty())
165        else {
166            return Err(format!("{} has no parent directory", path.display()));
167        };
168        let meta = match std::fs::symlink_metadata(parent) {
169            Ok(meta) => meta,
170            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
171            Err(error) => return Err(format!("{}: {error}", parent.display())),
172        };
173        if !meta.is_dir() {
174            return Err(format!("{} is not a directory", parent.display()));
175        }
176        check_root_owned(parent, &meta)?;
177    }
178    let file = match open_no_follow(path) {
179        Ok(file) => file,
180        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
181        Err(error) if is_symlink_refusal(&error) => {
182            return Err(format!("{} is not a regular file", path.display()));
183        }
184        Err(error) => return Err(format!("{}: {error}", path.display())),
185    };
186    let meta = file
187        .metadata()
188        .map_err(|error| format!("{}: {error}", path.display()))?;
189    if !meta.is_file() {
190        return Err(format!("{} is not a regular file", path.display()));
191    }
192    let too_large = || format!("{} is larger than {MAX_BYTES} bytes", path.display());
193    if meta.len() > MAX_BYTES {
194        return Err(too_large());
195    }
196    if owner == Owner::Root {
197        check_root_owned(path, &meta)?;
198    }
199    let mut bytes = Vec::with_capacity(meta.len() as usize);
200    file.take(MAX_BYTES + 1)
201        .read_to_end(&mut bytes)
202        .map_err(|error| format!("{}: {error}", path.display()))?;
203    if bytes.len() as u64 > MAX_BYTES {
204        return Err(too_large());
205    }
206    Ok(Some(bytes))
207}
208
209/// Open for reading without following a final symlink and without blocking
210/// on a FIFO (the type check on the handle refuses both).
211#[cfg(unix)]
212fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
213    use std::os::unix::fs::OpenOptionsExt;
214    std::fs::OpenOptions::new()
215        .read(true)
216        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
217        .open(path)
218}
219
220#[cfg(not(unix))]
221fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
222    std::fs::File::open(path)
223}
224
225/// `O_NOFOLLOW` met a symlink.
226#[cfg(unix)]
227fn is_symlink_refusal(error: &std::io::Error) -> bool {
228    error.raw_os_error() == Some(libc::ELOOP)
229}
230
231#[cfg(not(unix))]
232fn is_symlink_refusal(_error: &std::io::Error) -> bool {
233    false
234}
235
236#[cfg(unix)]
237fn check_root_owned(path: &Path, meta: &std::fs::Metadata) -> Result<(), String> {
238    use std::os::unix::fs::MetadataExt;
239    if meta.uid() != 0 || meta.mode() & 0o022 != 0 {
240        return Err(format!(
241            "{} must be owned by root and writable only by root",
242            path.display()
243        ));
244    }
245    Ok(())
246}
247
248#[cfg(not(unix))]
249fn check_root_owned(_path: &Path, _meta: &std::fs::Metadata) -> Result<(), String> {
250    Ok(())
251}
252
253/// The values of a registry key: `REG_SZ`/`REG_EXPAND_SZ` as strings,
254/// `REG_DWORD`/`REG_QWORD` as numbers, `REG_MULTI_SZ` as string lists.
255#[cfg(windows)]
256pub fn read_registry(root: RegistryRoot, key: &str) -> Result<Option<Map<String, Value>>, String> {
257    use windows_sys::Win32::Foundation::{
258        ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS,
259    };
260    use windows_sys::Win32::System::Registry::{
261        HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, REG_DWORD, REG_EXPAND_SZ,
262        REG_MULTI_SZ, REG_QWORD, REG_SZ, RegCloseKey, RegEnumValueW, RegOpenKeyExW,
263    };
264
265    let location = format!(
266        "{}\\{key}",
267        if root == RegistryRoot::Machine {
268            "HKLM"
269        } else {
270            "HKCU"
271        }
272    );
273    let base = if root == RegistryRoot::Machine {
274        HKEY_LOCAL_MACHINE
275    } else {
276        HKEY_CURRENT_USER
277    };
278    let wide: Vec<u16> = key.encode_utf16().chain(std::iter::once(0)).collect();
279    let mut handle: HKEY = std::ptr::null_mut();
280    // SAFETY: `wide` is NUL-terminated and outlives the call; `handle` is a
281    // valid out pointer.
282    let status = unsafe { RegOpenKeyExW(base, wide.as_ptr(), 0, KEY_READ, &raw mut handle) };
283    if status == ERROR_FILE_NOT_FOUND {
284        return Ok(None);
285    }
286    if status != ERROR_SUCCESS {
287        return Err(format!("{location}: cannot open (error {status})"));
288    }
289    let mut members = Map::new();
290    let mut total: u64 = 0;
291    let mut result = Ok(());
292    for index in 0u32.. {
293        let mut name = vec![0u16; 256];
294        let mut name_len = name.len() as u32;
295        let mut kind = 0u32;
296        let mut data = vec![0u8; 16 * 1024];
297        let mut data_len = data.len() as u32;
298        // SAFETY: every buffer pointer is paired with its length; `handle`
299        // was opened above and is closed after the loop.
300        let status = unsafe {
301            RegEnumValueW(
302                handle,
303                index,
304                name.as_mut_ptr(),
305                &raw mut name_len,
306                std::ptr::null(),
307                &raw mut kind,
308                data.as_mut_ptr(),
309                &raw mut data_len,
310            )
311        };
312        if status == ERROR_NO_MORE_ITEMS {
313            break;
314        }
315        if status != ERROR_SUCCESS {
316            result = Err(format!(
317                "{location}: cannot read value {index} (error {status})"
318            ));
319            break;
320        }
321        total += u64::from(data_len);
322        if total > MAX_BYTES {
323            result = Err(format!("{location} holds more than {MAX_BYTES} bytes"));
324            break;
325        }
326        let name = String::from_utf16_lossy(&name[..name_len as usize]);
327        let data = &data[..data_len as usize];
328        let value = match kind {
329            REG_SZ | REG_EXPAND_SZ => {
330                Value::String(wide_strings(data).into_iter().next().unwrap_or_default())
331            }
332            REG_MULTI_SZ => {
333                Value::Array(wide_strings(data).into_iter().map(Value::String).collect())
334            }
335            REG_DWORD if data.len() == 4 => {
336                Value::from(u32::from_le_bytes([data[0], data[1], data[2], data[3]]))
337            }
338            REG_QWORD if data.len() == 8 => {
339                let mut bytes = [0u8; 8];
340                bytes.copy_from_slice(data);
341                Value::from(u64::from_le_bytes(bytes))
342            }
343            other => {
344                result = Err(format!(
345                    "{location}\\{name}: unsupported value type {other}"
346                ));
347                break;
348            }
349        };
350        members.insert(name, value);
351    }
352    // SAFETY: `handle` is the key opened above, closed exactly once.
353    unsafe { RegCloseKey(handle) };
354    result.map(|()| Some(members))
355}
356
357/// NUL-separated UTF-16 strings in `data` (empty entries dropped).
358#[cfg(windows)]
359#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
360fn wide_strings(data: &[u8]) -> Vec<String> {
361    let units: Vec<u16> = data
362        .chunks_exact(2)
363        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
364        .collect();
365    units
366        .split(|unit| *unit == 0)
367        .filter(|part| !part.is_empty())
368        .map(String::from_utf16_lossy)
369        .collect()
370}
371
372/// No registry outside Windows: the key is always absent.
373#[cfg(not(windows))]
374pub fn read_registry(
375    _root: RegistryRoot,
376    _key: &str,
377) -> Result<Option<Map<String, Value>>, String> {
378    Ok(None)
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn a_missing_carrier_is_absent() {
387        let dir = tempfile::tempdir().expect("tempdir");
388        let path = dir.path().join("managed.toml");
389        assert_eq!(read(&Carrier::File(path.clone()), Owner::Any), Ok(None));
390        assert_eq!(read(&Carrier::MacosProfile(path), Owner::Any), Ok(None));
391    }
392
393    #[test]
394    fn the_file_carrier_reads_nested_and_dotted_members() {
395        let dir = tempfile::tempdir().expect("tempdir");
396        let path = dir.path().join("managed.toml");
397        std::fs::write(
398            &path,
399            "schema = \"leanctx.managed-config/v1\"\n\"updates.mode\" = \"mdm\"\n\
400             [hive]\nurl = \"https://hive.example.com\"\nrequire = true\n",
401        )
402        .expect("write");
403        let members = read(&Carrier::File(path), Owner::Any)
404            .expect("readable")
405            .expect("present");
406        assert_eq!(members["hive"]["url"], "https://hive.example.com");
407        assert_eq!(members["hive"]["require"], true);
408        assert_eq!(members["updates.mode"], "mdm");
409    }
410
411    #[test]
412    fn an_unparsable_file_is_an_error_not_absent() {
413        let dir = tempfile::tempdir().expect("tempdir");
414        let path = dir.path().join("managed.toml");
415        std::fs::write(&path, "[hive\nurl = ").expect("write");
416        assert!(read(&Carrier::File(path), Owner::Any).is_err());
417    }
418
419    #[cfg(unix)]
420    #[test]
421    fn the_machine_file_must_be_root_owned() {
422        use std::os::unix::fs::MetadataExt;
423        let dir = tempfile::tempdir().expect("tempdir");
424        let path = dir.path().join("managed.toml");
425        std::fs::write(&path, "[hive]\nrequire = true\n").expect("write");
426        if std::fs::metadata(&path).expect("meta").uid() == 0 {
427            return; // running as root: the file is legitimately root-owned
428        }
429        // A user-owned directory is refused before the file is opened: its
430        // owner could swap the file between any check and the read.
431        let error = read(&Carrier::File(path.clone()), Owner::Root).expect_err("user-owned");
432        assert!(error.contains("owned by root"), "{error}");
433        assert!(
434            error.starts_with(&dir.path().display().to_string()),
435            "{error}"
436        );
437        assert!(!error.starts_with(&path.display().to_string()), "{error}");
438        // A missing directory is a missing carrier.
439        let absent = dir.path().join("missing").join("managed.toml");
440        assert_eq!(read(&Carrier::File(absent), Owner::Root), Ok(None));
441    }
442
443    #[cfg(unix)]
444    #[test]
445    fn a_symlinked_carrier_is_refused() {
446        let dir = tempfile::tempdir().expect("tempdir");
447        let target = dir.path().join("elsewhere.toml");
448        std::fs::write(&target, "[hive]\nrequire = true\n").expect("write");
449        let path = dir.path().join("managed.toml");
450        std::os::unix::fs::symlink(&target, &path).expect("symlink");
451        let error = read(&Carrier::File(path), Owner::Any).expect_err("symlink");
452        assert!(error.contains("not a regular file"), "{error}");
453    }
454
455    #[cfg(target_os = "macos")]
456    #[test]
457    fn the_macos_profile_carrier_reads_a_plist() {
458        let dir = tempfile::tempdir().expect("tempdir");
459        let path = dir.path().join("com.leanctx.managed.plist");
460        std::fs::write(
461            &path,
462            r#"<?xml version="1.0" encoding="UTF-8"?>
463<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
464<plist version="1.0"><dict>
465<key>PayloadUUID</key><string>0F2D</string>
466<key>hive.org</key><string>acme</string>
467<key>hive</key><dict><key>require</key><true/></dict>
468<key>workspace.vaults</key><array><string>acme/main</string></array>
469</dict></plist>"#,
470        )
471        .expect("write");
472        let members = read(&Carrier::MacosProfile(path), Owner::Any)
473            .expect("readable")
474            .expect("present");
475        assert_eq!(members["hive.org"], "acme");
476        assert_eq!(members["hive"]["require"], true);
477        assert_eq!(members["workspace.vaults"][0], "acme/main");
478        assert!(!members.contains_key("PayloadUUID"));
479    }
480
481    #[cfg(windows)]
482    #[test]
483    fn the_windows_policy_carrier_reads_a_registry_key() {
484        use windows_sys::Win32::System::Registry::{
485            HKEY, HKEY_CURRENT_USER, REG_DWORD, REG_MULTI_SZ, REG_SZ, RegCloseKey, RegCreateKeyW,
486            RegDeleteTreeW, RegSetValueExW,
487        };
488        fn wide(text: &str) -> Vec<u16> {
489            text.encode_utf16().chain(std::iter::once(0)).collect()
490        }
491        let key = format!(r"Software\LeanCTX-test\managed-{}", std::process::id());
492        let mut handle: HKEY = std::ptr::null_mut();
493        let set = |handle: HKEY, name: &str, kind: u32, bytes: &[u8]| {
494            let name = wide(name);
495            // SAFETY: name is NUL-terminated, bytes is paired with its length.
496            unsafe {
497                RegSetValueExW(
498                    handle,
499                    name.as_ptr(),
500                    0,
501                    kind,
502                    bytes.as_ptr(),
503                    bytes.len() as u32,
504                )
505            };
506        };
507        let bytes = |text: &str| -> Vec<u8> {
508            wide(text)
509                .iter()
510                .flat_map(|unit| unit.to_le_bytes())
511                .collect()
512        };
513        // SAFETY: the key name is NUL-terminated; handle is a valid out pointer.
514        unsafe { RegCreateKeyW(HKEY_CURRENT_USER, wide(&key).as_ptr(), &raw mut handle) };
515        set(
516            handle,
517            "hive.url",
518            REG_SZ,
519            &bytes("https://hive.example.com"),
520        );
521        set(handle, "hive.require", REG_DWORD, &1u32.to_le_bytes());
522        let mut multi = bytes("acme/main");
523        multi.extend(bytes("acme/docs"));
524        multi.extend([0, 0]);
525        set(handle, "workspace.vaults", REG_MULTI_SZ, &multi);
526        // SAFETY: handle was created above.
527        unsafe { RegCloseKey(handle) };
528
529        let members = read(
530            &Carrier::WindowsPolicy(RegistryRoot::User, key.clone()),
531            Owner::Any,
532        );
533        // SAFETY: the key name is NUL-terminated.
534        unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, wide(&key).as_ptr()) };
535        let members = members.expect("readable").expect("present");
536        assert_eq!(members["hive.url"], "https://hive.example.com");
537        assert_eq!(members["hive.require"], 1);
538        assert_eq!(members["workspace.vaults"][1], "acme/docs");
539    }
540}