Skip to main content

cfgd_core/
lib.rs

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
20// ---------------------------------------------------------------------------
21// Shared utilities — used by multiple modules within cfgd-core and downstream
22// ---------------------------------------------------------------------------
23
24/// The canonical API version string used in all cfgd YAML documents (local and CRD).
25pub 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
29/// Returns the current UTC time as an ISO 8601 / RFC 3339 string.
30pub 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
38/// Returns the current time as seconds since the Unix epoch.
39pub 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
46/// Converts a Unix timestamp (seconds since epoch) to an ISO 8601 UTC string.
47pub 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    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
61    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
74/// Deep merge two YAML values. Mappings are merged recursively; all other
75/// types are replaced by the overlay value.
76pub 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
93/// Extend a `Vec<String>` with items from `source`, skipping duplicates.
94pub 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
103/// Prepare a `git` CLI command with SSH hang protection.
104///
105/// Sets `GIT_TERMINAL_PROMPT=0` to prevent interactive prompts and, for SSH URLs,
106/// sets `GIT_SSH_COMMAND` with `BatchMode=yes` and configurable `StrictHostKeyChecking`
107/// to prevent hangs in non-interactive contexts (piped install scripts, daemons).
108///
109/// The `ssh_policy` parameter controls the `StrictHostKeyChecking` value:
110/// - `None` uses the default (`accept-new`)
111/// - `Some(policy)` uses the specified policy
112pub 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
133/// Try a git CLI command via [`git_cmd_safe`], returning `true` on success.
134/// On failure, logs the stderr via `tracing::debug` and returns `false`.
135pub 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
161/// Default timeout for external commands (2 minutes).
162pub const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
163
164/// Default timeout for git network operations (5 minutes).
165pub const GIT_NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
166
167/// Run a [`Command`] with a timeout, killing the process if it exceeds the limit.
168/// Returns `Err` if spawn fails or the process is killed due to timeout.
169pub 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    // Spawn a watchdog thread that kills the child after timeout
180    std::thread::spawn(move || {
181        if rx.recv_timeout(timeout).is_err() {
182            // Timeout expired — kill the process
183            terminate_process(id);
184        }
185    });
186
187    let result = child.wait_with_output();
188    // Signal the watchdog to stop (if the process finished before timeout)
189    let _ = tx.send(());
190    result
191}
192
193/// Default config directory: `~/.config/cfgd` on Unix (respects XDG_CONFIG_HOME),
194/// `AppData\Roaming\cfgd` on Windows.
195pub 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
211/// Expand `~` and `~/...` paths to the user's home directory.
212pub 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/// Resolve the user's home directory from environment variables.
227/// Unix: checks HOME.
228/// Windows: checks USERPROFILE first, then HOME (for WSL/Git Bash contexts).
229#[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
241/// Get the system hostname as a String. Returns "unknown" on failure.
242pub 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
248/// Resolve a relative path against a base directory with traversal validation.
249/// Absolute paths are returned as-is. Relative paths are joined to `base` and
250/// validated with `validate_no_traversal`. Returns `Err` if the relative path
251/// contains `..` components.
252pub 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
265/// Create a symbolic link. On Unix, uses `std::os::unix::fs::symlink`.
266/// On Windows, uses `symlink_file` or `symlink_dir` based on the source type.
267/// If symlink creation fails on Windows due to insufficient privileges,
268/// returns an error with guidance to enable Developer Mode or run as admin.
269pub 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                // ERROR_PRIVILEGE_NOT_HELD
279                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/// Get Unix permission mode bits from file metadata. Returns None on Windows.
309#[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/// Set Unix permission mode bits on a file. No-op on Windows (NTFS uses inherited ACLs).
321#[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/// Check if a file is executable.
334/// Unix: checks the executable bit in mode.
335/// Windows: checks file extension against known executable types.
336#[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/// Check if two paths refer to the same file (same inode on Unix, same file index on Windows).
352#[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/// Send a termination signal to a process by PID.
385/// Unix: sends SIGTERM. Windows: calls TerminateProcess.
386#[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/// Check if the current process is running with elevated privileges.
407/// Unix: checks euid == 0. Windows: checks IsUserAnAdmin().
408#[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
420/// Parse a potentially loose version string into a semver Version.
421/// Handles "1.28" → "1.28.0" and "1" → "1.0.0".
422pub 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
439/// Check whether `version_str` satisfies `requirement_str` (semver range).
440pub 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
450/// Git credential callback for git2 — handles SSH and HTTPS authentication.
451/// Used by sources/, modules/, and daemon/ for all git operations.
452///
453/// Tries in order:
454/// 1. SSH agent (for SSH URLs)
455/// 2. SSH key files: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa` (for SSH URLs)
456/// 3. Git credential helper / GIT_ASKPASS (for HTTPS URLs)
457/// 4. Default system credentials
458pub 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
496/// Recursively copy a directory from source to target.
497/// Skips symlinks to prevent symlink-following attacks and infinite loops.
498pub 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        // Skip symlinks — prevents following links outside the source tree
507        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
520/// Check if a command is available on the system via PATH lookup.
521/// On Windows, tries common executable extensions (.exe, .cmd, .bat, .ps1, .com)
522/// since executables require an extension to be found.
523pub 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
545/// Merge env vars by name: later entries override earlier ones with the same name.
546/// Used by config layer merging, composition, and reconciler module merge.
547pub 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
563/// Parse a `KEY=VALUE` string into an `EnvVar`.
564pub 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
575/// Validate that an environment variable name is safe for shell interpolation.
576/// Accepts names matching `[A-Za-z_][A-Za-z0-9_]*`.
577pub 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
597/// Validate that a shell alias name is safe for shell interpolation.
598/// Accepts names matching `[A-Za-z0-9_.-]+`.
599pub 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
615/// Merge shell aliases by name: later entries override earlier ones with the same name.
616/// Same semantics as `merge_env`.
617pub 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
633/// Split a list of values into adds and removes.
634///
635/// Values starting with `-` are treated as removals (the leading `-` is stripped).
636/// All other values are adds. This powers the unified `--thing` CLI flags where
637/// `--thing foo` adds and `--thing -foo` removes.
638pub 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
651/// Parse a `name=command` string into a `ShellAlias`.
652pub 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
663// ---------------------------------------------------------------------------
664// File safety primitives — atomic writes, state capture, path validation
665// ---------------------------------------------------------------------------
666
667/// Maximum file size (10 MB) for backup content capture.
668/// Files larger than this are tracked but their content is not stored in backups.
669const MAX_BACKUP_FILE_SIZE: u64 = 10 * 1024 * 1024;
670
671/// Captured state of a file for backup purposes.
672#[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    /// True if the file exceeded MAX_BACKUP_FILE_SIZE and content was not captured.
680    pub oversized: bool,
681}
682
683/// Compute SHA256 hash of data and return as lowercase hex string.
684use sha2::Digest as _;
685
686pub fn sha256_hex(data: &[u8]) -> String {
687    format!("{:x}", sha2::Sha256::digest(data))
688}
689
690/// Extract stdout from a `Command` output as a trimmed, lossy UTF-8 string.
691pub fn stdout_lossy_trimmed(output: &std::process::Output) -> String {
692    String::from_utf8_lossy(&output.stdout).trim().to_string()
693}
694
695/// Extract stderr from a `Command` output as a trimmed, lossy UTF-8 string.
696pub fn stderr_lossy_trimmed(output: &std::process::Output) -> String {
697    String::from_utf8_lossy(&output.stderr).trim().to_string()
698}
699
700/// Atomically write content to a file using temp-file-then-rename.
701///
702/// The temp file is created in the same directory as `target` to guarantee a
703/// same-filesystem rename (atomic on POSIX). Preserves the permissions of an
704/// existing target file if one exists. Creates parent directories as needed.
705///
706/// Returns the SHA256 hex digest of the written content.
707pub 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    // Preserve permissions of existing file if present
721    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    // persist() does atomic rename on Unix
728    tmp.persist(target).map_err(|e| e.error)?;
729
730    Ok(hash)
731}
732
733/// Atomically write string content to a file.
734pub 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
741/// Capture a file's content and metadata for backup.
742///
743/// Uses `symlink_metadata()` — never follows symlinks. For symlinks, captures
744/// the link target path but not the content. For regular files >10 MB, sets
745/// `oversized: true` and does not capture content.
746///
747/// Returns `None` if the file does not exist.
748pub 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
795/// Like `capture_file_state`, but follows symlinks to capture the resolved
796/// content. For symlinks, `is_symlink` and `symlink_target` are recorded AND
797/// the actual file content behind the symlink is read. This is used for
798/// post-apply snapshots where we need to know both the link target and the
799/// content that was accessible through the symlink at the time of capture.
800///
801/// Returns `None` if the file does not exist (or the symlink is dangling).
802pub 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    // Read the actual content (following symlinks)
819    let real_meta = match std::fs::metadata(path) {
820        Ok(m) => m,
821        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
822            // Dangling symlink
823            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
854/// Validate that a resolved path does not escape a root directory.
855///
856/// Canonicalizes both paths and checks containment. Returns the canonicalized
857/// path on success.
858pub 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
877/// Validate that a path contains no `..` components (pre-canonicalization check).
878///
879/// This catches traversal attempts even when intermediate directories don't
880/// exist yet, which `canonicalize()` cannot handle.
881pub 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
890/// Escape a value for use in shell `export` statements.
891///
892/// Sanitize a string for use as a Kubernetes object name (RFC 1123 DNS label).
893/// Lowercases, replaces underscores with hyphens, filters non-alphanumeric chars,
894/// and trims leading/trailing hyphens.
895pub 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
905/// Uses single quotes for values containing shell metacharacters (`$`, backtick,
906/// `\`, `"`). Single quotes within the value are escaped via `'\''`.
907/// Single-pass scan: returns double-quoted string when no metacharacters are present
908/// (zero intermediate allocations in the common case).
909pub 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    // Single-quote strategy: only `'` needs escaping inside single quotes
917    if !value.contains('\'') {
918        return format!("'{}'", value);
919    }
920    // Value contains both metacharacters and single quotes — break-out escaping
921    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
934/// Escape a value for use inside bash/zsh double quotes (single pass).
935/// Escapes `\`, `"`, `` ` ``, and `!` — the four characters with special
936/// meaning inside double-quoted strings.
937pub 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
951/// Escape a string for safe inclusion in XML/plist content (single pass).
952pub 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("&amp;"),
957            '<' => out.push_str("&lt;"),
958            '>' => out.push_str("&gt;"),
959            '"' => out.push_str("&quot;"),
960            '\'' => out.push_str("&apos;"),
961            _ => out.push(c),
962        }
963    }
964    out
965}
966
967/// Acquire an exclusive apply lock via `flock()`.
968///
969/// The lock file is created at `state_dir/apply.lock`. Uses non-blocking
970/// `LOCK_EX | LOCK_NB` — returns `StateError::ApplyLockHeld` if another
971/// process holds the lock. The lock is released automatically when the guard
972/// is dropped.
973/// Platform-specific lock file type.
974/// Unix: `nix::fcntl::Flock` (safe RAII flock, unlocks on drop).
975/// Windows: plain `File` (LockFileEx releases on handle close).
976#[cfg(unix)]
977type LockFile = nix::fcntl::Flock<std::fs::File>;
978#[cfg(windows)]
979type LockFile = std::fs::File;
980
981/// RAII guard that releases the apply lock when dropped.
982#[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        // Clear the PID so stale reads aren't confusing.
991        // Lock is released when LockFile is dropped.
992        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    // Write our PID to the lock file
1023    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/// Acquire an exclusive apply lock via `LockFileEx`.
1034///
1035/// The lock file is created at `state_dir/apply.lock`. Uses
1036/// `LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY` — returns
1037/// `StateError::ApplyLockHeld` if another process holds the lock. The lock is
1038/// released automatically when the guard is dropped (file handle closed).
1039#[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        // ERROR_LOCK_VIOLATION (33) = lock held by another process
1072        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// ---------------------------------------------------------------------------
1094// Reconcile patch resolution
1095// ---------------------------------------------------------------------------
1096
1097/// Fully resolved reconcile settings for a single entity (no Options).
1098#[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
1105/// Resolve effective reconcile settings for a module given the profile
1106/// inheritance chain and any patches in the global reconcile config.
1107///
1108/// Precedence (most specific wins):
1109///   Named Module patch > Kind-wide Module patch > Named Profile patch >
1110///   Kind-wide Profile patch > Global reconcile settings
1111///
1112/// `profile_chain` is ancestors-first, leaf-last (e.g., `["base", "work"]`).
1113/// Within each level, patches apply in list order (last wins for duplicates).
1114pub 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    // 1. Kind-wide Profile patch (no name = applies to all profiles)
1126    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    // 2. Named Profile patches in inheritance order (leaf last = leaf wins)
1136    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    // 3. Kind-wide Module patch (no name = applies to all modules)
1145    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    // 4. Named Module patch (highest priority) — last matching entry wins
1155    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
1164/// Overlay a patch's `Some` fields onto an effective reconcile struct.
1165fn 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
1177// ---------------------------------------------------------------------------
1178// Duration parsing
1179// ---------------------------------------------------------------------------
1180
1181/// Parse a duration string like "30s", "5m", "1h", or a plain number (as seconds).
1182///
1183/// Returns an error description on invalid input.
1184pub 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
1201/// Default timeout for profile-level scripts (5 minutes).
1202pub const PROFILE_SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
1203
1204/// Check if a file is encrypted with the given backend.
1205///
1206/// - `sops`: parses YAML/JSON and checks for a top-level `sops` key with `mac` and `lastmodified`.
1207/// - `age`: checks if the file starts with the `age-encryption.org` header (reads as bytes to handle binary).
1208/// - Unknown backend: returns `FileError::UnknownEncryptionBackend`.
1209pub 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            // Try YAML first.  SOPS injects a top-level `sops` map with `mac` + `lastmodified`.
1221            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            // Try JSON (SOPS can encrypt JSON files too).
1231            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        // semver::Version::parse handles pre-release tags
1374        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); // SHA256 hex
1422    }
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        // Use two independent tempdirs so the target exists on every platform
1573        // (/tmp is absent on Windows) and lives outside our designated root.
1574        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>&\"'"), "&lt;tag&gt;&amp;&quot;&apos;");
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)] // Windows LockFileEx prevents reading lock file content while held
1624    fn acquire_apply_lock_works() {
1625        let dir = tempfile::tempdir().unwrap();
1626        let guard = acquire_apply_lock(dir.path()).unwrap();
1627        // Lock file should contain our PID
1628        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        // Second acquire should fail with ApplyLockHeld
1638        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        // Contains both single-quote AND $, so must use break-out escaping
1816        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&amp;b&lt;c&gt;d&quot;e&apos;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    // --- Reconcile patch resolution tests ---
1896
1897    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); // inherited from global
1929        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"); // global
1943        assert!(eff.auto_apply); // from profile patch
1944    }
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); // module wins over profile
1966    }
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        // work is the leaf (last in chain) → wins
1987        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"); // from profile patch
2011        assert_eq!(eff.drift_policy, config::DriftPolicy::Auto); // from module patch
2012        assert!(!eff.auto_apply); // from global
2013    }
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        // Asking for a different module — patch doesn't apply
2025        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"); // last entry wins
2049    }
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, // applies to all modules
2056            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, // all modules
2070                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()), // specific
2077                interval: Some("5s".into()),
2078                auto_apply: None,
2079                drift_policy: None,
2080            },
2081        ]);
2082        // Named patch wins over kind-wide
2083        let eff = resolve_effective_reconcile("certs", &["default"], &cfg);
2084        assert_eq!(eff.interval, "5s");
2085        // Other modules get kind-wide
2086        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        // All of these should fail with the "invalid characters" message
2110        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        // Values can contain '=' — only the first '=' splits key from value
2158        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        // Command can contain '=' — only the first splits name from command
2199        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}