Skip to main content

ssh_cli/
fs_perm.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! Unix secret file/dir modes — single source (G-AUD-24 / no hardcode drift).
4//!
5//! # Platform coverage (A9)
6//!
7//! Only Unix is implemented. A Windows DACL restricted to the current user needs
8//! `windows-sys` with the `Win32_Security` / `Win32_Storage_FileSystem` features,
9//! which this crate does not currently enable, so the code cannot be written
10//! here without touching `Cargo.toml`. Until then, every entry point reports
11//! [`crate::fs_perm::SecretProtection::Unsupported`] instead of returning a success that would
12//! make the caller believe a secret file is locked down when it is not.
13
14use std::path::Path;
15
16use crate::constants::{SECRET_DIR_MODE_UNIX, SECRET_FILE_MODE_UNIX};
17use crate::errors::{SshCliError, SshCliResult};
18
19/// Whether a restrictive mode was actually applied to a path.
20///
21/// A9: the previous API returned `SshCliResult<()>` and answered `Ok(())` on
22/// every non-Unix target, so `secrets.key`, `config.toml`, `known_hosts` and TLS
23/// PEMs were left at whatever the platform default is while the caller believed
24/// they were protected. Encoding "not applied" in the success value keeps the
25/// caller honest: nothing here may claim a protection it did not perform.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SecretProtection {
28    /// The restrictive mode was applied to the path.
29    Applied,
30    /// The platform has no supported implementation; the path is unprotected.
31    Unsupported,
32}
33
34impl SecretProtection {
35    /// True when the caller may consider the path protected.
36    #[must_use]
37    pub const fn is_applied(self) -> bool {
38        matches!(self, Self::Applied)
39    }
40
41    /// Stable name for JSON / `doctor` reporting.
42    #[must_use]
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::Applied => "applied",
46            Self::Unsupported => "unsupported",
47        }
48    }
49}
50
51/// True when this build can restrict access to secret files and directories.
52///
53/// A9: `false` means every secret written by this binary is readable by any
54/// account that can reach the path; `doctor` is expected to surface that instead
55/// of the process pretending the files are locked down.
56#[must_use]
57pub const fn secret_protection_supported() -> bool {
58    cfg!(unix)
59}
60
61/// Sets secret-file mode (`0o600`) and reports whether it was applied.
62///
63/// Prefer this over [`set_secret_file_mode`] when the caller can report or act
64/// on an unprotected file.
65///
66/// # Errors
67/// Metadata read or permission change failure on a supported platform.
68pub fn set_secret_file_mode_checked(path: &Path) -> SshCliResult<SecretProtection> {
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::PermissionsExt;
72        let mut perms = std::fs::metadata(path)
73            .map_err(SshCliError::Io)?
74            .permissions();
75        perms.set_mode(SECRET_FILE_MODE_UNIX);
76        std::fs::set_permissions(path, perms).map_err(SshCliError::Io)?;
77        Ok(SecretProtection::Applied)
78    }
79    #[cfg(not(unix))]
80    {
81        // A9: no Windows DACL implementation is reachable from this crate today
82        // (see module note); report the gap rather than fake success.
83        let _ = path;
84        Ok(SecretProtection::Unsupported)
85    }
86}
87
88/// Sets secret-dir mode (`0o700`) and reports whether it was applied.
89///
90/// # Errors
91/// Metadata read or permission change failure on a supported platform.
92pub fn set_secret_dir_mode_checked(path: &Path) -> SshCliResult<SecretProtection> {
93    #[cfg(unix)]
94    {
95        use std::os::unix::fs::PermissionsExt;
96        let mut perms = std::fs::metadata(path)
97            .map_err(SshCliError::Io)?
98            .permissions();
99        perms.set_mode(SECRET_DIR_MODE_UNIX);
100        std::fs::set_permissions(path, perms).map_err(SshCliError::Io)?;
101        Ok(SecretProtection::Applied)
102    }
103    #[cfg(not(unix))]
104    {
105        let _ = path;
106        Ok(SecretProtection::Unsupported)
107    }
108}
109
110/// Sets secret-file mode (`0o600`) on Unix; **warns** on unsupported targets.
111///
112/// Compatibility wrapper for call sites that cannot act on the outcome. It never
113/// silently succeeds: an unsupported platform emits a warning naming the path,
114/// so the gap is at least observable in logs. New code should call
115/// [`set_secret_file_mode_checked`].
116pub fn set_secret_file_mode(path: &Path) -> SshCliResult<()> {
117    warn_if_unprotected(path, set_secret_file_mode_checked(path)?);
118    Ok(())
119}
120
121/// Sets secret-dir mode (`0o700`) on Unix; **warns** on unsupported targets.
122///
123/// See [`set_secret_file_mode`] for why this does not fail closed.
124pub fn set_secret_dir_mode(path: &Path) -> SshCliResult<()> {
125    warn_if_unprotected(path, set_secret_dir_mode_checked(path)?);
126    Ok(())
127}
128
129/// Emits a warning when a secret path was left at platform-default access.
130fn warn_if_unprotected(path: &Path, outcome: SecretProtection) {
131    if !outcome.is_applied() {
132        tracing::warn!(
133            path = %path.display(),
134            "secret path left at platform-default permissions: this build cannot restrict access on this OS"
135        );
136    }
137}
138
139/// Writes secret bytes to `path` atomically, never exposing them at a wider mode.
140///
141/// A2: writing with [`std::fs::write`] creates the file at `0644` under the default
142/// umask and only narrows it afterwards, leaving a window where a private key is
143/// world-readable. [`tempfile::NamedTempFile`] creates at `0600` via `O_EXCL`, so the
144/// content is never observable by other users, and the rename is atomic.
145///
146/// # Errors
147/// Directory creation, temp-file creation, write, fsync, mode change or rename failure.
148pub fn write_secret_file_atomic(path: &Path, data: &[u8]) -> SshCliResult<()> {
149    use std::io::Write;
150
151    if let Some(parent) = path.parent() {
152        std::fs::create_dir_all(parent).map_err(SshCliError::Io)?;
153    }
154    let parent = path.parent().unwrap_or_else(|| Path::new("."));
155    let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(SshCliError::Io)?;
156    tmp.write_all(data).map_err(SshCliError::Io)?;
157    tmp.as_file().sync_all().map_err(SshCliError::Io)?;
158    set_secret_file_mode(tmp.path())?;
159    tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
160    // Re-apply after rename; the temp file already carried the restricted mode.
161    set_secret_file_mode(path)?;
162    Ok(())
163}
164
165/// Compile-time alias for call sites that need the raw secret-file mode integer.
166#[must_use]
167pub const fn secret_file_mode() -> u32 {
168    SECRET_FILE_MODE_UNIX
169}
170
171/// Compile-time alias for call sites that need the raw secret-dir mode integer.
172#[must_use]
173pub const fn secret_dir_mode() -> u32 {
174    SECRET_DIR_MODE_UNIX
175}