1use std::path::{Path, PathBuf};
19
20use serde_json::{Map, Value};
21
22pub const MACOS_PROFILE_PATH: &str = "/Library/Managed Preferences/com.leanctx.managed.plist";
24pub const WINDOWS_POLICY_KEY: &str = r"SOFTWARE\Policies\LeanCTX\Managed";
26pub const LINUX_FILE_PATH: &str = "/etc/lean-ctx/managed.toml";
28
29const MAX_BYTES: u64 = 64 * 1024;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Carrier {
35 MacosProfile(PathBuf),
37 WindowsPolicy(RegistryRoot, String),
40 File(PathBuf),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum RegistryRoot {
47 Machine,
48 User,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Owner {
54 Root,
57 Any,
59}
60
61impl Carrier {
62 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
72pub 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
83pub 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
93pub 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
109pub 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 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 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
152fn 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#[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#[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#[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 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 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 unsafe { RegCloseKey(handle) };
354 result.map(|()| Some(members))
355}
356
357#[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#[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; }
429 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 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 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 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 unsafe { RegCloseKey(handle) };
528
529 let members = read(
530 &Carrier::WindowsPolicy(RegistryRoot::User, key.clone()),
531 Owner::Any,
532 );
533 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}