30.1 KiB · text History d69022d
//! Flat-file desired-state store.
//!
//! The source of truth is a directory of per-host TOML files under
//! `<config_dir>/hosts/<host_id>.toml`. Each describes the files to install and
//! services to manage in an operator-friendly shape (octal mode strings, inline
//! or file-referenced content), which [`HostConfig::compile`] lowers into a
//! [`Manifest`] plus the file bodies to publish.
//!
//! The web UI edits these TOML files directly (see [`read_raw`]/[`write_raw`]),
//! keeping the on-disk files authoritative and git-friendly.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use devendra_proto::{
    DirResource, FileResource, FileState, Manifest, PROTOCOL_VERSION, PackageResource,
    ServiceResource, ServiceState, content_hash,
};

/// One host's desired state, as authored on disk.
///
/// A host file may pull in shared fragments via [`HostConfig::include`], letting
/// common quadlets/services be defined once and reused across hosts. The root
/// file's own entries take precedence over included ones (see
/// [`HostConfig::resolve`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
    /// Paths (relative to `config_dir`) of fragment TOML files to merge in
    /// before this file's own entries. Fragments have the same shape and may
    /// themselves include others.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub include: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub packages: Vec<PackageEntry>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub directories: Vec<DirEntry>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<FileEntry>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub services: Vec<ServiceEntry>,
}

/// A system package to install or remove on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageEntry {
    pub name: String,
    /// `"present"` (default) or `"absent"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<String>,
}

/// A directory to ensure exists on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirEntry {
    /// Absolute path on the target host.
    pub path: String,
    /// Octal permission string, e.g. `"0755"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    /// `"present"` (default) or `"absent"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<String>,
}

/// A config file to install on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
    /// Absolute destination path on the target host.
    pub path: String,
    /// Inline file content. Mutually exclusive with `source`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    /// Path (relative to `config_dir`) to a file whose bytes are the content.
    /// Mutually exclusive with `content`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// Octal permission string, e.g. `"0644"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    /// `"present"` (default) or `"absent"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<String>,
}

/// A service to reconcile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceEntry {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    /// `"started"`, `"stopped"`, `"restarted"`, or `"reloaded"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<String>,
}

/// A compiled host: the manifest to publish plus the file bodies it references,
/// keyed by destination path.
pub struct Compiled {
    pub manifest: Manifest,
    pub bodies: HashMap<String, Vec<u8>>,
}

impl HostConfig {
    /// Resolve `include`s into a single flattened config with no remaining
    /// includes. Fragments are merged depth-first in listed order, then this
    /// file's own entries are applied last so the root always wins. Entries are
    /// keyed by file `path` / service `name`: a later definition replaces an
    /// earlier one in place. Diamond includes are merged once; cycles error.
    ///
    /// Include paths are relative to `config_dir`.
    pub fn resolve(&self, config_dir: &Path) -> Result<HostConfig> {
        let mut merged = HostConfig::default();
        let mut visiting = HashSet::new();
        let mut done = HashSet::new();
        self.merge_into(config_dir, &mut merged, &mut visiting, &mut done)?;
        Ok(merged)
    }

    fn merge_into(
        &self,
        config_dir: &Path,
        merged: &mut HostConfig,
        visiting: &mut HashSet<PathBuf>,
        done: &mut HashSet<PathBuf>,
    ) -> Result<()> {
        for inc in &self.include {
            let path = config_dir.join(inc);
            let canon =
                std::fs::canonicalize(&path).with_context(|| format!("resolving include {inc}"))?;
            if done.contains(&canon) {
                continue; // already merged via another path (diamond)
            }
            if !visiting.insert(canon.clone()) {
                bail!("include cycle detected at {inc}");
            }
            let text =
                std::fs::read_to_string(&path).with_context(|| format!("reading include {inc}"))?;
            let fragment: HostConfig =
                toml::from_str(&text).with_context(|| format!("parsing include {inc}"))?;
            fragment.merge_into(config_dir, merged, visiting, done)?;
            visiting.remove(&canon);
            done.insert(canon);
        }

        // This file's own entries override anything pulled in from includes.
        for p in &self.packages {
            match merged.packages.iter_mut().find(|e| e.name == p.name) {
                Some(existing) => *existing = p.clone(),
                None => merged.packages.push(p.clone()),
            }
        }
        for d in &self.directories {
            match merged.directories.iter_mut().find(|e| e.path == d.path) {
                Some(existing) => *existing = d.clone(),
                None => merged.directories.push(d.clone()),
            }
        }
        for f in &self.files {
            match merged.files.iter_mut().find(|e| e.path == f.path) {
                Some(existing) => *existing = f.clone(),
                None => merged.files.push(f.clone()),
            }
        }
        for s in &self.services {
            match merged.services.iter_mut().find(|e| e.name == s.name) {
                Some(existing) => *existing = s.clone(),
                None => merged.services.push(s.clone()),
            }
        }
        Ok(())
    }

    /// Lower this config into a [`Manifest`] (at generation 0 — the publisher
    /// assigns the real generation) and resolve all file bodies.
    ///
    /// `config_dir` roots relative `source` paths. Call [`Self::resolve`] first
    /// if the config may contain `include`s.
    pub fn compile(&self, config_dir: &Path) -> Result<Compiled> {
        let mut files = Vec::with_capacity(self.files.len());
        let mut bodies = HashMap::new();

        for f in &self.files {
            let state = parse_file_state(f.state.as_deref())?;
            let mode = match &f.mode {
                Some(m) => {
                    Some(parse_mode(m).with_context(|| format!("mode {m:?} for {}", f.path))?)
                }
                None => None,
            };

            let content_blake3 = if state == FileState::Present {
                let body = resolve_body(f, config_dir)
                    .with_context(|| format!("resolving content for {}", f.path))?;
                let hash = content_hash(&body);
                bodies.insert(f.path.clone(), body);
                Some(hash)
            } else {
                None
            };

            files.push(FileResource {
                path: f.path.clone(),
                state,
                content_blake3,
                mode,
                owner: f.owner.clone(),
                group: f.group.clone(),
            });
        }

        let mut packages = Vec::with_capacity(self.packages.len());
        for p in &self.packages {
            packages.push(PackageResource {
                name: p.name.clone(),
                state: parse_file_state(p.state.as_deref())
                    .with_context(|| format!("state for package {}", p.name))?,
            });
        }

        let mut directories = Vec::with_capacity(self.directories.len());
        for d in &self.directories {
            let mode = match &d.mode {
                Some(m) => {
                    Some(parse_mode(m).with_context(|| format!("mode {m:?} for dir {}", d.path))?)
                }
                None => None,
            };
            directories.push(DirResource {
                path: d.path.clone(),
                state: parse_file_state(d.state.as_deref())
                    .with_context(|| format!("state for dir {}", d.path))?,
                mode,
                owner: d.owner.clone(),
                group: d.group.clone(),
            });
        }

        let mut services = Vec::with_capacity(self.services.len());
        for s in &self.services {
            services.push(ServiceResource {
                name: s.name.clone(),
                enabled: s.enabled,
                state: parse_service_state(s.state.as_deref())
                    .with_context(|| format!("state for service {}", s.name))?,
            });
        }

        Ok(Compiled {
            manifest: Manifest {
                protocol_version: PROTOCOL_VERSION,
                generation: 0,
                packages,
                directories,
                files,
                services,
            },
            bodies,
        })
    }
}

fn resolve_body(f: &FileEntry, config_dir: &Path) -> Result<Vec<u8>> {
    match (&f.content, &f.source) {
        (Some(_), Some(_)) => bail!("file {} sets both `content` and `source`", f.path),
        (Some(c), None) => Ok(c.clone().into_bytes()),
        (None, Some(src)) => {
            let path = config_dir.join(src);
            std::fs::read(&path).with_context(|| format!("reading source {}", path.display()))
        }
        (None, None) => bail!(
            "file {} is present but has neither `content` nor `source`",
            f.path
        ),
    }
}

fn parse_mode(s: &str) -> Result<u32> {
    let trimmed = s.trim().trim_start_matches("0o");
    u32::from_str_radix(trimmed, 8).with_context(|| format!("invalid octal mode {s:?}"))
}

fn parse_file_state(s: Option<&str>) -> Result<FileState> {
    match s {
        None | Some("present") => Ok(FileState::Present),
        Some("absent") => Ok(FileState::Absent),
        Some(other) => bail!("invalid file state {other:?} (want present|absent)"),
    }
}

fn parse_service_state(s: Option<&str>) -> Result<Option<ServiceState>> {
    Ok(match s {
        None => None,
        Some("started") => Some(ServiceState::Started),
        Some("stopped") => Some(ServiceState::Stopped),
        Some("restarted") => Some(ServiceState::Restarted),
        Some("reloaded") => Some(ServiceState::Reloaded),
        Some(other) => bail!("invalid service state {other:?}"),
    })
}

/// A loaded host config paired with its id and any load error.
pub struct LoadedHost {
    pub id: String,
    pub config: HostConfig,
}

/// List host ids present in the hosts directory (files named `<id>.toml`),
/// sorted. Missing directory yields an empty list.
pub fn list_host_ids(hosts_dir: &Path) -> Result<Vec<String>> {
    let mut ids = Vec::new();
    let entries = match std::fs::read_dir(hosts_dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
        Err(e) => return Err(e).context("reading hosts dir"),
    };
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("toml")
            && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
        {
            ids.push(stem.to_string());
        }
    }
    ids.sort();
    Ok(ids)
}

/// Load and parse a single host's config.
pub fn load_host(hosts_dir: &Path, id: &str) -> Result<HostConfig> {
    let raw = read_raw(hosts_dir, id)?;
    toml::from_str(&raw).with_context(|| format!("parsing {id}.toml"))
}

/// Load every host config. Parse errors abort the whole load so a broken file
/// can't silently drop a host from a publish.
pub fn load_all(hosts_dir: &Path) -> Result<Vec<LoadedHost>> {
    list_host_ids(hosts_dir)?
        .into_iter()
        .map(|id| {
            let config = load_host(hosts_dir, &id)?;
            Ok(LoadedHost { id, config })
        })
        .collect()
}

fn host_path(hosts_dir: &Path, id: &str) -> Result<PathBuf> {
    validate_host_id(id)?;
    Ok(hosts_dir.join(format!("{id}.toml")))
}

/// Read a host's raw TOML text (for the editor).
pub fn read_raw(hosts_dir: &Path, id: &str) -> Result<String> {
    let path = host_path(hosts_dir, id)?;
    std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}

/// Write a host's raw TOML text, after validating it parses. Creates the hosts
/// directory if needed.
pub fn write_raw(hosts_dir: &Path, id: &str, toml_text: &str) -> Result<()> {
    // Reject invalid TOML before touching disk so the editor round-trips safely.
    toml::from_str::<HostConfig>(toml_text).context("config does not parse as valid host TOML")?;
    let path = host_path(hosts_dir, id)?;
    std::fs::create_dir_all(hosts_dir).context("creating hosts dir")?;
    std::fs::write(&path, toml_text).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// Write an adopted file body into the config tree as a reusable source file
/// under `<config_dir>/files/<id>/<rel>`, returning the `source` path (relative
/// to `config_dir`) to record in the host TOML. Rejects path traversal in `rel`.
pub fn write_source_file(config_dir: &Path, id: &str, rel: &str, bytes: &[u8]) -> Result<String> {
    validate_host_id(id)?;
    let rel = rel.trim_start_matches('/');
    if rel.is_empty() || rel.split('/').any(|c| c == ".." || c.is_empty()) {
        bail!("unsafe source path {rel:?}");
    }
    let source = format!("files/{id}/{rel}");
    let dest = config_dir.join(&source);
    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating {}", parent.display()))?;
    }
    std::fs::write(&dest, bytes).with_context(|| format!("writing {}", dest.display()))?;
    Ok(source)
}

/// Merge `new_files` into host `id`'s config, creating the host if it doesn't
/// exist yet. Existing file entries with the same `path` are replaced; others
/// are kept. The result is written back as TOML.
pub fn upsert_files(hosts_dir: &Path, id: &str, new_files: Vec<FileEntry>) -> Result<()> {
    let mut config = match read_raw(hosts_dir, id) {
        Ok(raw) => {
            toml::from_str::<HostConfig>(&raw).with_context(|| format!("parsing {id}.toml"))?
        }
        Err(_) => HostConfig::default(), // new host
    };
    for nf in new_files {
        if let Some(existing) = config.files.iter_mut().find(|f| f.path == nf.path) {
            *existing = nf;
        } else {
            config.files.push(nf);
        }
    }
    let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
    write_raw(hosts_dir, id, &toml_text)
}

// ---- fragments -------------------------------------------------------------
//
// Fragments are reusable `HostConfig`-shaped TOML files under
// `<config_dir>/include/`, referenced from a host's `include = [...]`. They are
// flat (no subdirectories) so a fragment id is a single safe path segment.

/// The `include`-path form used to reference fragment `id` from a host file.
pub fn fragment_include_ref(id: &str) -> String {
    format!("include/{id}.toml")
}

/// The fragment id an `include`-path refers to, if it's a well-formed reference
/// to `include/<id>.toml`.
pub fn fragment_id_from_ref(reference: &str) -> Option<&str> {
    reference.strip_prefix("include/")?.strip_suffix(".toml")
}

/// Append fragment `fragment_id` to host `id`'s `include` list (no-op if already
/// present) and write the config back.
pub fn add_include(hosts_dir: &Path, id: &str, fragment_id: &str) -> Result<()> {
    validate_fragment_id(fragment_id)?;
    let mut config = load_host(hosts_dir, id)?;
    let reference = fragment_include_ref(fragment_id);
    if !config.include.contains(&reference) {
        config.include.push(reference);
    }
    let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
    write_raw(hosts_dir, id, &toml_text)
}

/// Remove an `include` reference from host `id`'s config and write it back.
pub fn remove_include(hosts_dir: &Path, id: &str, reference: &str) -> Result<()> {
    let mut config = load_host(hosts_dir, id)?;
    config.include.retain(|i| i != reference);
    let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
    write_raw(hosts_dir, id, &toml_text)
}

/// List fragment ids (files named `<id>.toml` directly under `include/`), sorted.
pub fn list_fragment_ids(include_dir: &Path) -> Result<Vec<String>> {
    let mut ids = Vec::new();
    let entries = match std::fs::read_dir(include_dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
        Err(e) => return Err(e).context("reading include dir"),
    };
    for entry in entries {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("toml")
            && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
        {
            ids.push(stem.to_string());
        }
    }
    ids.sort();
    Ok(ids)
}

fn fragment_path(include_dir: &Path, id: &str) -> Result<PathBuf> {
    validate_fragment_id(id)?;
    Ok(include_dir.join(format!("{id}.toml")))
}

/// Read a fragment's raw TOML text.
pub fn read_fragment_raw(include_dir: &Path, id: &str) -> Result<String> {
    let path = fragment_path(include_dir, id)?;
    std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}

/// Write a fragment's raw TOML, after validating it parses as a config.
pub fn write_fragment_raw(include_dir: &Path, id: &str, toml_text: &str) -> Result<()> {
    toml::from_str::<HostConfig>(toml_text)
        .context("fragment does not parse as valid config TOML")?;
    let path = fragment_path(include_dir, id)?;
    std::fs::create_dir_all(include_dir).context("creating include dir")?;
    std::fs::write(&path, toml_text).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// Host ids whose config directly `include`s fragment `id`. Best-effort: broken
/// host files are skipped rather than failing the lookup.
pub fn hosts_using_fragment(hosts_dir: &Path, fragment_id: &str) -> Result<Vec<String>> {
    let want = fragment_include_ref(fragment_id);
    let mut users = Vec::new();
    for id in list_host_ids(hosts_dir)? {
        if let Ok(cfg) = load_host(hosts_dir, &id)
            && cfg.include.iter().any(|i| i == &want)
        {
            users.push(id);
        }
    }
    Ok(users)
}

/// A fragment id must be a safe single path segment (same rules as a host id).
pub fn validate_fragment_id(id: &str) -> Result<()> {
    validate_host_id(id).map_err(|_| {
        anyhow::anyhow!("invalid fragment id {id:?}: use [A-Za-z0-9._-], not starting with '.'")
    })
}

/// A host id must be a safe single path segment so it can key both a filename
/// and a document keyspace.
pub fn validate_host_id(id: &str) -> Result<()> {
    if id.is_empty()
        || id.len() > 64
        || !id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
        || id.starts_with('.')
    {
        bail!("invalid host id {id:?}: use [A-Za-z0-9._-], not starting with '.'");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compiles_inline_and_service() {
        let cfg: HostConfig = toml::from_str(
            r#"
            [[files]]
            path = "/etc/containers/systemd/web.container"
            content = "[Container]\nImage=nginx\n"
            mode = "0644"

            [[services]]
            name = "web.service"
            enabled = true
            state = "started"
            "#,
        )
        .unwrap();

        let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
        assert_eq!(compiled.manifest.files.len(), 1);
        let file = &compiled.manifest.files[0];
        assert_eq!(file.mode, Some(0o644));
        assert_eq!(file.state, FileState::Present);
        // Body is present and its hash matches the manifest.
        let body = &compiled.bodies[&file.path];
        assert_eq!(
            file.content_blake3.as_deref(),
            Some(content_hash(body).as_str())
        );

        let svc = &compiled.manifest.services[0];
        assert_eq!(svc.enabled, Some(true));
        assert_eq!(svc.state, Some(ServiceState::Started));
        assert!(compiled.manifest.requires_daemon_reload());
    }

    #[test]
    fn compiles_packages() {
        let cfg: HostConfig = toml::from_str(
            r#"
            [[packages]]
            name = "mosquitto"

            [[packages]]
            name = "telnet"
            state = "absent"
            "#,
        )
        .unwrap();
        let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
        assert_eq!(compiled.manifest.packages.len(), 2);
        assert_eq!(compiled.manifest.packages[0].name, "mosquitto");
        assert_eq!(compiled.manifest.packages[0].state, FileState::Present);
        assert_eq!(compiled.manifest.packages[1].state, FileState::Absent);
        assert!(compiled.bodies.is_empty());
    }

    #[test]
    fn compiles_directories() {
        let cfg: HostConfig = toml::from_str(
            r#"
            [[directories]]
            path = "/mnt/hass"
            mode = "0750"
            owner = "hass"
            "#,
        )
        .unwrap();
        let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
        assert_eq!(compiled.manifest.directories.len(), 1);
        let d = &compiled.manifest.directories[0];
        assert_eq!(d.path, "/mnt/hass");
        assert_eq!(d.mode, Some(0o750));
        assert_eq!(d.owner.as_deref(), Some("hass"));
        assert_eq!(d.state, devendra_proto::FileState::Present);
        // Directories need no file bodies.
        assert!(compiled.bodies.is_empty());
    }

    #[test]
    fn absent_file_needs_no_body() {
        let cfg: HostConfig = toml::from_str(
            r#"
            [[files]]
            path = "/etc/old.conf"
            state = "absent"
            "#,
        )
        .unwrap();
        let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
        assert_eq!(compiled.manifest.files[0].state, FileState::Absent);
        assert!(compiled.bodies.is_empty());
    }

    #[test]
    fn rejects_bad_host_ids() {
        assert!(validate_host_id("web-01").is_ok());
        assert!(validate_host_id("a.b_c").is_ok());
        assert!(validate_host_id("../etc").is_err());
        assert!(validate_host_id("has/slash").is_err());
        assert!(validate_host_id(".hidden").is_err());
        assert!(validate_host_id("").is_err());
    }

    #[test]
    fn present_file_without_content_is_error() {
        let cfg: HostConfig = toml::from_str(
            r#"
            [[files]]
            path = "/etc/x.conf"
            "#,
        )
        .unwrap();
        assert!(cfg.compile(Path::new("/nonexistent")).is_err());
    }

    // ---- includes ----------------------------------------------------------

    /// Build a temporary config dir with the given `name -> contents` files.
    fn temp_config(files: &[(&str, &str)]) -> PathBuf {
        // Deterministic unique dir without external deps or a clock: hash inputs.
        let mut hasher = blake3::Hasher::new();
        for (n, c) in files {
            hasher.update(n.as_bytes());
            hasher.update(c.as_bytes());
        }
        let dir =
            std::env::temp_dir().join(format!("devendra-test-{}", hasher.finalize().to_hex()));
        let _ = std::fs::remove_dir_all(&dir);
        for (name, contents) in files {
            let path = dir.join(name);
            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
            std::fs::write(&path, contents).unwrap();
        }
        dir
    }

    #[test]
    fn include_merges_fragments_with_root_overriding() {
        let dir = temp_config(&[
            (
                "include/base.toml",
                r#"
                [[files]]
                path = "/etc/shared.conf"
                content = "from base"
                mode = "0644"

                [[services]]
                name = "shared.service"
                state = "started"
                "#,
            ),
            (
                "hosts/web.toml",
                r#"
                include = ["include/base.toml"]

                # Override the shared file's content; add a host-specific one.
                [[files]]
                path = "/etc/shared.conf"
                content = "from web"
                mode = "0600"

                [[files]]
                path = "/etc/web.conf"
                content = "web only"
                "#,
            ),
        ]);

        let root = load_host(&dir.join("hosts"), "web").unwrap();
        let merged = root.resolve(&dir).unwrap();

        // shared.conf comes from base's position but with web's values (root wins).
        assert_eq!(merged.files.len(), 2);
        assert_eq!(merged.files[0].path, "/etc/shared.conf");
        assert_eq!(merged.files[0].content.as_deref(), Some("from web"));
        assert_eq!(merged.files[0].mode.as_deref(), Some("0600"));
        assert_eq!(merged.files[1].path, "/etc/web.conf");
        // The service from the include survives.
        assert_eq!(merged.services.len(), 1);
        assert_eq!(merged.services[0].name, "shared.service");

        // And it compiles cleanly end to end.
        let compiled = merged.compile(&dir).unwrap();
        assert_eq!(compiled.bodies["/etc/shared.conf"], b"from web");
    }

    #[test]
    fn include_cycle_is_rejected() {
        let dir = temp_config(&[
            ("include/a.toml", "include = [\"include/b.toml\"]\n"),
            ("include/b.toml", "include = [\"include/a.toml\"]\n"),
            ("hosts/h.toml", "include = [\"include/a.toml\"]\n"),
        ]);
        let root = load_host(&dir.join("hosts"), "h").unwrap();
        let err = root.resolve(&dir).unwrap_err();
        assert!(format!("{err:#}").contains("cycle"), "{err:#}");
    }

    #[test]
    fn add_and_remove_include() {
        let dir = temp_config(&[("hosts/web.toml", "include = [\"include/base.toml\"]\n")]);
        let hosts_dir = dir.join("hosts");

        add_include(&hosts_dir, "web", "nginx").unwrap();
        let cfg = load_host(&hosts_dir, "web").unwrap();
        assert_eq!(cfg.include, vec!["include/base.toml", "include/nginx.toml"]);

        // Adding the same fragment again is a no-op.
        add_include(&hosts_dir, "web", "nginx").unwrap();
        assert_eq!(load_host(&hosts_dir, "web").unwrap().include.len(), 2);

        remove_include(&hosts_dir, "web", "include/base.toml").unwrap();
        assert_eq!(
            load_host(&hosts_dir, "web").unwrap().include,
            vec!["include/nginx.toml"]
        );

        assert_eq!(fragment_id_from_ref("include/nginx.toml"), Some("nginx"));
        assert_eq!(fragment_id_from_ref("weird"), None);
    }

    #[test]
    fn fragment_crud_and_usage_lookup() {
        let dir = temp_config(&[("hosts/web.toml", "include = [\"include/nginx.toml\"]\n")]);
        let include_dir = dir.join("include");
        let hosts_dir = dir.join("hosts");

        // Initially no fragments.
        assert!(list_fragment_ids(&include_dir).unwrap().is_empty());

        // Write one, read it back, and confirm it lists.
        write_fragment_raw(
            &include_dir,
            "nginx",
            "[[files]]\npath=\"/x\"\ncontent=\"y\"\n",
        )
        .unwrap();
        assert_eq!(list_fragment_ids(&include_dir).unwrap(), vec!["nginx"]);
        assert!(
            read_fragment_raw(&include_dir, "nginx")
                .unwrap()
                .contains("/x")
        );

        // The host that includes it is found; another id is not.
        assert_eq!(
            hosts_using_fragment(&hosts_dir, "nginx").unwrap(),
            vec!["web"]
        );
        assert!(
            hosts_using_fragment(&hosts_dir, "other")
                .unwrap()
                .is_empty()
        );

        // Invalid TOML and unsafe ids are rejected.
        assert!(write_fragment_raw(&include_dir, "bad", "not = = toml").is_err());
        assert!(validate_fragment_id("../escape").is_err());
        assert_eq!(fragment_include_ref("nginx"), "include/nginx.toml");
    }

    #[test]
    fn diamond_include_merges_once() {
        let dir = temp_config(&[
            (
                "include/common.toml",
                "[[files]]\npath = \"/etc/c.conf\"\ncontent = \"c\"\n",
            ),
            ("include/left.toml", "include = [\"include/common.toml\"]\n"),
            (
                "include/right.toml",
                "include = [\"include/common.toml\"]\n",
            ),
            (
                "hosts/h.toml",
                "include = [\"include/left.toml\", \"include/right.toml\"]\n",
            ),
        ]);
        let root = load_host(&dir.join("hosts"), "h").unwrap();
        let merged = root.resolve(&dir).unwrap();
        assert_eq!(merged.files.len(), 1, "common should merge exactly once");
    }
}