Skip to main content

dotm/
metadata.rs

1use crate::config::PackageConfig;
2use anyhow::{Context, Result};
3use std::os::unix::fs::MetadataExt;
4use std::path::Path;
5
6/// Resolved metadata for a single file.
7#[derive(Debug, Clone)]
8pub struct ResolvedMetadata {
9    pub owner: Option<String>,
10    pub group: Option<String>,
11    pub mode: Option<String>,
12}
13
14/// Resolve what metadata to apply for a file, following the resolution order:
15/// 1. Per-file preserve -> keep existing (overrides package-level)
16/// 2. Per-file ownership/permissions -> explicit override
17/// 3. Package-level owner/group -> default for all files
18/// 4. Nothing -> preserve existing (None)
19pub fn resolve_metadata(pkg_config: &PackageConfig, rel_path: &str) -> ResolvedMetadata {
20    let preserve_fields: Vec<&str> = pkg_config
21        .preserve
22        .get(rel_path)
23        .map(|v| v.iter().map(|s| s.as_str()).collect())
24        .unwrap_or_default();
25
26    let owner = if preserve_fields.contains(&"owner") {
27        None
28    } else if let Some(ownership) = pkg_config.ownership.get(rel_path) {
29        ownership.split(':').next().map(|s| s.to_string())
30    } else {
31        pkg_config.owner.clone()
32    };
33
34    let group = if preserve_fields.contains(&"group") {
35        None
36    } else if let Some(ownership) = pkg_config.ownership.get(rel_path) {
37        ownership.split(':').nth(1).map(|s| s.to_string())
38    } else {
39        pkg_config.group.clone()
40    };
41
42    let mode = if preserve_fields.contains(&"mode") {
43        None
44    } else {
45        pkg_config.permissions.get(rel_path).cloned()
46    };
47
48    ResolvedMetadata { owner, group, mode }
49}
50
51/// Read the current metadata of a file on disk. Returns (owner_name, group_name, octal_mode).
52pub fn read_file_metadata(path: &Path) -> Result<(String, String, String)> {
53    let meta = std::fs::metadata(path)
54        .with_context(|| format!("failed to read metadata for {}", path.display()))?;
55
56    let uid = meta.uid();
57    let gid = meta.gid();
58
59    let owner = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
60        .ok()
61        .flatten()
62        .map(|u| u.name)
63        .unwrap_or_else(|| uid.to_string());
64
65    let group = nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(gid))
66        .ok()
67        .flatten()
68        .map(|g| g.name)
69        .unwrap_or_else(|| gid.to_string());
70
71    let mode = format!("{:o}", meta.mode() & 0o7777);
72
73    Ok((owner, group, mode))
74}
75
76/// Apply ownership (chown) to a file. Only applies fields that are Some.
77pub fn apply_ownership(path: &Path, owner: Option<&str>, group: Option<&str>) -> Result<()> {
78    let uid = match owner {
79        Some(name) => {
80            let user = nix::unistd::User::from_name(name)
81                .with_context(|| format!("failed to look up user '{name}'"))?
82                .with_context(|| format!("user '{name}' not found"))?;
83            Some(user.uid)
84        }
85        None => None,
86    };
87
88    let gid = match group {
89        Some(name) => {
90            let grp = nix::unistd::Group::from_name(name)
91                .with_context(|| format!("failed to look up group '{name}'"))?
92                .with_context(|| format!("group '{name}' not found"))?;
93            Some(grp.gid)
94        }
95        None => None,
96    };
97
98    nix::unistd::chown(path, uid, gid)
99        .with_context(|| format!("failed to chown {}", path.display()))?;
100
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::config::PackageConfig;
108
109    fn make_pkg_config() -> PackageConfig {
110        PackageConfig {
111            target: Some("/etc/foo".into()),
112            strategy: Some(crate::config::DeployStrategy::Copy),
113            system: true,
114            owner: Some("root".into()),
115            group: Some("root".into()),
116            ..Default::default()
117        }
118    }
119
120    #[test]
121    fn resolve_uses_package_level_defaults() {
122        let pkg = make_pkg_config();
123        let meta = resolve_metadata(&pkg, "some/file.conf");
124        assert_eq!(meta.owner.as_deref(), Some("root"));
125        assert_eq!(meta.group.as_deref(), Some("root"));
126        assert!(meta.mode.is_none());
127    }
128
129    #[test]
130    fn resolve_per_file_ownership_overrides_package() {
131        let mut pkg = make_pkg_config();
132        pkg.ownership
133            .insert("file.conf".into(), "www:webgroup".into());
134        let meta = resolve_metadata(&pkg, "file.conf");
135        assert_eq!(meta.owner.as_deref(), Some("www"));
136        assert_eq!(meta.group.as_deref(), Some("webgroup"));
137    }
138
139    #[test]
140    fn resolve_preserve_overrides_package_level() {
141        let mut pkg = make_pkg_config();
142        pkg.preserve
143            .insert("file.conf".into(), vec!["owner".into()]);
144        let meta = resolve_metadata(&pkg, "file.conf");
145        assert!(meta.owner.is_none());
146        assert_eq!(meta.group.as_deref(), Some("root"));
147    }
148
149    #[test]
150    fn resolve_preserve_mode_blocks_permission_override() {
151        let mut pkg = make_pkg_config();
152        pkg.permissions.insert("file.conf".into(), "640".into());
153        pkg.preserve
154            .insert("file.conf".into(), vec!["mode".into()]);
155        let meta = resolve_metadata(&pkg, "file.conf");
156        assert!(meta.mode.is_none());
157    }
158
159    #[test]
160    fn resolve_no_config_preserves_everything() {
161        let mut pkg = make_pkg_config();
162        pkg.owner = None;
163        pkg.group = None;
164        let meta = resolve_metadata(&pkg, "file.conf");
165        assert!(meta.owner.is_none());
166        assert!(meta.group.is_none());
167        assert!(meta.mode.is_none());
168    }
169
170    #[test]
171    fn resolve_permissions_from_config() {
172        let mut pkg = make_pkg_config();
173        pkg.permissions.insert("file.conf".into(), "755".into());
174        let meta = resolve_metadata(&pkg, "file.conf");
175        assert_eq!(meta.mode.as_deref(), Some("755"));
176    }
177}