Skip to main content

artifactize_core/
platform.rs

1//! Operating-system specifics behind one interface: owner-only files and directories, scoped
2//! opens and listings that never follow a link, process trees held until admitted, stop
3//! signals, hidden terminal input, and host details.
4//!
5//! Unix uses modes, `openat` with `O_NOFOLLOW`, `/proc`, and process groups. Windows uses
6//! protected owner-only DACLs, handle-relative opens that refuse every reparse point, and
7//! kill-on-close Job Objects.
8//!
9//! Kept as is (#201): process-wrap 10.0.1 is used for Unix process groups, but its Windows
10//! JobObject assigns a job after spawning and waits for the whole job, not just the leader.
11//! It exposes neither creation-time job assignment nor our separately owned admission gate;
12//! keep the current suspended-spawn/assignment and detached-launch policy on Windows.
13//! rpassword 7.5.4 exposes blocking password reads, not a caller-owned mode guard that can
14//! restore the terminal when a pending read is cancelled. Keep the echo-only guard and the
15//! bounded stdin reader; its restoration contract is tested in `hidden_input`.
16//! Program lookup and desktop-opener crate choices are documented in artifactize-tools'
17//! platform module, where those implementations live.
18
19pub mod environment;
20mod hidden_input;
21pub(crate) mod path_serde;
22pub(crate) mod paths;
23pub(crate) mod program;
24#[cfg(unix)]
25mod unix;
26#[cfg(windows)]
27mod windows;
28
29#[cfg(unix)]
30use unix as os;
31#[cfg(windows)]
32use windows as os;
33
34pub(crate) use os::{
35    CRLF_LINE_ENDINGS, Child, DEFAULT_EDITOR, ENV_NAMES_IGNORE_CASE, HOME_VARIABLE, HiddenInput,
36    PrivateTempDir, STATE_VARIABLES, TEMP_VARIABLES, USER_VARIABLES, VISIBLE_INPUT_FALLBACK,
37    create_private_dir, create_private_dir_all, editor, exit_signal, file_identity, home_directory,
38    host_name, ipc, is_owner_only, is_private_dir, is_private_file, kill_saved_group,
39    path_from_bytes, private_options, private_tempdir_in, process_start_time,
40    remove_abandoned_directory, resolve_system_aliases, restrict_file, running_process_start_time,
41    spawn_detached, spawn_gated, stop_requested, sync_dir, transient_file_access,
42};
43pub(crate) use os::{PRIVATE_DIRECTORY, PRIVATE_FILE};
44
45/// A file's identity on its volume. Two open files with equal identities are the same file,
46/// whatever paths reached them; a replaced file gets a new identity.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) struct FileIdentity {
49    volume: u64,
50    index: u64,
51}
52
53/// How a finished process ended.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub(crate) enum ProcessEnd {
56    /// It exited with this code.
57    Exited(i32),
58    /// It was ended without an exit code: on Unix by a signal, when known.
59    Signaled(Option<i32>),
60}
61
62/// How the process whose `status` this is ended.
63pub(crate) fn process_end(status: &std::process::ExitStatus) -> ProcessEnd {
64    match status.code() {
65        Some(code) => ProcessEnd::Exited(code),
66        None => ProcessEnd::Signaled(exit_signal(status)),
67    }
68}
69
70/// How a finished process ended, for a message: its exit code, or on Unix the signal that
71/// ended it, in the system's own wording.
72pub(crate) fn exit_description(status: &std::process::ExitStatus) -> String {
73    status.to_string()
74}
75
76/// Poll contended process-shared file locks without blocking the async runtime;
77/// 25 ms keeps session sends and credential refreshes responsive without busy-waiting.
78pub(crate) const FILE_LOCK_RETRY_INTERVAL: std::time::Duration =
79    std::time::Duration::from_millis(25);
80
81pub use artifactize_tools::files::path_text;
82pub(crate) use artifactize_tools::files::{
83    EntryName, FileKind, canonicalize, entry_kind, link_target, open_directory, open_entry,
84    open_no_follow, open_nonblocking, read_dir,
85};
86
87/// Open an existing regular file for reading without following a link in its last
88/// component (on Windows, any reparse point), and check what was opened.
89pub fn open_regular(path: &std::path::Path) -> std::io::Result<std::fs::File> {
90    let file = open_no_follow(std::fs::OpenOptions::new().read(true), path)?;
91    if file.metadata()?.is_file() {
92        Ok(file)
93    } else {
94        Err(std::io::Error::new(
95            std::io::ErrorKind::InvalidInput,
96            "not a regular file",
97        ))
98    }
99}
100
101/// Default state directory policy belongs with the platform's environment names.
102pub(crate) fn state_directory() -> Option<std::path::PathBuf> {
103    resolve_state_directory(
104        environment::var("ARTIFACTIZE_STATE_HOME").map(Into::into),
105        STATE_VARIABLES
106            .iter()
107            .find_map(|name| environment::var(name).filter(|v| !v.is_empty()))
108            .map(Into::into),
109        home_directory(),
110    )
111}
112
113fn resolve_state_directory(
114    artifactize: Option<std::path::PathBuf>,
115    state: Option<std::path::PathBuf>,
116    home: Option<std::path::PathBuf>,
117) -> Option<std::path::PathBuf> {
118    artifactize
119        .filter(|p| !p.as_os_str().is_empty())
120        .or_else(|| {
121            state
122                .filter(|p| !p.as_os_str().is_empty())
123                .map(|p| p.join("artifactize"))
124        })
125        .or_else(|| {
126            home.filter(|p| !p.as_os_str().is_empty())
127                .map(|p| p.join(".local/state/artifactize"))
128        })
129}
130
131/// Printable user metadata, not an authenticated principal.
132pub(crate) fn user_name() -> Option<String> {
133    USER_VARIABLES
134        .iter()
135        .find_map(|name| environment::var_text(name).filter(|value| !value.is_empty()))
136}
137
138/// Strip one platform-supported script line ending and name the accepted endings.
139pub(crate) fn fingerprint_line_ending(stdout: &[u8]) -> (&[u8], &'static str) {
140    if CRLF_LINE_ENDINGS {
141        (
142            stdout
143                .strip_suffix(b"\r\n")
144                .or_else(|| stdout.strip_suffix(b"\n"))
145                .unwrap_or(stdout),
146            "LF or CRLF",
147        )
148    } else {
149        (stdout.strip_suffix(b"\n").unwrap_or(stdout), "LF")
150    }
151}
152
153/// Classify the final component without following links or Windows reparse points.
154pub(crate) fn path_kind(path: &std::path::Path) -> std::io::Result<FileKind> {
155    match (path.parent(), path.file_name()) {
156        (Some(parent), Some(name)) => entry_kind(&open_directory(parent)?, name),
157        _ => open_directory(path).map(|_| FileKind::Directory),
158    }
159}
160
161/// Render serialized path lists with portable separators, just like single saved paths.
162pub(crate) fn serialize_paths<S: serde::Serializer>(
163    paths: &[std::path::PathBuf],
164    serializer: S,
165) -> Result<S::Ok, S::Error> {
166    use serde::Serialize;
167    paths
168        .iter()
169        .map(|path| path_text(path))
170        .collect::<Vec<_>>()
171        .serialize(serializer)
172}
173
174#[cfg(test)]
175mod state_directory_tests {
176    use super::*;
177    use std::path::PathBuf;
178
179    #[test]
180    fn state_home_precedence_and_empty_values() {
181        let artifactize = PathBuf::from("custom/state");
182        let state = PathBuf::from("state");
183        let home = PathBuf::from("reviewer");
184        assert_eq!(
185            resolve_state_directory(
186                Some(artifactize.clone()),
187                Some(state.clone()),
188                Some(home.clone())
189            ),
190            Some(artifactize)
191        );
192        assert_eq!(
193            resolve_state_directory(None, Some(state.clone()), Some(home.clone())),
194            Some(state.join("artifactize"))
195        );
196        assert_eq!(
197            resolve_state_directory(None, None, Some(home.clone())),
198            Some(home.join(".local/state/artifactize"))
199        );
200        let empty = Some(PathBuf::new());
201        assert_eq!(
202            resolve_state_directory(empty.clone(), empty.clone(), Some(home.clone())),
203            Some(home.join(".local/state/artifactize"))
204        );
205        assert_eq!(
206            resolve_state_directory(empty.clone(), empty.clone(), empty),
207            None
208        );
209        assert_eq!(resolve_state_directory(None, None, None), None);
210    }
211}
212
213mod console;
214pub(crate) use console::pause_after_help;
215
216mod interaction;
217pub use interaction::{PrivateTempFile, private_temp_file, run_editor, selected_editor};
218pub(crate) use interaction::{is_within, label, paths_equal, private_anonymous_file};
219
220/// Whether an entry exists without following links (including dangling links).
221pub(crate) fn entry_exists(path: &std::path::Path) -> std::io::Result<bool> {
222    let Some(parent) = path.parent() else {
223        return Ok(false);
224    };
225    let directory = match open_directory(parent) {
226        Ok(directory) => directory,
227        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
228        Err(error) => return Err(error),
229    };
230    let Some(name) = path.file_name() else {
231        return Ok(false);
232    };
233    match entry_kind(&directory, name) {
234        Ok(_) => Ok(true),
235        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
236        Err(error) => Err(error),
237    }
238}
239
240/// A no-follow stat of `path`. Unlike [`entry_exists`], this never opens `path`'s parent, so
241/// it still works on a search-only ancestor (executable but unreadable): opening a directory
242/// needs read permission on it, while naming one of its entries by an exact path needs only
243/// search (execute) permission on every ancestor up to it.
244pub(crate) fn marker_metadata(path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
245    path.symlink_metadata()
246}