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}