Skip to main content

vta_cli_common/
secure_file.rs

1//! Cross-platform file / directory permission tightening for
2//! secret-bearing paths (bootstrap seeds, keystores, export bundles).
3//!
4//! # Unix
5//!
6//! `restrict_file_to_owner` → `chmod 0600`, `restrict_dir_to_owner` →
7//! `chmod 0700`. Mirrors the discipline already applied inline at
8//! existing call sites.
9//!
10//! # Windows
11//!
12//! `icacls <path> /inheritance:r /grant:r <user>:(F)` — removes any
13//! inherited ACEs and replaces the DACL with a single full-control
14//! grant to the current user. This is defence-in-depth on top of the
15//! user-profile defaults (which already keep other local users out,
16//! but inherited admin / Users group grants can slip through on
17//! misconfigured boxes or when the data lives outside the profile).
18//!
19//! Shell-out to `icacls` rather than native `SetNamedSecurityInfoW`
20//! because `icacls` is universally available on every supported Windows
21//! version, gets the quirks right (inheritance flags, SID lookup), and
22//! doesn't force the crate to carry a pile of unsafe Windows API code
23//! on a platform we don't exercise in CI. A future iteration can swap
24//! to the native API if `icacls` becomes insufficient.
25//!
26//! Errors are non-fatal at call sites: callers log a warning and
27//! continue, matching how the existing Unix `PermissionsExt` calls are
28//! already wired (best-effort hardening, not a correctness gate).
29
30use std::path::Path;
31
32/// Restrict `path` (a file) so only the owner can read / write.
33///
34/// Returns `Ok(())` on platforms where the operation either succeeded
35/// or is a no-op (everything non-Unix / non-Windows falls through —
36/// Unix gets 0600, Windows gets an icacls-applied user-only DACL).
37pub fn restrict_file_to_owner(path: &Path) -> std::io::Result<()> {
38    #[cfg(unix)]
39    {
40        use std::os::unix::fs::PermissionsExt;
41        let mut perm = std::fs::metadata(path)?.permissions();
42        perm.set_mode(0o600);
43        std::fs::set_permissions(path, perm)?;
44    }
45    #[cfg(windows)]
46    {
47        apply_windows_user_only_dacl(path)?;
48    }
49    #[cfg(not(any(unix, windows)))]
50    {
51        let _ = path;
52    }
53    Ok(())
54}
55
56/// Restrict `path` (a directory) so only the owner can traverse / read /
57/// write. On Unix: `0700`. On Windows: inheritance removed and DACL
58/// replaced with full control to the current user only.
59pub fn restrict_dir_to_owner(path: &Path) -> std::io::Result<()> {
60    #[cfg(unix)]
61    {
62        use std::os::unix::fs::PermissionsExt;
63        let mut perm = std::fs::metadata(path)?.permissions();
64        perm.set_mode(0o700);
65        std::fs::set_permissions(path, perm)?;
66    }
67    #[cfg(windows)]
68    {
69        apply_windows_user_only_dacl(path)?;
70    }
71    #[cfg(not(any(unix, windows)))]
72    {
73        let _ = path;
74    }
75    Ok(())
76}
77
78#[cfg(windows)]
79fn apply_windows_user_only_dacl(path: &Path) -> std::io::Result<()> {
80    use std::process::Command;
81
82    // Resolve the current user. `USERNAME` is present on every modern
83    // Windows shell environment; fall back to `USERDOMAIN\USERNAME`
84    // only if the plain name is missing. A missing `USERNAME` means
85    // the process is running in an unusual execution context
86    // (service, scheduled task without user context, etc.) — surface
87    // that via an error rather than silently leaving the file
88    // wide-open.
89    let user = std::env::var("USERNAME").map_err(|_| {
90        std::io::Error::new(
91            std::io::ErrorKind::NotFound,
92            "USERNAME env var not set — cannot apply Windows user-only DACL",
93        )
94    })?;
95    let user_trimmed = user.trim();
96    if user_trimmed.is_empty() {
97        return Err(std::io::Error::new(
98            std::io::ErrorKind::InvalidData,
99            "USERNAME is empty — cannot apply Windows user-only DACL",
100        ));
101    }
102
103    let path_str = path.to_str().ok_or_else(|| {
104        std::io::Error::new(
105            std::io::ErrorKind::InvalidInput,
106            "path is not valid UTF-8 — cannot pass to icacls",
107        )
108    })?;
109
110    // `icacls <path> /inheritance:r /grant:r "user:(F)"`
111    //   /inheritance:r → remove inherited ACEs
112    //   /grant:r       → replace any existing grant for <user>
113    //   user:(F)       → full control
114    let output = Command::new("icacls")
115        .arg(path_str)
116        .arg("/inheritance:r")
117        .arg("/grant:r")
118        .arg(format!("{user_trimmed}:(F)"))
119        .output()?;
120
121    if !output.status.success() {
122        return Err(std::io::Error::other(format!(
123            "icacls failed ({}): {}",
124            output.status,
125            String::from_utf8_lossy(&output.stderr).trim()
126        )));
127    }
128    Ok(())
129}
130
131#[cfg(all(test, unix))]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn restrict_file_sets_0600_on_unix() {
137        use std::os::unix::fs::PermissionsExt;
138        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
139        std::fs::create_dir_all(&tmp).unwrap();
140        let f = tmp.join("secret.bin");
141        std::fs::write(&f, b"sensitive").unwrap();
142
143        // Start with permissive mode so we can see the change.
144        let mut perm = std::fs::metadata(&f).unwrap().permissions();
145        perm.set_mode(0o644);
146        std::fs::set_permissions(&f, perm).unwrap();
147
148        restrict_file_to_owner(&f).expect("restrict_file_to_owner succeeds");
149
150        let mode = std::fs::metadata(&f).unwrap().permissions().mode();
151        assert_eq!(mode & 0o777, 0o600);
152        let _ = std::fs::remove_dir_all(&tmp);
153    }
154
155    #[test]
156    fn restrict_dir_sets_0700_on_unix() {
157        use std::os::unix::fs::PermissionsExt;
158        let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
159        std::fs::create_dir_all(&tmp).unwrap();
160
161        let mut perm = std::fs::metadata(&tmp).unwrap().permissions();
162        perm.set_mode(0o755);
163        std::fs::set_permissions(&tmp, perm).unwrap();
164
165        restrict_dir_to_owner(&tmp).expect("restrict_dir_to_owner succeeds");
166
167        let mode = std::fs::metadata(&tmp).unwrap().permissions().mode();
168        assert_eq!(mode & 0o777, 0o700);
169        let _ = std::fs::remove_dir_all(&tmp);
170    }
171}