Skip to main content

artifactize_core/platform/
unix.rs

1//! Unix private file modes, process control, signals, and termios.
2//! Pinned file access lives in artifactize-tools.
3
4mod descriptors;
5pub(crate) mod ipc;
6mod process;
7
8use std::{
9    fs::{self, DirBuilder, File, OpenOptions, Permissions},
10    future::Future,
11    io,
12    os::unix::{
13        fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt},
14        process::ExitStatusExt,
15    },
16    path::Path,
17    process::ExitStatus,
18};
19
20pub(crate) use tempfile::TempDir as PrivateTempDir;
21use tokio::signal::unix::{SignalKind, signal};
22
23pub(crate) use process::{
24    Child, hide_console_window, kill_saved_group, kill_saved_session, process_start_time,
25    running_process_start_time, spawn_detached, spawn_gated, spawn_now, spawn_run, spawn_watched,
26    spawn_windowless,
27};
28
29/// Give the heap memory freed so far back to the system. glibc keeps freed memory in its
30/// arenas for reuse, so a long-lived process that once read much stays that large; macOS's
31/// allocator returns it by itself.
32pub(crate) fn release_free_memory() {
33    #[cfg(all(target_os = "linux", target_env = "gnu"))]
34    // SAFETY: malloc_trim only releases memory that is already free, under glibc's own locks.
35    unsafe {
36        libc::malloc_trim(0);
37    }
38}
39
40/// No Windows-style pending sharing disposition survives group cleanup.
41pub(crate) fn remove_abandoned_directory(path: &Path) -> io::Result<()> {
42    std::fs::remove_dir_all(path)
43}
44
45/// POSIX file locks are advisory, and unlinking a name never invalidates another handle's
46/// access to it, so a plain metadata or open race like Windows's never happens here.
47pub(crate) fn transient_file_access(_error: &io::Error) -> bool {
48    false
49}
50
51/// Owner-only directory mode (rwx------): these directories hold credentials, Agent sessions,
52/// sockets and review state, so no other local user may list or enter them.
53const PRIVATE_DIR_MODE: u32 = 0o700;
54
55/// Owner-only file mode (rw-------): these files hold credentials, Agent transcripts and review
56/// evidence, so no other local user may read them.
57const PRIVATE_FILE_MODE: u32 = 0o600;
58
59/// How a private directory and file are described in messages.
60pub(crate) const PRIVATE_DIRECTORY: &str = "owner-only (mode 0700)";
61pub(crate) const PRIVATE_FILE: &str = "readable by their owner only (mode 0600)";
62
63/// The group and other permission bits; a private path has none of them set.
64pub(crate) const GROUP_OTHER_BITS: u32 = 0o077;
65
66/// Group/other write bits let another user replace system-alias or IPC parent
67/// directories, so neither the alias nor the endpoint under them can be trusted.
68const GROUP_OTHER_WRITE_BITS: u32 = 0o022;
69
70/// The permission bits of a mode, without the file type and set-id bits, so a file can be
71/// compared with [`PRIVATE_FILE_MODE`] exactly.
72const PERMISSION_BITS: u32 = 0o777;
73
74/// Create a directory and its missing parents as 0700; existing ones keep their mode.
75pub(crate) fn create_private_dir_all(path: &Path) -> io::Result<()> {
76    DirBuilder::new()
77        .recursive(true)
78        .mode(PRIVATE_DIR_MODE)
79        .create(path)
80}
81
82/// Create one directory as exactly 0700, whatever the umask.
83pub(crate) fn create_private_dir(path: &Path) -> io::Result<()> {
84    DirBuilder::new().mode(PRIVATE_DIR_MODE).create(path)?;
85    fs::set_permissions(path, Permissions::from_mode(PRIVATE_DIR_MODE))
86}
87
88/// A new 0700 temporary directory below `parent`.
89pub(crate) fn private_tempdir_in(prefix: &str, parent: &Path) -> io::Result<PrivateTempDir> {
90    let directory = tempfile::Builder::new()
91        .prefix(prefix)
92        .permissions(Permissions::from_mode(PRIVATE_DIR_MODE))
93        .tempdir_in(parent)?;
94    fs::set_permissions(directory.path(), Permissions::from_mode(PRIVATE_DIR_MODE))?;
95    Ok(directory)
96}
97
98/// Whether a directory grants nothing to its group or others.
99pub(crate) fn is_private_dir(path: &Path) -> io::Result<bool> {
100    Ok(fs::metadata(path)?.mode() & GROUP_OTHER_BITS == 0)
101}
102
103/// Whether an open file is a regular, single-link 0600 file.
104pub(crate) fn is_private_file(file: &File) -> io::Result<bool> {
105    let metadata = file.metadata()?;
106    Ok(metadata.is_file()
107        && metadata.mode() & PERMISSION_BITS == PRIVATE_FILE_MODE
108        && metadata.nlink() == 1)
109}
110
111/// Options that create files as 0600.
112pub(crate) fn private_options() -> OpenOptions {
113    let mut options = OpenOptions::new();
114    options.mode(PRIVATE_FILE_MODE);
115    options
116}
117
118/// Make an open file 0600.
119pub(crate) fn restrict_file(file: &File) -> io::Result<()> {
120    file.set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))
121}
122
123/// Persist a directory's entries, such as a file just renamed into it.
124pub(crate) fn sync_dir(path: &Path) -> io::Result<()> {
125    File::open(path).and_then(|directory| directory.sync_all())
126}
127
128/// A file's identity on its volume: its device and inode.
129pub(crate) fn file_identity(file: &File) -> io::Result<super::FileIdentity> {
130    let metadata = file.metadata()?;
131    Ok(super::FileIdentity {
132        volume: metadata.dev(),
133        index: metadata.ino(),
134    })
135}
136
137/// The effective user, the owner that private paths must have.
138pub(crate) fn current_user() -> u32 {
139    // SAFETY: geteuid has no preconditions and does not mutate process identity.
140    unsafe { libc::geteuid() }
141}
142
143/// Whether an open file or directory belongs to the current user and grants nothing to its
144/// group or others.
145pub(crate) fn is_owner_only(file: &File) -> io::Result<bool> {
146    let metadata = file.metadata()?;
147    Ok(metadata.mode() & GROUP_OTHER_BITS == 0 && metadata.uid() == current_user())
148}
149
150/// Environment variable names compare exactly.
151pub(crate) const ENV_NAMES_IGNORE_CASE: bool = false;
152
153/// The variable naming the user's home directory.
154pub(crate) const HOME_VARIABLE: &str = "HOME";
155
156/// Variables programs read their temporary directory from. `TMP` and `TEMP` are set too, for
157/// programs written for Windows.
158pub(crate) const TEMP_VARIABLES: &[&str] = &["TMPDIR", "TMP", "TEMP"];
159
160/// Variables naming a per-user state directory, in order; artifactize keeps its state in an
161/// `artifactize` folder below the first one set, and otherwise below `HOME`.
162pub(crate) const STATE_VARIABLES: &[&str] = &["XDG_STATE_HOME"];
163
164/// The variables naming the signed-in user, in order.
165pub(crate) const USER_VARIABLES: &[&str] = &["USER", "LOGNAME"];
166
167/// The user's home directory.
168pub(crate) fn home_directory() -> Option<std::path::PathBuf> {
169    std::env::var_os(HOME_VARIABLE)
170        .filter(|path| !path.is_empty())
171        .map(Into::into)
172}
173
174/// A path from the raw bytes a tool such as git prints: any bytes name a Unix path.
175pub(crate) fn path_from_bytes(bytes: &[u8]) -> std::path::PathBuf {
176    use std::os::unix::ffi::OsStringExt;
177    std::ffi::OsString::from_vec(bytes.to_vec()).into()
178}
179
180/// Unix programs end a line with LF only.
181pub(crate) const CRLF_LINE_ENDINGS: bool = false;
182
183/// Whether a terminal that cannot hide input is still read, visibly, after a warning. A Unix
184/// terminal always can; failing to hide input is an error.
185pub const VISIBLE_INPUT_FALLBACK: bool = false;
186
187/// The signal that ended a process, if one did.
188pub(crate) fn exit_signal(status: &ExitStatus) -> Option<i32> {
189    status.signal()
190}
191
192/// The operating system's host name, without depending on a proc filesystem.
193pub(crate) fn host_name() -> Option<String> {
194    hostname::get().ok()?.into_string().ok()
195}
196
197/// The absolute form of `path` with a leading macOS system alias (`/tmp`, `/var`, `/etc`)
198/// replaced by its fixed `/private` target. The alias and its parents must be root-owned
199/// and not replaceable; descendants are left for the caller to open without following links.
200#[cfg(target_os = "macos")]
201pub(crate) fn resolve_system_aliases(path: &Path) -> io::Result<std::path::PathBuf> {
202    let absolute = std::path::absolute(path)?;
203    for name in ["tmp", "var", "etc"] {
204        let alias = Path::new("/").join(name);
205        if let Ok(suffix) = absolute.strip_prefix(&alias) {
206            let metadata = alias.symlink_metadata()?;
207            let target = Path::new("/private").join(name);
208            let link = fs::read_link(&alias)?;
209            let link = if link.is_absolute() {
210                link
211            } else {
212                Path::new("/").join(link)
213            };
214            if !metadata.is_symlink() || metadata.uid() != 0 || link != target {
215                return Err(io::Error::other(format!(
216                    "Refusing an untrusted system alias: {}",
217                    alias.display()
218                )));
219            }
220            for parent in [Path::new("/"), Path::new("/private")] {
221                let metadata = parent.symlink_metadata()?;
222                if !metadata.is_dir()
223                    || metadata.uid() != 0
224                    || metadata.mode() & GROUP_OTHER_WRITE_BITS != 0
225                {
226                    return Err(io::Error::other(
227                        "System alias parents must not be replaceable.",
228                    ));
229                }
230            }
231            return Ok(target.join(suffix));
232        }
233    }
234    Ok(absolute)
235}
236
237/// The absolute form of `path`; this system has no aliased system roots.
238#[cfg(not(target_os = "macos"))]
239pub(crate) fn resolve_system_aliases(path: &Path) -> io::Result<std::path::PathBuf> {
240    std::path::absolute(path)
241}
242
243/// Register for SIGINT and SIGTERM now; the future completes at the first of them.
244pub fn stop_requested() -> io::Result<impl Future<Output = ()> + Send + 'static> {
245    let mut interrupt = signal(SignalKind::interrupt())?;
246    let mut terminate = signal(SignalKind::terminate())?;
247    Ok(async move {
248        tokio::select! { _ = interrupt.recv() => {}, _ = terminate.recv() => {} }
249    })
250}
251
252/// Turns terminal echo off until dropped, so a pasted secret is not shown.
253pub struct HiddenInput(libc::termios);
254
255impl HiddenInput {
256    pub fn new() -> io::Result<Self> {
257        let mut termios = std::mem::MaybeUninit::uninit();
258        // SAFETY: tcgetattr fills the struct on success; it is read only then.
259        let saved = unsafe {
260            if libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) != 0 {
261                return Err(io::Error::last_os_error());
262            }
263            termios.assume_init()
264        };
265        let mut hidden = saved;
266        hidden.c_lflag &= !libc::ECHO;
267        // SAFETY: a valid termios for the same descriptor.
268        if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &hidden) } != 0 {
269            return Err(io::Error::last_os_error());
270        }
271        Ok(Self(saved))
272    }
273}
274
275impl Drop for HiddenInput {
276    fn drop(&mut self) {
277        // SAFETY: restores the attributes read in `new`.
278        unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0) };
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// Unlike Windows, no error here is ever transient in this sense; the predicate is a
287    /// permanent no-op, not a gap in detecting a race that would need retrying.
288    #[test]
289    fn transient_file_access_is_always_false() {
290        assert!(!transient_file_access(&io::Error::from_raw_os_error(5)));
291        assert!(!transient_file_access(&io::Error::new(
292            io::ErrorKind::PermissionDenied,
293            "permission denied"
294        )));
295    }
296}