1pub mod compliance;
2pub mod composition;
3pub mod config;
4pub mod daemon;
5pub mod errors;
6pub mod generate;
7pub mod modules;
8pub mod oci;
9pub mod output;
10pub mod platform;
11pub mod providers;
12pub mod reconciler;
13pub mod server_client;
14pub mod sources;
15pub mod state;
16#[cfg(any(test, feature = "test-helpers"))]
17pub mod test_helpers;
18pub mod upgrade;
19
20pub const API_VERSION: &str = "cfgd.io/v1alpha1";
26pub const CSI_DRIVER_NAME: &str = "csi.cfgd.io";
27pub const MODULES_ANNOTATION: &str = "cfgd.io/modules";
28
29pub fn utc_now_iso8601() -> String {
31 let secs = std::time::SystemTime::now()
32 .duration_since(std::time::UNIX_EPOCH)
33 .unwrap_or_default()
34 .as_secs();
35 unix_secs_to_iso8601(secs)
36}
37
38pub fn unix_secs_now() -> u64 {
40 std::time::SystemTime::now()
41 .duration_since(std::time::UNIX_EPOCH)
42 .unwrap_or_default()
43 .as_secs()
44}
45
46pub fn unix_secs_to_iso8601(secs: u64) -> String {
48 let days = secs / 86400;
49 let time_of_day = secs % 86400;
50 let hours = time_of_day / 3600;
51 let minutes = (time_of_day % 3600) / 60;
52 let seconds = time_of_day % 60;
53
54 let (year, month, day) = days_to_ymd(days);
55
56 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
57}
58
59fn days_to_ymd(days: u64) -> (u64, u64, u64) {
60 let z = days + 719468;
62 let era = z / 146097;
63 let doe = z - era * 146097;
64 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
65 let y = yoe + era * 400;
66 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
67 let mp = (5 * doy + 2) / 153;
68 let d = doy - (153 * mp + 2) / 5 + 1;
69 let m = if mp < 10 { mp + 3 } else { mp - 9 };
70 let y = if m <= 2 { y + 1 } else { y };
71 (y, m, d)
72}
73
74pub fn deep_merge_yaml(base: &mut serde_yaml::Value, overlay: &serde_yaml::Value) {
77 match (base, overlay) {
78 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
79 for (key, value) in overlay_map {
80 if let Some(base_value) = base_map.get_mut(key) {
81 deep_merge_yaml(base_value, value);
82 } else {
83 base_map.insert(key.clone(), value.clone());
84 }
85 }
86 }
87 (base, overlay) => {
88 *base = overlay.clone();
89 }
90 }
91}
92
93pub fn union_extend(target: &mut Vec<String>, source: &[String]) {
95 let mut existing: std::collections::HashSet<String> = target.iter().cloned().collect();
96 for item in source {
97 if existing.insert(item.clone()) {
98 target.push(item.clone());
99 }
100 }
101}
102
103pub fn git_cmd_safe(
113 url: Option<&str>,
114 ssh_policy: Option<config::SshHostKeyPolicy>,
115) -> std::process::Command {
116 let mut cmd = std::process::Command::new("git");
117 cmd.env("GIT_TERMINAL_PROMPT", "0")
118 .stdout(std::process::Stdio::null())
119 .stderr(std::process::Stdio::piped());
120 if url.is_some_and(|u| u.starts_with("git@") || u.starts_with("ssh://")) {
121 let policy = ssh_policy.unwrap_or_default();
122 cmd.env(
123 "GIT_SSH_COMMAND",
124 format!(
125 "ssh -o BatchMode=yes -o StrictHostKeyChecking={}",
126 policy.as_ssh_option()
127 ),
128 );
129 }
130 cmd
131}
132
133pub fn try_git_cmd(
136 url: Option<&str>,
137 args: &[&str],
138 label: &str,
139 ssh_policy: Option<config::SshHostKeyPolicy>,
140) -> bool {
141 let mut cmd = git_cmd_safe(url, ssh_policy);
142 cmd.args(args);
143 match command_output_with_timeout(&mut cmd, GIT_NETWORK_TIMEOUT) {
144 Ok(output) if output.status.success() => true,
145 Ok(output) => {
146 tracing::debug!(
147 "git {} CLI failed (exit {}): {}",
148 label,
149 output.status.code().unwrap_or(-1),
150 stderr_lossy_trimmed(&output),
151 );
152 false
153 }
154 Err(e) => {
155 tracing::debug!("git {} CLI unavailable: {e}", label);
156 false
157 }
158 }
159}
160
161pub const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
163
164pub const GIT_NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
166
167pub fn command_output_with_timeout(
170 cmd: &mut std::process::Command,
171 timeout: std::time::Duration,
172) -> std::io::Result<std::process::Output> {
173 use std::sync::mpsc;
174
175 let child = cmd.spawn()?;
176 let id = child.id();
177 let (tx, rx) = mpsc::channel();
178
179 std::thread::spawn(move || {
181 if rx.recv_timeout(timeout).is_err() {
182 terminate_process(id);
184 }
185 });
186
187 let result = child.wait_with_output();
188 let _ = tx.send(());
190 result
191}
192
193pub fn default_config_dir() -> std::path::PathBuf {
196 #[cfg(unix)]
197 {
198 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
199 return std::path::PathBuf::from(xdg).join("cfgd");
200 }
201 expand_tilde(std::path::Path::new("~/.config/cfgd"))
202 }
203 #[cfg(windows)]
204 {
205 directories::BaseDirs::new()
206 .map(|b| b.config_dir().join("cfgd"))
207 .unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData\cfgd"))
208 }
209}
210
211pub fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf {
213 let path_str = path.display().to_string();
214 let home = home_dir_var();
215 if let Some(home) = home {
216 if path_str == "~" {
217 return std::path::PathBuf::from(home);
218 }
219 if path_str.starts_with("~/") || path_str.starts_with("~\\") {
220 return std::path::PathBuf::from(path_str.replacen('~', &home, 1));
221 }
222 }
223 path.to_path_buf()
224}
225
226#[cfg(unix)]
230fn home_dir_var() -> Option<String> {
231 std::env::var("HOME").ok()
232}
233
234#[cfg(windows)]
235fn home_dir_var() -> Option<String> {
236 std::env::var("USERPROFILE")
237 .or_else(|_| std::env::var("HOME"))
238 .ok()
239}
240
241pub fn hostname_string() -> String {
243 hostname::get()
244 .map(|h| h.to_string_lossy().to_string())
245 .unwrap_or_else(|_| "unknown".to_string())
246}
247
248pub fn resolve_relative_path(
253 path: &std::path::Path,
254 base: &std::path::Path,
255) -> std::result::Result<std::path::PathBuf, String> {
256 if path.is_absolute() {
257 Ok(path.to_path_buf())
258 } else {
259 let joined = base.join(path);
260 validate_no_traversal(&joined)?;
261 Ok(joined)
262 }
263}
264
265pub fn create_symlink(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> {
270 #[cfg(unix)]
271 {
272 create_symlink_impl(source, target)
273 }
274 #[cfg(windows)]
275 {
276 create_symlink_impl(source, target).map_err(|e| {
277 if e.raw_os_error() == Some(1314) {
278 return std::io::Error::new(
280 e.kind(),
281 format!(
282 "symlink creation requires Developer Mode or admin privileges: {} -> {}\n\
283 Enable Developer Mode: Settings > Update & Security > For developers",
284 source.display(),
285 target.display()
286 ),
287 );
288 }
289 e
290 })
291 }
292}
293
294#[cfg(unix)]
295fn create_symlink_impl(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> {
296 std::os::unix::fs::symlink(source, target)
297}
298
299#[cfg(windows)]
300fn create_symlink_impl(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> {
301 if source.is_dir() {
302 std::os::windows::fs::symlink_dir(source, target)
303 } else {
304 std::os::windows::fs::symlink_file(source, target)
305 }
306}
307
308#[cfg(unix)]
310pub fn file_permissions_mode(metadata: &std::fs::Metadata) -> Option<u32> {
311 use std::os::unix::fs::PermissionsExt;
312 Some(metadata.permissions().mode() & 0o777)
313}
314
315#[cfg(windows)]
316pub fn file_permissions_mode(_metadata: &std::fs::Metadata) -> Option<u32> {
317 None
318}
319
320#[cfg(unix)]
322pub fn set_file_permissions(path: &std::path::Path, mode: u32) -> std::io::Result<()> {
323 use std::os::unix::fs::PermissionsExt;
324 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
325}
326
327#[cfg(windows)]
328pub fn set_file_permissions(_path: &std::path::Path, _mode: u32) -> std::io::Result<()> {
329 tracing::debug!("set_file_permissions is a no-op on Windows (NTFS uses inherited ACLs)");
330 Ok(())
331}
332
333#[cfg(unix)]
337pub fn is_executable(_path: &std::path::Path, metadata: &std::fs::Metadata) -> bool {
338 use std::os::unix::fs::PermissionsExt;
339 metadata.permissions().mode() & 0o111 != 0
340}
341
342#[cfg(windows)]
343pub fn is_executable(path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
344 const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "cmd", "bat", "ps1", "com"];
345 path.extension()
346 .and_then(|e| e.to_str())
347 .map(|e| EXECUTABLE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
348 .unwrap_or(false)
349}
350
351#[cfg(unix)]
353pub fn is_same_inode(a: &std::path::Path, b: &std::path::Path) -> bool {
354 use std::os::unix::fs::MetadataExt;
355 match (std::fs::metadata(a), std::fs::metadata(b)) {
356 (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
357 _ => false,
358 }
359}
360
361#[cfg(windows)]
362pub fn is_same_inode(a: &std::path::Path, b: &std::path::Path) -> bool {
363 use std::os::windows::io::AsRawHandle;
364 use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION;
365 use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle;
366
367 fn file_info(path: &std::path::Path) -> Option<BY_HANDLE_FILE_INFORMATION> {
368 let file = std::fs::File::open(path).ok()?;
369 let mut info = unsafe { std::mem::zeroed() };
370 let ret = unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) };
371 if ret != 0 { Some(info) } else { None }
372 }
373
374 match (file_info(a), file_info(b)) {
375 (Some(ia), Some(ib)) => {
376 ia.dwVolumeSerialNumber == ib.dwVolumeSerialNumber
377 && ia.nFileIndexHigh == ib.nFileIndexHigh
378 && ia.nFileIndexLow == ib.nFileIndexLow
379 }
380 _ => false,
381 }
382}
383
384#[cfg(unix)]
387pub fn terminate_process(pid: u32) {
388 use nix::sys::signal::{Signal, kill};
389 use nix::unistd::Pid;
390 let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
391}
392
393#[cfg(windows)]
394pub fn terminate_process(pid: u32) {
395 use windows_sys::Win32::Foundation::CloseHandle;
396 use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
397 unsafe {
398 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
399 if !handle.is_null() {
400 TerminateProcess(handle, 1);
401 CloseHandle(handle);
402 }
403 }
404}
405
406#[cfg(unix)]
409pub fn is_root() -> bool {
410 use nix::unistd::geteuid;
411 geteuid().is_root()
412}
413
414#[cfg(windows)]
415pub fn is_root() -> bool {
416 use windows_sys::Win32::UI::Shell::IsUserAnAdmin;
417 unsafe { IsUserAnAdmin() != 0 }
418}
419
420pub fn parse_loose_version(s: &str) -> Option<semver::Version> {
423 if let Ok(ver) = semver::Version::parse(s) {
424 return Some(ver);
425 }
426 if s.matches('.').count() == 1
427 && let Ok(ver) = semver::Version::parse(&format!("{s}.0"))
428 {
429 return Some(ver);
430 }
431 if !s.contains('.')
432 && let Ok(ver) = semver::Version::parse(&format!("{s}.0.0"))
433 {
434 return Some(ver);
435 }
436 None
437}
438
439pub fn version_satisfies(version_str: &str, requirement_str: &str) -> bool {
441 let req = match semver::VersionReq::parse(requirement_str) {
442 Ok(r) => r,
443 Err(_) => return false,
444 };
445 parse_loose_version(version_str)
446 .map(|ver| req.matches(&ver))
447 .unwrap_or(false)
448}
449
450pub fn git_ssh_credentials(
459 _url: &str,
460 username_from_url: Option<&str>,
461 allowed_types: git2::CredentialType,
462) -> std::result::Result<git2::Cred, git2::Error> {
463 let username = username_from_url.unwrap_or("git");
464
465 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
466 if let Ok(cred) = git2::Cred::ssh_key_from_agent(username) {
467 return Ok(cred);
468 }
469 let home = home_dir_var().unwrap_or_default();
470 for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
471 let key_path = std::path::Path::new(&home).join(".ssh").join(key_name);
472 if key_path.exists()
473 && let Ok(cred) = git2::Cred::ssh_key(username, None, &key_path, None)
474 {
475 return Ok(cred);
476 }
477 }
478 }
479
480 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
481 return git2::Cred::credential_helper(
482 &git2::Config::open_default()
483 .map_err(|e| git2::Error::from_str(&format!("cannot open git config: {e}")))?,
484 _url,
485 username_from_url,
486 );
487 }
488
489 if allowed_types.contains(git2::CredentialType::DEFAULT) {
490 return git2::Cred::default();
491 }
492
493 Err(git2::Error::from_str("no suitable credentials found"))
494}
495
496pub fn copy_dir_recursive(
499 src: &std::path::Path,
500 dst: &std::path::Path,
501) -> std::result::Result<(), std::io::Error> {
502 std::fs::create_dir_all(dst)?;
503 for entry in std::fs::read_dir(src)? {
504 let entry = entry?;
505 let file_type = entry.file_type()?;
506 if file_type.is_symlink() {
508 continue;
509 }
510 let dst_path = dst.join(entry.file_name());
511 if file_type.is_dir() {
512 copy_dir_recursive(&entry.path(), &dst_path)?;
513 } else {
514 std::fs::copy(entry.path(), &dst_path)?;
515 }
516 }
517 Ok(())
518}
519
520pub fn command_available(cmd: &str) -> bool {
524 let extensions: &[&str] = if cfg!(windows) {
525 &["", ".exe", ".cmd", ".bat", ".ps1", ".com"]
526 } else {
527 &[""]
528 };
529 std::env::var_os("PATH")
530 .map(|paths| {
531 std::env::split_paths(&paths).any(|dir| {
532 extensions.iter().any(|ext| {
533 let name = format!("{}{}", cmd, ext);
534 let path = dir.join(&name);
535 path.is_file()
536 && std::fs::metadata(&path)
537 .map(|m| is_executable(&path, &m))
538 .unwrap_or(false)
539 })
540 })
541 })
542 .unwrap_or(false)
543}
544
545pub fn merge_env(base: &mut Vec<config::EnvVar>, updates: &[config::EnvVar]) {
548 let mut index: std::collections::HashMap<String, usize> = base
549 .iter()
550 .enumerate()
551 .map(|(i, e)| (e.name.clone(), i))
552 .collect();
553 for ev in updates {
554 if let Some(&pos) = index.get(&ev.name) {
555 base[pos] = ev.clone();
556 } else {
557 index.insert(ev.name.clone(), base.len());
558 base.push(ev.clone());
559 }
560 }
561}
562
563pub fn parse_env_var(input: &str) -> std::result::Result<config::EnvVar, String> {
565 let (key, value) = input
566 .split_once('=')
567 .ok_or_else(|| format!("invalid env var '{}' — expected KEY=VALUE", input))?;
568 validate_env_var_name(key)?;
569 Ok(config::EnvVar {
570 name: key.to_string(),
571 value: value.to_string(),
572 })
573}
574
575pub fn validate_env_var_name(name: &str) -> std::result::Result<(), String> {
578 if name.is_empty() {
579 return Err("environment variable name must not be empty".to_string());
580 }
581 let first = name.as_bytes()[0];
582 if !first.is_ascii_alphabetic() && first != b'_' {
583 return Err(format!(
584 "invalid env var name '{}' — must start with a letter or underscore",
585 name
586 ));
587 }
588 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
589 return Err(format!(
590 "invalid env var name '{}' — must contain only letters, digits, and underscores",
591 name
592 ));
593 }
594 Ok(())
595}
596
597pub fn validate_alias_name(name: &str) -> std::result::Result<(), String> {
600 if name.is_empty() {
601 return Err("alias name must not be empty".to_string());
602 }
603 if !name
604 .bytes()
605 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
606 {
607 return Err(format!(
608 "invalid alias name '{}' — must contain only letters, digits, underscores, hyphens, and dots",
609 name
610 ));
611 }
612 Ok(())
613}
614
615pub fn merge_aliases(base: &mut Vec<config::ShellAlias>, updates: &[config::ShellAlias]) {
618 let mut index: std::collections::HashMap<String, usize> = base
619 .iter()
620 .enumerate()
621 .map(|(i, a)| (a.name.clone(), i))
622 .collect();
623 for alias in updates {
624 if let Some(&pos) = index.get(&alias.name) {
625 base[pos] = alias.clone();
626 } else {
627 index.insert(alias.name.clone(), base.len());
628 base.push(alias.clone());
629 }
630 }
631}
632
633pub fn split_add_remove(values: &[String]) -> (Vec<String>, Vec<String>) {
639 let mut adds = Vec::new();
640 let mut removes = Vec::new();
641 for v in values {
642 if let Some(stripped) = v.strip_prefix('-') {
643 removes.push(stripped.to_string());
644 } else {
645 adds.push(v.clone());
646 }
647 }
648 (adds, removes)
649}
650
651pub fn parse_alias(input: &str) -> std::result::Result<config::ShellAlias, String> {
653 let (name, command) = input
654 .split_once('=')
655 .ok_or_else(|| format!("invalid alias '{}' — expected name=command", input))?;
656 validate_alias_name(name)?;
657 Ok(config::ShellAlias {
658 name: name.to_string(),
659 command: command.to_string(),
660 })
661}
662
663const MAX_BACKUP_FILE_SIZE: u64 = 10 * 1024 * 1024;
670
671#[derive(Debug, Clone)]
673pub struct FileState {
674 pub content: Vec<u8>,
675 pub content_hash: String,
676 pub permissions: Option<u32>,
677 pub is_symlink: bool,
678 pub symlink_target: Option<std::path::PathBuf>,
679 pub oversized: bool,
681}
682
683use sha2::Digest as _;
685
686pub fn sha256_hex(data: &[u8]) -> String {
687 format!("{:x}", sha2::Sha256::digest(data))
688}
689
690pub fn stdout_lossy_trimmed(output: &std::process::Output) -> String {
692 String::from_utf8_lossy(&output.stdout).trim().to_string()
693}
694
695pub fn stderr_lossy_trimmed(output: &std::process::Output) -> String {
697 String::from_utf8_lossy(&output.stderr).trim().to_string()
698}
699
700pub fn atomic_write(
708 target: &std::path::Path,
709 content: &[u8],
710) -> std::result::Result<String, std::io::Error> {
711 use std::io::Write;
712
713 let parent = target.parent().unwrap_or(std::path::Path::new("."));
714 std::fs::create_dir_all(parent)?;
715
716 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
717 tmp.write_all(content)?;
718 tmp.as_file().sync_all()?;
719
720 if let Ok(meta) = std::fs::metadata(target) {
722 let _ = tmp.as_file().set_permissions(meta.permissions());
723 }
724
725 let hash = sha256_hex(content);
726
727 tmp.persist(target).map_err(|e| e.error)?;
729
730 Ok(hash)
731}
732
733pub fn atomic_write_str(
735 target: &std::path::Path,
736 content: &str,
737) -> std::result::Result<String, std::io::Error> {
738 atomic_write(target, content.as_bytes())
739}
740
741pub fn capture_file_state(
749 path: &std::path::Path,
750) -> std::result::Result<Option<FileState>, std::io::Error> {
751 let symlink_meta = match std::fs::symlink_metadata(path) {
752 Ok(m) => m,
753 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
754 Err(e) => return Err(e),
755 };
756
757 if symlink_meta.file_type().is_symlink() {
758 let symlink_target = std::fs::read_link(path)?;
759 return Ok(Some(FileState {
760 content: Vec::new(),
761 content_hash: String::new(),
762 permissions: None,
763 is_symlink: true,
764 symlink_target: Some(symlink_target),
765 oversized: false,
766 }));
767 }
768
769 let permissions = file_permissions_mode(&symlink_meta);
770
771 if symlink_meta.len() > MAX_BACKUP_FILE_SIZE {
772 return Ok(Some(FileState {
773 content: Vec::new(),
774 content_hash: String::new(),
775 permissions,
776 is_symlink: false,
777 symlink_target: None,
778 oversized: true,
779 }));
780 }
781
782 let content = std::fs::read(path)?;
783 let hash = sha256_hex(&content);
784
785 Ok(Some(FileState {
786 content,
787 content_hash: hash,
788 permissions,
789 is_symlink: false,
790 symlink_target: None,
791 oversized: false,
792 }))
793}
794
795pub fn capture_file_resolved_state(
803 path: &std::path::Path,
804) -> std::result::Result<Option<FileState>, std::io::Error> {
805 let symlink_meta = match std::fs::symlink_metadata(path) {
806 Ok(m) => m,
807 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
808 Err(e) => return Err(e),
809 };
810
811 let is_symlink = symlink_meta.file_type().is_symlink();
812 let symlink_target = if is_symlink {
813 std::fs::read_link(path).ok()
814 } else {
815 None
816 };
817
818 let real_meta = match std::fs::metadata(path) {
820 Ok(m) => m,
821 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
822 return Ok(None);
824 }
825 Err(e) => return Err(e),
826 };
827
828 let permissions = file_permissions_mode(&real_meta);
829
830 if real_meta.len() > MAX_BACKUP_FILE_SIZE {
831 return Ok(Some(FileState {
832 content: Vec::new(),
833 content_hash: String::new(),
834 permissions,
835 is_symlink,
836 symlink_target,
837 oversized: true,
838 }));
839 }
840
841 let content = std::fs::read(path)?;
842 let hash = sha256_hex(&content);
843
844 Ok(Some(FileState {
845 content,
846 content_hash: hash,
847 permissions,
848 is_symlink,
849 symlink_target,
850 oversized: false,
851 }))
852}
853
854pub fn validate_path_within(
859 path: &std::path::Path,
860 root: &std::path::Path,
861) -> std::result::Result<std::path::PathBuf, std::io::Error> {
862 let canonical_root = root.canonicalize()?;
863 let canonical_path = path.canonicalize()?;
864 if !canonical_path.starts_with(&canonical_root) {
865 return Err(std::io::Error::new(
866 std::io::ErrorKind::PermissionDenied,
867 format!(
868 "path {} escapes root {}",
869 canonical_path.display(),
870 canonical_root.display()
871 ),
872 ));
873 }
874 Ok(canonical_path)
875}
876
877pub fn validate_no_traversal(path: &std::path::Path) -> std::result::Result<(), String> {
882 for component in path.components() {
883 if let std::path::Component::ParentDir = component {
884 return Err(format!("path contains '..': {}", path.display()));
885 }
886 }
887 Ok(())
888}
889
890pub fn sanitize_k8s_name(name: &str) -> String {
896 name.to_ascii_lowercase()
897 .replace('_', "-")
898 .chars()
899 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
900 .collect::<String>()
901 .trim_matches('-')
902 .to_string()
903}
904
905pub fn shell_escape_value(value: &str) -> String {
910 if !value
911 .bytes()
912 .any(|b| matches!(b, b'$' | b'`' | b'\\' | b'"' | b'\''))
913 {
914 return format!("\"{}\"", value);
915 }
916 if !value.contains('\'') {
918 return format!("'{}'", value);
919 }
920 let mut out = String::with_capacity(value.len() + 8);
922 out.push('\'');
923 for c in value.chars() {
924 if c == '\'' {
925 out.push_str("'\\''");
926 } else {
927 out.push(c);
928 }
929 }
930 out.push('\'');
931 out
932}
933
934pub fn escape_double_quoted(s: &str) -> String {
938 let mut out = String::with_capacity(s.len() + s.len() / 8);
939 for c in s.chars() {
940 match c {
941 '\\' | '"' | '`' | '!' => {
942 out.push('\\');
943 out.push(c);
944 }
945 _ => out.push(c),
946 }
947 }
948 out
949}
950
951pub fn xml_escape(s: &str) -> String {
953 let mut out = String::with_capacity(s.len() + s.len() / 8);
954 for c in s.chars() {
955 match c {
956 '&' => out.push_str("&"),
957 '<' => out.push_str("<"),
958 '>' => out.push_str(">"),
959 '"' => out.push_str("""),
960 '\'' => out.push_str("'"),
961 _ => out.push(c),
962 }
963 }
964 out
965}
966
967#[cfg(unix)]
977type LockFile = nix::fcntl::Flock<std::fs::File>;
978#[cfg(windows)]
979type LockFile = std::fs::File;
980
981#[derive(Debug)]
983pub struct ApplyLockGuard {
984 _file: LockFile,
985 _path: std::path::PathBuf,
986}
987
988impl Drop for ApplyLockGuard {
989 fn drop(&mut self) {
990 let _ = self._file.set_len(0);
993 }
994}
995
996#[cfg(unix)]
997pub fn acquire_apply_lock(state_dir: &std::path::Path) -> errors::Result<ApplyLockGuard> {
998 use std::io::Write;
999
1000 std::fs::create_dir_all(state_dir)?;
1001 let lock_path = state_dir.join("apply.lock");
1002
1003 let file = std::fs::OpenOptions::new()
1004 .create(true)
1005 .truncate(false)
1006 .read(true)
1007 .write(true)
1008 .open(&lock_path)?;
1009
1010 let mut locked = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock)
1011 .map_err(|(_file, errno)| {
1012 if errno == nix::errno::Errno::EWOULDBLOCK {
1013 let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
1014 errors::CfgdError::from(errors::StateError::ApplyLockHeld {
1015 holder: format!("pid {}", holder.trim()),
1016 })
1017 } else {
1018 errors::CfgdError::from(std::io::Error::from(errno))
1019 }
1020 })?;
1021
1022 locked.set_len(0)?;
1024 write!(locked, "{}", std::process::id())?;
1025 locked.sync_all()?;
1026
1027 Ok(ApplyLockGuard {
1028 _file: locked,
1029 _path: lock_path,
1030 })
1031}
1032
1033#[cfg(windows)]
1040pub fn acquire_apply_lock(state_dir: &std::path::Path) -> errors::Result<ApplyLockGuard> {
1041 use std::io::Write;
1042 use std::os::windows::io::AsRawHandle;
1043 use windows_sys::Win32::Storage::FileSystem::{
1044 LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx,
1045 };
1046
1047 std::fs::create_dir_all(state_dir)?;
1048 let lock_path = state_dir.join("apply.lock");
1049
1050 let file = std::fs::OpenOptions::new()
1051 .create(true)
1052 .truncate(false)
1053 .read(true)
1054 .write(true)
1055 .open(&lock_path)?;
1056
1057 let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
1058 let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
1059 let ret = unsafe {
1060 LockFileEx(
1061 handle,
1062 LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
1063 0,
1064 1,
1065 0,
1066 &mut overlapped,
1067 )
1068 };
1069 if ret == 0 {
1070 let err = std::io::Error::last_os_error();
1071 if err.raw_os_error() == Some(33) {
1073 let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
1074 return Err(errors::StateError::ApplyLockHeld {
1075 holder: format!("pid {}", holder.trim()),
1076 }
1077 .into());
1078 }
1079 return Err(err.into());
1080 }
1081
1082 let mut f = file;
1083 f.set_len(0)?;
1084 write!(f, "{}", std::process::id())?;
1085 f.sync_all()?;
1086
1087 Ok(ApplyLockGuard {
1088 _file: f,
1089 _path: lock_path,
1090 })
1091}
1092
1093#[derive(Debug, Clone, serde::Serialize)]
1099pub struct EffectiveReconcile {
1100 pub interval: String,
1101 pub auto_apply: bool,
1102 pub drift_policy: config::DriftPolicy,
1103}
1104
1105pub fn resolve_effective_reconcile(
1115 module_name: &str,
1116 profile_chain: &[&str],
1117 reconcile: &config::ReconcileConfig,
1118) -> EffectiveReconcile {
1119 let mut effective = EffectiveReconcile {
1120 interval: reconcile.interval.clone(),
1121 auto_apply: reconcile.auto_apply,
1122 drift_policy: reconcile.drift_policy.clone(),
1123 };
1124
1125 if let Some(patch) = reconcile
1127 .patches
1128 .iter()
1129 .rev()
1130 .find(|p| p.kind == config::ReconcilePatchKind::Profile && p.name.is_none())
1131 {
1132 overlay_reconcile_patch(&mut effective, patch);
1133 }
1134
1135 for profile_name in profile_chain {
1137 if let Some(patch) = reconcile.patches.iter().rev().find(|p| {
1138 p.kind == config::ReconcilePatchKind::Profile && p.name.as_deref() == Some(profile_name)
1139 }) {
1140 overlay_reconcile_patch(&mut effective, patch);
1141 }
1142 }
1143
1144 if let Some(patch) = reconcile
1146 .patches
1147 .iter()
1148 .rev()
1149 .find(|p| p.kind == config::ReconcilePatchKind::Module && p.name.is_none())
1150 {
1151 overlay_reconcile_patch(&mut effective, patch);
1152 }
1153
1154 if let Some(patch) = reconcile.patches.iter().rev().find(|p| {
1156 p.kind == config::ReconcilePatchKind::Module && p.name.as_deref() == Some(module_name)
1157 }) {
1158 overlay_reconcile_patch(&mut effective, patch);
1159 }
1160
1161 effective
1162}
1163
1164fn overlay_reconcile_patch(base: &mut EffectiveReconcile, patch: &config::ReconcilePatch) {
1166 if let Some(ref interval) = patch.interval {
1167 base.interval = interval.clone();
1168 }
1169 if let Some(auto_apply) = patch.auto_apply {
1170 base.auto_apply = auto_apply;
1171 }
1172 if let Some(ref dp) = patch.drift_policy {
1173 base.drift_policy = dp.clone();
1174 }
1175}
1176
1177pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
1185 let s = s.trim();
1186 const SUFFIXES: &[(char, u64)] = &[('s', 1), ('m', 60), ('h', 3600), ('d', 86400)];
1187 for &(suffix, multiplier) in SUFFIXES {
1188 if let Some(n) = s.strip_suffix(suffix) {
1189 return n
1190 .trim()
1191 .parse::<u64>()
1192 .map(|v| std::time::Duration::from_secs(v * multiplier))
1193 .map_err(|_| format!("invalid timeout: {}", s));
1194 }
1195 }
1196 s.parse::<u64>()
1197 .map(std::time::Duration::from_secs)
1198 .map_err(|_| format!("invalid timeout '{}': use 30s, 5m, or 1h", s))
1199}
1200
1201pub const PROFILE_SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
1203
1204pub fn is_file_encrypted(
1210 path: &std::path::Path,
1211 backend: &str,
1212) -> std::result::Result<bool, errors::FileError> {
1213 use errors::FileError;
1214 match backend {
1215 "sops" => {
1216 let content = std::fs::read_to_string(path).map_err(|e| FileError::Io {
1217 path: path.to_path_buf(),
1218 source: e,
1219 })?;
1220 let value: Option<serde_yaml::Value> = serde_yaml::from_str(&content).ok();
1222 if let Some(serde_yaml::Value::Mapping(map)) = value
1223 && let Some(serde_yaml::Value::Mapping(sops)) =
1224 map.get(serde_yaml::Value::String("sops".to_string()))
1225 && sops.contains_key(serde_yaml::Value::String("mac".to_string()))
1226 && sops.contains_key(serde_yaml::Value::String("lastmodified".to_string()))
1227 {
1228 return Ok(true);
1229 }
1230 let json_value: Option<serde_json::Value> = serde_json::from_str(&content).ok();
1232 if let Some(serde_json::Value::Object(map)) = json_value
1233 && let Some(serde_json::Value::Object(sops)) = map.get("sops")
1234 && sops.contains_key("mac")
1235 && sops.contains_key("lastmodified")
1236 {
1237 return Ok(true);
1238 }
1239 Ok(false)
1240 }
1241 "age" => {
1242 let content = std::fs::read(path).map_err(|e| FileError::Io {
1243 path: path.to_path_buf(),
1244 source: e,
1245 })?;
1246 Ok(content.starts_with(b"age-encryption.org"))
1247 }
1248 other => Err(FileError::UnknownEncryptionBackend {
1249 backend: other.to_string(),
1250 }),
1251 }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257
1258 #[test]
1259 fn parse_duration_str_seconds() {
1260 let d = parse_duration_str("30s").unwrap();
1261 assert_eq!(d, std::time::Duration::from_secs(30));
1262 }
1263
1264 #[test]
1265 fn parse_duration_str_minutes() {
1266 let d = parse_duration_str("5m").unwrap();
1267 assert_eq!(d, std::time::Duration::from_secs(300));
1268 }
1269
1270 #[test]
1271 fn parse_duration_str_hours() {
1272 let d = parse_duration_str("1h").unwrap();
1273 assert_eq!(d, std::time::Duration::from_secs(3600));
1274 }
1275
1276 #[test]
1277 fn parse_duration_str_plain_seconds() {
1278 let d = parse_duration_str("60").unwrap();
1279 assert_eq!(d, std::time::Duration::from_secs(60));
1280 }
1281
1282 #[test]
1283 fn parse_duration_str_whitespace() {
1284 let d = parse_duration_str(" 10 s ").unwrap();
1285 assert_eq!(d, std::time::Duration::from_secs(10));
1286 }
1287
1288 #[test]
1289 fn parse_duration_str_days() {
1290 let d = parse_duration_str("30d").unwrap();
1291 assert_eq!(d, std::time::Duration::from_secs(30 * 86400));
1292 }
1293
1294 #[test]
1295 fn parse_duration_str_invalid() {
1296 assert!(
1297 parse_duration_str("abc")
1298 .unwrap_err()
1299 .contains("invalid timeout"),
1300 "bare letters should fail with a useful message"
1301 );
1302 assert!(
1303 parse_duration_str("")
1304 .unwrap_err()
1305 .contains("invalid timeout"),
1306 "empty string should fail"
1307 );
1308 assert!(
1309 parse_duration_str("xs")
1310 .unwrap_err()
1311 .contains("invalid timeout"),
1312 "non-numeric prefix should fail"
1313 );
1314 }
1315
1316 #[test]
1317 fn parse_duration_str_zero() {
1318 assert_eq!(
1319 parse_duration_str("0s").unwrap(),
1320 std::time::Duration::from_secs(0)
1321 );
1322 assert_eq!(
1323 parse_duration_str("0").unwrap(),
1324 std::time::Duration::from_secs(0)
1325 );
1326 }
1327
1328 #[test]
1329 fn parse_duration_str_negative() {
1330 assert!(
1331 parse_duration_str("-5s").is_err(),
1332 "negative durations should be rejected"
1333 );
1334 }
1335
1336 #[test]
1337 fn parse_loose_version_full_semver() {
1338 assert_eq!(
1339 parse_loose_version("1.28.3"),
1340 Some(semver::Version::new(1, 28, 3))
1341 );
1342 assert_eq!(
1343 parse_loose_version("0.1.0"),
1344 Some(semver::Version::new(0, 1, 0))
1345 );
1346 }
1347
1348 #[test]
1349 fn parse_loose_version_two_part() {
1350 let ver = parse_loose_version("1.28").unwrap();
1351 assert_eq!(ver, semver::Version::new(1, 28, 0));
1352 }
1353
1354 #[test]
1355 fn parse_loose_version_single_part() {
1356 let ver = parse_loose_version("1").unwrap();
1357 assert_eq!(ver, semver::Version::new(1, 0, 0));
1358 }
1359
1360 #[test]
1361 fn parse_loose_version_rejects_garbage() {
1362 assert!(parse_loose_version("abc").is_none());
1363 assert!(parse_loose_version("").is_none());
1364 assert!(parse_loose_version("1.2.3.4").is_none());
1365 assert!(
1366 parse_loose_version("-1").is_none(),
1367 "negative numbers are not valid versions"
1368 );
1369 }
1370
1371 #[test]
1372 fn parse_loose_version_preserves_prerelease() {
1373 let ver = parse_loose_version("1.2.3-beta.1").unwrap();
1375 assert_eq!(ver.major, 1);
1376 assert_eq!(ver.minor, 2);
1377 assert_eq!(ver.patch, 3);
1378 assert!(!ver.pre.is_empty(), "pre-release should be preserved");
1379 }
1380
1381 #[test]
1382 fn version_satisfies_basic() {
1383 assert!(version_satisfies("1.28.3", ">=1.28"));
1384 assert!(!version_satisfies("1.27.0", ">=1.28"));
1385 assert!(version_satisfies("2.40.1", "~2.40"));
1386 assert!(!version_satisfies("2.39.0", "~2.40"));
1387 }
1388
1389 #[test]
1390 fn version_satisfies_loose() {
1391 assert!(version_satisfies("1.28", ">=1.28"));
1392 assert!(version_satisfies("2", ">=1.28"));
1393 assert!(!version_satisfies("1", ">=1.28"));
1394 }
1395
1396 #[test]
1397 fn version_satisfies_invalid_requirement() {
1398 assert!(!version_satisfies("1.0.0", "not valid"));
1399 }
1400
1401 #[cfg(unix)]
1402 #[test]
1403 fn home_dir_var_uses_home_on_unix() {
1404 let result = home_dir_var();
1405 assert!(result.is_some());
1406 assert_eq!(result.unwrap(), std::env::var("HOME").unwrap());
1407 }
1408
1409 #[test]
1410 fn version_satisfies_invalid_version() {
1411 assert!(!version_satisfies("abc", ">=1.0"));
1412 }
1413
1414 #[test]
1415 fn atomic_write_creates_file_with_content() {
1416 let dir = tempfile::tempdir().unwrap();
1417 let target = dir.path().join("test.txt");
1418 let hash = atomic_write(&target, b"hello world").unwrap();
1419 assert_eq!(std::fs::read_to_string(&target).unwrap(), "hello world");
1420 assert!(!hash.is_empty());
1421 assert_eq!(hash.len(), 64); }
1423
1424 #[test]
1425 fn atomic_write_creates_parent_dirs() {
1426 let dir = tempfile::tempdir().unwrap();
1427 let target = dir.path().join("a/b/c/test.txt");
1428 atomic_write(&target, b"nested").unwrap();
1429 assert_eq!(std::fs::read_to_string(&target).unwrap(), "nested");
1430 }
1431
1432 #[cfg(unix)]
1433 #[test]
1434 fn atomic_write_preserves_permissions() {
1435 use std::os::unix::fs::PermissionsExt;
1436 let dir = tempfile::tempdir().unwrap();
1437 let target = dir.path().join("perms.txt");
1438 std::fs::write(&target, "old").unwrap();
1439 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
1440
1441 atomic_write(&target, b"new").unwrap();
1442
1443 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1444 assert_eq!(mode, 0o600);
1445 }
1446
1447 #[test]
1448 fn atomic_write_str_works() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let target = dir.path().join("str.txt");
1451 let hash = atomic_write_str(&target, "string content").unwrap();
1452 assert_eq!(std::fs::read_to_string(&target).unwrap(), "string content");
1453 assert_eq!(hash.len(), 64);
1454 }
1455
1456 #[test]
1457 fn capture_file_state_regular_file() {
1458 let dir = tempfile::tempdir().unwrap();
1459 let target = dir.path().join("file.txt");
1460 std::fs::write(&target, "contents").unwrap();
1461
1462 let state = capture_file_state(&target).unwrap().unwrap();
1463 assert_eq!(state.content, b"contents");
1464 assert!(!state.content_hash.is_empty());
1465 assert!(!state.is_symlink);
1466 assert!(state.symlink_target.is_none());
1467 assert!(!state.oversized);
1468 }
1469
1470 #[test]
1471 #[cfg(unix)]
1472 fn capture_file_state_symlink() {
1473 let dir = tempfile::tempdir().unwrap();
1474 let real = dir.path().join("real.txt");
1475 let link = dir.path().join("link.txt");
1476 std::fs::write(&real, "target").unwrap();
1477 std::os::unix::fs::symlink(&real, &link).unwrap();
1478
1479 let state = capture_file_state(&link).unwrap().unwrap();
1480 assert!(state.is_symlink);
1481 assert_eq!(state.symlink_target.unwrap(), real);
1482 assert!(state.content.is_empty());
1483 }
1484
1485 #[test]
1486 fn capture_file_state_missing_returns_none() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let missing = dir.path().join("does_not_exist.txt");
1489 let state = capture_file_state(&missing).unwrap();
1490 assert!(state.is_none());
1491 }
1492
1493 #[test]
1494 fn create_symlink_creates_link() {
1495 let dir = tempfile::tempdir().unwrap();
1496 let source = dir.path().join("source.txt");
1497 std::fs::write(&source, "hello").unwrap();
1498 let link = dir.path().join("link.txt");
1499 create_symlink(&source, &link).unwrap();
1500 assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
1501 assert_eq!(std::fs::read_to_string(&link).unwrap(), "hello");
1502 }
1503
1504 #[cfg(unix)]
1505 #[test]
1506 fn file_permissions_mode_returns_mode() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let file = dir.path().join("test.txt");
1509 std::fs::write(&file, "test").unwrap();
1510 let meta = std::fs::metadata(&file).unwrap();
1511 let mode = file_permissions_mode(&meta);
1512 assert!(mode.is_some());
1513 let bits = mode.unwrap();
1514 assert!(bits & 0o777 > 0, "mode bits should be non-zero");
1515 assert!(
1516 bits & 0o400 != 0,
1517 "owner read bit should be set on a newly created file"
1518 );
1519 }
1520
1521 #[cfg(unix)]
1522 #[test]
1523 fn set_file_permissions_changes_mode() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let file = dir.path().join("test.txt");
1526 std::fs::write(&file, "test").unwrap();
1527 set_file_permissions(&file, 0o755).unwrap();
1528 let meta = std::fs::metadata(&file).unwrap();
1529 assert_eq!(file_permissions_mode(&meta), Some(0o755));
1530 }
1531
1532 #[cfg(unix)]
1533 #[test]
1534 fn is_executable_checks_mode() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let file = dir.path().join("script.sh");
1537 std::fs::write(&file, "#!/bin/sh").unwrap();
1538
1539 set_file_permissions(&file, 0o644).unwrap();
1540 let meta = std::fs::metadata(&file).unwrap();
1541 assert!(!is_executable(&file, &meta));
1542
1543 set_file_permissions(&file, 0o755).unwrap();
1544 let meta = std::fs::metadata(&file).unwrap();
1545 assert!(is_executable(&file, &meta));
1546 }
1547
1548 #[cfg(unix)]
1549 #[test]
1550 fn is_same_inode_detects_hard_links() {
1551 let dir = tempfile::tempdir().unwrap();
1552 let file = dir.path().join("original.txt");
1553 std::fs::write(&file, "content").unwrap();
1554 let link = dir.path().join("hardlink.txt");
1555 std::fs::hard_link(&file, &link).unwrap();
1556
1557 assert!(is_same_inode(&file, &link));
1558 assert!(!is_same_inode(&file, &dir.path().join("nonexistent")));
1559 }
1560
1561 #[test]
1562 fn validate_path_within_accepts_child() {
1563 let dir = tempfile::tempdir().unwrap();
1564 let child = dir.path().join("sub/file.txt");
1565 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
1566 std::fs::write(&child, "").unwrap();
1567 assert!(validate_path_within(&child, dir.path()).is_ok());
1568 }
1569
1570 #[test]
1571 fn validate_path_within_rejects_escape() {
1572 let root = tempfile::tempdir().unwrap();
1575 let outside = tempfile::tempdir().unwrap();
1576 let result = validate_path_within(outside.path(), root.path());
1577 let err = result.unwrap_err();
1578 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
1579 assert!(
1580 err.to_string().contains("escapes root"),
1581 "expected 'escapes root' message, got: {err}"
1582 );
1583 }
1584
1585 #[test]
1586 fn validate_no_traversal_accepts_clean_path() {
1587 assert!(validate_no_traversal(std::path::Path::new("a/b/c")).is_ok());
1588 assert!(validate_no_traversal(std::path::Path::new("/absolute/path")).is_ok());
1589 }
1590
1591 #[test]
1592 fn validate_no_traversal_rejects_dotdot() {
1593 assert!(validate_no_traversal(std::path::Path::new("a/../b")).is_err());
1594 assert!(validate_no_traversal(std::path::Path::new("../../etc")).is_err());
1595 }
1596
1597 #[test]
1598 fn shell_escape_value_simple() {
1599 assert_eq!(shell_escape_value("hello"), "\"hello\"");
1600 }
1601
1602 #[test]
1603 fn shell_escape_value_with_dollar() {
1604 assert_eq!(shell_escape_value("$HOME/bin"), "'$HOME/bin'");
1605 }
1606
1607 #[test]
1608 fn shell_escape_value_with_single_quote() {
1609 assert_eq!(shell_escape_value("it's"), "'it'\\''s'");
1610 }
1611
1612 #[test]
1613 fn xml_escape_special_chars() {
1614 assert_eq!(xml_escape("<tag>&\"'"), "<tag>&"'");
1615 }
1616
1617 #[test]
1618 fn xml_escape_passthrough() {
1619 assert_eq!(xml_escape("normal text"), "normal text");
1620 }
1621
1622 #[test]
1623 #[cfg(unix)] fn acquire_apply_lock_works() {
1625 let dir = tempfile::tempdir().unwrap();
1626 let guard = acquire_apply_lock(dir.path()).unwrap();
1627 let content = std::fs::read_to_string(dir.path().join("apply.lock")).unwrap();
1629 assert_eq!(content, format!("{}", std::process::id()));
1630 drop(guard);
1631 }
1632
1633 #[test]
1634 fn acquire_apply_lock_detects_contention() {
1635 let dir = tempfile::tempdir().unwrap();
1636 let _guard = acquire_apply_lock(dir.path()).unwrap();
1637 let result = acquire_apply_lock(dir.path());
1639 assert!(result.is_err());
1640 let err = result.unwrap_err();
1641 assert!(
1642 matches!(
1643 err,
1644 errors::CfgdError::State(errors::StateError::ApplyLockHeld { .. })
1645 ),
1646 "expected ApplyLockHeld, got: {}",
1647 err
1648 );
1649 }
1650
1651 #[test]
1652 fn merge_aliases_override_by_name() {
1653 let mut base = vec![
1654 config::ShellAlias {
1655 name: "vim".into(),
1656 command: "vi".into(),
1657 },
1658 config::ShellAlias {
1659 name: "ll".into(),
1660 command: "ls -l".into(),
1661 },
1662 ];
1663 let updates = vec![config::ShellAlias {
1664 name: "vim".into(),
1665 command: "nvim".into(),
1666 }];
1667 merge_aliases(&mut base, &updates);
1668 assert_eq!(base.len(), 2);
1669 assert_eq!(base[0].command, "nvim");
1670 assert_eq!(base[1].command, "ls -l");
1671 }
1672
1673 #[test]
1674 fn merge_aliases_appends_new() {
1675 let mut base = vec![config::ShellAlias {
1676 name: "vim".into(),
1677 command: "nvim".into(),
1678 }];
1679 let updates = vec![config::ShellAlias {
1680 name: "ll".into(),
1681 command: "ls -la".into(),
1682 }];
1683 merge_aliases(&mut base, &updates);
1684 assert_eq!(base.len(), 2);
1685 }
1686
1687 #[test]
1688 fn split_add_remove_basic() {
1689 let vals: Vec<String> = vec!["foo".into(), "-bar".into(), "baz".into(), "-qux".into()];
1690 let (adds, removes) = split_add_remove(&vals);
1691 assert_eq!(adds, vec!["foo", "baz"]);
1692 assert_eq!(removes, vec!["bar", "qux"]);
1693 }
1694
1695 #[test]
1696 fn split_add_remove_empty() {
1697 let (adds, removes) = split_add_remove(&[]);
1698 assert!(adds.is_empty());
1699 assert!(removes.is_empty());
1700 }
1701
1702 #[test]
1703 fn split_add_remove_all_adds() {
1704 let vals: Vec<String> = vec!["a".into(), "b".into()];
1705 let (adds, removes) = split_add_remove(&vals);
1706 assert_eq!(adds, vec!["a", "b"]);
1707 assert!(removes.is_empty());
1708 }
1709
1710 #[test]
1711 fn split_add_remove_all_removes() {
1712 let vals: Vec<String> = vec!["-x".into(), "-y".into()];
1713 let (adds, removes) = split_add_remove(&vals);
1714 assert!(adds.is_empty());
1715 assert_eq!(removes, vec!["x", "y"]);
1716 }
1717
1718 #[test]
1719 fn parse_alias_valid() {
1720 let alias = parse_alias("vim=nvim").unwrap();
1721 assert_eq!(alias.name, "vim");
1722 assert_eq!(alias.command, "nvim");
1723 }
1724
1725 #[test]
1726 fn parse_alias_with_args() {
1727 let alias = parse_alias("ll=ls -la --color").unwrap();
1728 assert_eq!(alias.name, "ll");
1729 assert_eq!(alias.command, "ls -la --color");
1730 }
1731
1732 #[test]
1733 fn parse_alias_invalid() {
1734 assert!(parse_alias("no-equals-sign").is_err());
1735 }
1736
1737 #[test]
1738 fn deep_merge_yaml_maps() {
1739 let mut base = serde_yaml::from_str::<serde_yaml::Value>("a: 1\nb: 2").unwrap();
1740 let overlay = serde_yaml::from_str::<serde_yaml::Value>("b: 3\nc: 4").unwrap();
1741 deep_merge_yaml(&mut base, &overlay);
1742 assert_eq!(base["a"], serde_yaml::Value::from(1));
1743 assert_eq!(base["b"], serde_yaml::Value::from(3));
1744 assert_eq!(base["c"], serde_yaml::Value::from(4));
1745 }
1746
1747 #[test]
1748 fn deep_merge_yaml_nested() {
1749 let mut base = serde_yaml::from_str::<serde_yaml::Value>("top:\n a: 1\n b: 2").unwrap();
1750 let overlay = serde_yaml::from_str::<serde_yaml::Value>("top:\n b: 9\n c: 3").unwrap();
1751 deep_merge_yaml(&mut base, &overlay);
1752 assert_eq!(base["top"]["a"], serde_yaml::Value::from(1));
1753 assert_eq!(base["top"]["b"], serde_yaml::Value::from(9));
1754 assert_eq!(base["top"]["c"], serde_yaml::Value::from(3));
1755 }
1756
1757 #[test]
1758 fn deep_merge_yaml_overlay_replaces_scalar() {
1759 let mut base = serde_yaml::from_str::<serde_yaml::Value>("x: old").unwrap();
1760 let overlay = serde_yaml::from_str::<serde_yaml::Value>("x: new").unwrap();
1761 deep_merge_yaml(&mut base, &overlay);
1762 assert_eq!(base["x"], serde_yaml::Value::from("new"));
1763 }
1764
1765 #[test]
1766 fn union_extend_deduplicates() {
1767 let mut target = vec!["a".to_string(), "b".to_string()];
1768 union_extend(&mut target, &["b".to_string(), "c".to_string()]);
1769 assert_eq!(target, vec!["a", "b", "c"]);
1770 }
1771
1772 #[test]
1773 fn union_extend_empty_source() {
1774 let mut target = vec!["a".to_string()];
1775 union_extend(&mut target, &[]);
1776 assert_eq!(target, vec!["a"]);
1777 }
1778
1779 #[test]
1780 fn merge_env_overrides_by_name() {
1781 let mut base = vec![
1782 config::EnvVar {
1783 name: "FOO".into(),
1784 value: "old".into(),
1785 },
1786 config::EnvVar {
1787 name: "BAR".into(),
1788 value: "keep".into(),
1789 },
1790 ];
1791 let updates = vec![config::EnvVar {
1792 name: "FOO".into(),
1793 value: "new".into(),
1794 }];
1795 merge_env(&mut base, &updates);
1796 assert_eq!(base.len(), 2);
1797 assert_eq!(base.iter().find(|e| e.name == "FOO").unwrap().value, "new");
1798 assert_eq!(base.iter().find(|e| e.name == "BAR").unwrap().value, "keep");
1799 }
1800
1801 #[test]
1802 fn merge_env_adds_new() {
1803 let mut base = vec![];
1804 let updates = vec![config::EnvVar {
1805 name: "NEW".into(),
1806 value: "val".into(),
1807 }];
1808 merge_env(&mut base, &updates);
1809 assert_eq!(base.len(), 1);
1810 assert_eq!(base[0].name, "NEW");
1811 }
1812
1813 #[test]
1814 fn shell_escape_value_metacharacters() {
1815 assert_eq!(shell_escape_value("it's a $test"), "'it'\\''s a $test'");
1817 }
1818
1819 #[test]
1820 fn shell_escape_value_backtick() {
1821 assert_eq!(shell_escape_value("`cmd`"), "'`cmd`'");
1822 }
1823
1824 #[test]
1825 fn shell_escape_value_backslash() {
1826 assert_eq!(shell_escape_value("a\\b"), "'a\\b'");
1827 }
1828
1829 #[test]
1830 fn shell_escape_value_empty() {
1831 assert_eq!(shell_escape_value(""), "\"\"");
1832 }
1833
1834 #[test]
1835 fn xml_escape_all_entities() {
1836 assert_eq!(
1837 xml_escape("a&b<c>d\"e'f"),
1838 "a&b<c>d"e'f"
1839 );
1840 }
1841
1842 #[test]
1843 fn unix_secs_to_iso8601_epoch() {
1844 let result = unix_secs_to_iso8601(0);
1845 assert_eq!(result, "1970-01-01T00:00:00Z");
1846 }
1847
1848 #[test]
1849 fn unix_secs_to_iso8601_known_date() {
1850 let result = unix_secs_to_iso8601(1700000000);
1851 assert!(result.starts_with("2023-11-14"));
1852 }
1853
1854 #[test]
1855 fn copy_dir_recursive_copies_tree() {
1856 let src = tempfile::tempdir().unwrap();
1857 let dst = tempfile::tempdir().unwrap();
1858 let dst_path = dst.path().join("copy");
1859 std::fs::create_dir_all(src.path().join("sub")).unwrap();
1860 std::fs::write(src.path().join("a.txt"), "hello").unwrap();
1861 std::fs::write(src.path().join("sub/b.txt"), "world").unwrap();
1862 copy_dir_recursive(src.path(), &dst_path).unwrap();
1863 assert_eq!(
1864 std::fs::read_to_string(dst_path.join("a.txt")).unwrap(),
1865 "hello"
1866 );
1867 assert_eq!(
1868 std::fs::read_to_string(dst_path.join("sub/b.txt")).unwrap(),
1869 "world"
1870 );
1871 }
1872
1873 #[test]
1874 fn expand_tilde_with_home() {
1875 let result = expand_tilde(std::path::Path::new("~/test"));
1876 let home = home_dir_var().expect("home directory must be available in test");
1877 let expected = std::path::PathBuf::from(home).join("test");
1878 assert_eq!(result, expected);
1879 }
1880
1881 #[test]
1882 fn expand_tilde_absolute_unchanged() {
1883 let result = expand_tilde(std::path::Path::new("/absolute/path"));
1884 assert_eq!(result, std::path::PathBuf::from("/absolute/path"));
1885 }
1886
1887 #[test]
1888 fn acquire_apply_lock_and_release() {
1889 let dir = tempfile::tempdir().unwrap();
1890 let guard = acquire_apply_lock(dir.path()).unwrap();
1891 assert!(dir.path().join("apply.lock").exists());
1892 drop(guard);
1893 }
1894
1895 fn test_reconcile_config(patches: Vec<config::ReconcilePatch>) -> config::ReconcileConfig {
1898 config::ReconcileConfig {
1899 interval: "5m".into(),
1900 on_change: false,
1901 auto_apply: false,
1902 policy: None,
1903 drift_policy: config::DriftPolicy::NotifyOnly,
1904 patches,
1905 }
1906 }
1907
1908 #[test]
1909 fn resolve_reconcile_global_only() {
1910 let cfg = test_reconcile_config(vec![]);
1911 let eff = resolve_effective_reconcile("some-module", &["default"], &cfg);
1912 assert_eq!(eff.interval, "5m");
1913 assert!(!eff.auto_apply);
1914 assert_eq!(eff.drift_policy, config::DriftPolicy::NotifyOnly);
1915 }
1916
1917 #[test]
1918 fn resolve_reconcile_module_patch() {
1919 let cfg = test_reconcile_config(vec![config::ReconcilePatch {
1920 kind: config::ReconcilePatchKind::Module,
1921 name: Some("certs".into()),
1922 interval: Some("1m".into()),
1923 auto_apply: None,
1924 drift_policy: Some(config::DriftPolicy::Auto),
1925 }]);
1926 let eff = resolve_effective_reconcile("certs", &["default"], &cfg);
1927 assert_eq!(eff.interval, "1m");
1928 assert!(!eff.auto_apply); assert_eq!(eff.drift_policy, config::DriftPolicy::Auto);
1930 }
1931
1932 #[test]
1933 fn resolve_reconcile_profile_patch() {
1934 let cfg = test_reconcile_config(vec![config::ReconcilePatch {
1935 kind: config::ReconcilePatchKind::Profile,
1936 name: Some("work".into()),
1937 interval: None,
1938 auto_apply: Some(true),
1939 drift_policy: None,
1940 }]);
1941 let eff = resolve_effective_reconcile("any-mod", &["base", "work"], &cfg);
1942 assert_eq!(eff.interval, "5m"); assert!(eff.auto_apply); }
1945
1946 #[test]
1947 fn resolve_reconcile_module_beats_profile() {
1948 let cfg = test_reconcile_config(vec![
1949 config::ReconcilePatch {
1950 kind: config::ReconcilePatchKind::Profile,
1951 name: Some("work".into()),
1952 interval: None,
1953 auto_apply: Some(false),
1954 drift_policy: None,
1955 },
1956 config::ReconcilePatch {
1957 kind: config::ReconcilePatchKind::Module,
1958 name: Some("certs".into()),
1959 interval: None,
1960 auto_apply: Some(true),
1961 drift_policy: None,
1962 },
1963 ]);
1964 let eff = resolve_effective_reconcile("certs", &["work"], &cfg);
1965 assert!(eff.auto_apply); }
1967
1968 #[test]
1969 fn resolve_reconcile_leaf_profile_wins() {
1970 let cfg = test_reconcile_config(vec![
1971 config::ReconcilePatch {
1972 kind: config::ReconcilePatchKind::Profile,
1973 name: Some("base".into()),
1974 interval: Some("10m".into()),
1975 auto_apply: None,
1976 drift_policy: None,
1977 },
1978 config::ReconcilePatch {
1979 kind: config::ReconcilePatchKind::Profile,
1980 name: Some("work".into()),
1981 interval: Some("2m".into()),
1982 auto_apply: None,
1983 drift_policy: None,
1984 },
1985 ]);
1986 let eff = resolve_effective_reconcile("any", &["base", "work"], &cfg);
1988 assert_eq!(eff.interval, "2m");
1989 }
1990
1991 #[test]
1992 fn resolve_reconcile_fields_merge_independently() {
1993 let cfg = test_reconcile_config(vec![
1994 config::ReconcilePatch {
1995 kind: config::ReconcilePatchKind::Profile,
1996 name: Some("work".into()),
1997 interval: Some("10m".into()),
1998 auto_apply: None,
1999 drift_policy: None,
2000 },
2001 config::ReconcilePatch {
2002 kind: config::ReconcilePatchKind::Module,
2003 name: Some("certs".into()),
2004 interval: None,
2005 auto_apply: None,
2006 drift_policy: Some(config::DriftPolicy::Auto),
2007 },
2008 ]);
2009 let eff = resolve_effective_reconcile("certs", &["work"], &cfg);
2010 assert_eq!(eff.interval, "10m"); assert_eq!(eff.drift_policy, config::DriftPolicy::Auto); assert!(!eff.auto_apply); }
2014
2015 #[test]
2016 fn resolve_reconcile_missing_module_ignored() {
2017 let cfg = test_reconcile_config(vec![config::ReconcilePatch {
2018 kind: config::ReconcilePatchKind::Module,
2019 name: Some("nonexistent".into()),
2020 interval: Some("1s".into()),
2021 auto_apply: None,
2022 drift_policy: None,
2023 }]);
2024 let eff = resolve_effective_reconcile("other", &["default"], &cfg);
2026 assert_eq!(eff.interval, "5m");
2027 }
2028
2029 #[test]
2030 fn resolve_reconcile_duplicate_module_last_wins() {
2031 let cfg = test_reconcile_config(vec![
2032 config::ReconcilePatch {
2033 kind: config::ReconcilePatchKind::Module,
2034 name: Some("certs".into()),
2035 interval: Some("10m".into()),
2036 auto_apply: None,
2037 drift_policy: None,
2038 },
2039 config::ReconcilePatch {
2040 kind: config::ReconcilePatchKind::Module,
2041 name: Some("certs".into()),
2042 interval: Some("1m".into()),
2043 auto_apply: None,
2044 drift_policy: None,
2045 },
2046 ]);
2047 let eff = resolve_effective_reconcile("certs", &["default"], &cfg);
2048 assert_eq!(eff.interval, "1m"); }
2050
2051 #[test]
2052 fn resolve_reconcile_kind_wide_module_patch() {
2053 let cfg = test_reconcile_config(vec![config::ReconcilePatch {
2054 kind: config::ReconcilePatchKind::Module,
2055 name: None, interval: Some("30s".into()),
2057 auto_apply: None,
2058 drift_policy: None,
2059 }]);
2060 let eff = resolve_effective_reconcile("any-module", &["default"], &cfg);
2061 assert_eq!(eff.interval, "30s");
2062 }
2063
2064 #[test]
2065 fn resolve_reconcile_named_beats_kind_wide() {
2066 let cfg = test_reconcile_config(vec![
2067 config::ReconcilePatch {
2068 kind: config::ReconcilePatchKind::Module,
2069 name: None, interval: Some("30s".into()),
2071 auto_apply: None,
2072 drift_policy: None,
2073 },
2074 config::ReconcilePatch {
2075 kind: config::ReconcilePatchKind::Module,
2076 name: Some("certs".into()), interval: Some("5s".into()),
2078 auto_apply: None,
2079 drift_policy: None,
2080 },
2081 ]);
2082 let eff = resolve_effective_reconcile("certs", &["default"], &cfg);
2084 assert_eq!(eff.interval, "5s");
2085 let eff2 = resolve_effective_reconcile("other", &["default"], &cfg);
2087 assert_eq!(eff2.interval, "30s");
2088 }
2089
2090 #[test]
2091 fn validate_env_var_name_accepts_valid() {
2092 assert!(validate_env_var_name("PATH").is_ok());
2093 assert!(validate_env_var_name("_PRIVATE").is_ok());
2094 assert!(validate_env_var_name("MY_VAR_123").is_ok());
2095 assert!(validate_env_var_name("a").is_ok());
2096 }
2097
2098 #[test]
2099 fn validate_env_var_name_rejects_invalid() {
2100 let err = validate_env_var_name("").unwrap_err();
2101 assert!(err.contains("empty"), "empty should say empty: {err}");
2102
2103 let err = validate_env_var_name("1STARTS_WITH_DIGIT").unwrap_err();
2104 assert!(
2105 err.contains("must start with"),
2106 "digit-prefix should explain: {err}"
2107 );
2108
2109 for bad in ["HAS SPACE", "HAS;SEMI", "HAS$DOLLAR", "HAS-DASH", "a=b"] {
2111 assert!(
2112 validate_env_var_name(bad).is_err(),
2113 "{bad:?} should be rejected"
2114 );
2115 }
2116 }
2117
2118 #[test]
2119 fn validate_alias_name_accepts_valid() {
2120 assert!(validate_alias_name("ls").is_ok());
2121 assert!(validate_alias_name("my-alias").is_ok());
2122 assert!(validate_alias_name("my.alias").is_ok());
2123 assert!(validate_alias_name("my_alias_123").is_ok());
2124 }
2125
2126 #[test]
2127 fn validate_alias_name_rejects_invalid() {
2128 let err = validate_alias_name("").unwrap_err();
2129 assert!(err.contains("empty"), "empty should say empty: {err}");
2130
2131 for bad in ["has space", "has;semi", "has$dollar", "a=b", "has/slash"] {
2132 let err = validate_alias_name(bad).unwrap_err();
2133 assert!(
2134 err.contains("must contain only"),
2135 "{bad:?} rejection should explain allowed chars: {err}"
2136 );
2137 }
2138 }
2139
2140 #[test]
2141 fn parse_env_var_validates_name() {
2142 let ev = parse_env_var("VALID=value").unwrap();
2143 assert_eq!(ev.name, "VALID");
2144 assert_eq!(ev.value, "value");
2145
2146 assert!(
2147 parse_env_var("1BAD=value")
2148 .unwrap_err()
2149 .contains("must start with"),
2150 "digit-leading name should fail"
2151 );
2152 assert!(parse_env_var("BAD;NAME=value").is_err());
2153 }
2154
2155 #[test]
2156 fn parse_env_var_value_with_equals() {
2157 let ev = parse_env_var("PATH=/usr/bin:/bin").unwrap();
2159 assert_eq!(ev.name, "PATH");
2160 assert_eq!(ev.value, "/usr/bin:/bin");
2161
2162 let ev2 = parse_env_var("FOO=a=b=c").unwrap();
2163 assert_eq!(ev2.name, "FOO");
2164 assert_eq!(ev2.value, "a=b=c");
2165 }
2166
2167 #[test]
2168 fn parse_env_var_empty_value() {
2169 let ev = parse_env_var("EMPTY=").unwrap();
2170 assert_eq!(ev.name, "EMPTY");
2171 assert_eq!(ev.value, "");
2172 }
2173
2174 #[test]
2175 fn parse_env_var_no_equals() {
2176 let err = parse_env_var("NOEQUALS").unwrap_err();
2177 assert!(
2178 err.contains("KEY=VALUE"),
2179 "should tell user the expected format, got: {err}"
2180 );
2181 }
2182
2183 #[test]
2184 fn parse_alias_validates_name() {
2185 let a = parse_alias("valid=ls -la").unwrap();
2186 assert_eq!(a.name, "valid");
2187 assert_eq!(a.command, "ls -la");
2188
2189 let a2 = parse_alias("my-alias=git status").unwrap();
2190 assert_eq!(a2.name, "my-alias");
2191 assert_eq!(a2.command, "git status");
2192
2193 assert!(parse_alias("bad;name=cmd").is_err());
2194 }
2195
2196 #[test]
2197 fn parse_alias_command_with_equals() {
2198 let a = parse_alias("env=FOO=bar baz").unwrap();
2200 assert_eq!(a.name, "env");
2201 assert_eq!(a.command, "FOO=bar baz");
2202 }
2203}