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(crate) mod desktop_app;
20pub mod desktop_commands;
21pub mod environment;
22mod hidden_input;
23pub(crate) mod install;
24pub(crate) mod net;
25pub(crate) mod path_serde;
26pub(crate) mod paths;
27pub(crate) mod program;
28#[cfg(unix)]
29mod unix;
30#[cfg(windows)]
31mod windows;
32
33#[cfg(unix)]
34use unix as os;
35#[cfg(windows)]
36use windows as os;
37
38pub(crate) use os::{
39    CRLF_LINE_ENDINGS, Child, ENV_NAMES_IGNORE_CASE, HOME_VARIABLE, PrivateTempDir,
40    STATE_VARIABLES, TEMP_VARIABLES, USER_VARIABLES, create_private_dir, create_private_dir_all,
41    exit_signal, file_identity, hide_console_window, home_directory, host_name, ipc, is_owner_only,
42    is_private_dir, is_private_file, kill_saved_group, kill_saved_session, path_from_bytes,
43    private_options, private_tempdir_in, process_start_time, release_free_memory,
44    remove_abandoned_directory, resolve_system_aliases, restrict_file, running_process_start_time,
45    spawn_detached, spawn_gated, spawn_now, spawn_run, spawn_watched, sync_dir,
46    transient_file_access,
47};
48pub use os::{HiddenInput, VISIBLE_INPUT_FALLBACK, stop_requested};
49pub(crate) use os::{PRIVATE_DIRECTORY, PRIVATE_FILE};
50
51/// A share in a large read's memory, such as an opened Run's: everyone who keeps part of the
52/// read keeps a share, declared after what it keeps so that it is dropped last. When the last
53/// share goes, the read's memory has been freed, and it is given back to the system then, once
54/// (see [`release_free_memory`]), rather than staying with a long-lived process.
55#[derive(Debug, Clone)]
56pub struct Footprint {
57    _release: std::sync::Arc<Release>,
58}
59
60impl Footprint {
61    pub(crate) fn new() -> Self {
62        Self {
63            _release: std::sync::Arc::new(Release),
64        }
65    }
66}
67
68#[derive(Debug)]
69struct Release;
70
71impl Drop for Release {
72    fn drop(&mut self) {
73        #[cfg(test)]
74        RELEASES.with(|count| count.set(count.get() + 1));
75        release_free_memory();
76    }
77}
78
79#[cfg(test)]
80thread_local! {
81    static RELEASES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
82}
83
84/// How often this thread gave a large read's memory back to the system.
85#[cfg(test)]
86pub(crate) fn releases() -> usize {
87    RELEASES.with(std::cell::Cell::get)
88}
89
90/// A file's identity on its volume. Two open files with equal identities are the same file,
91/// whatever paths reached them; a replaced file gets a new identity.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) struct FileIdentity {
94    volume: u64,
95    index: u64,
96}
97
98/// How a finished process ended.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub(crate) enum ProcessEnd {
101    /// It exited with this code.
102    Exited(i32),
103    /// It was ended without an exit code: on Unix by a signal, when known.
104    Signaled(Option<i32>),
105}
106
107/// How the process whose `status` this is ended.
108pub(crate) fn process_end(status: &std::process::ExitStatus) -> ProcessEnd {
109    match status.code() {
110        Some(code) => ProcessEnd::Exited(code),
111        None => ProcessEnd::Signaled(exit_signal(status)),
112    }
113}
114
115/// How a finished process ended, for a message: its exit code, or on Unix the signal that
116/// ended it, in the system's own wording.
117pub(crate) fn exit_description(status: &std::process::ExitStatus) -> String {
118    status.to_string()
119}
120
121/// Poll contended process-shared file locks without blocking the async runtime;
122/// 25 ms keeps session sends and credential refreshes responsive without busy-waiting.
123pub(crate) const FILE_LOCK_RETRY_INTERVAL: std::time::Duration =
124    std::time::Duration::from_millis(25);
125
126pub(crate) use artifactize_tools::files::{
127    EntryName, FileKind, canonicalize, entry_kind, link_target, open_directory, open_entry,
128    open_no_follow, read_dir,
129};
130pub use artifactize_tools::files::{open_nonblocking, path_text};
131
132/// The absolute local folder a `file:` URI names, by this OS's path rules (a drive letter
133/// or UNC host on Windows); `None` for another scheme, a remote host or a relative path.
134pub fn file_uri_path(uri: &str) -> Option<std::path::PathBuf> {
135    url::Url::parse(uri)
136        .ok()?
137        .to_file_path()
138        .ok()
139        .filter(|path| path.is_absolute())
140}
141
142/// The absolute folder a caller names; a relative one would resolve against this
143/// process's working directory, which the caller cannot see.
144pub fn absolute_folder(text: &str) -> Option<std::path::PathBuf> {
145    Some(std::path::PathBuf::from(text)).filter(|path| path.is_absolute())
146}
147
148/// Open an existing regular file for reading without following a link in its last
149/// component (on Windows, any reparse point), and check what was opened.
150pub fn open_regular(path: &std::path::Path) -> std::io::Result<std::fs::File> {
151    let file = open_no_follow(std::fs::OpenOptions::new().read(true), path)?;
152    if file.metadata()?.is_file() {
153        Ok(file)
154    } else {
155        Err(std::io::Error::new(
156            std::io::ErrorKind::InvalidInput,
157            "not a regular file",
158        ))
159    }
160}
161
162/// Decode one native tool path line. Unix paths may end in a literal carriage return;
163/// only Windows accepts CRLF as the line terminator rather than part of the path.
164pub(crate) fn path_from_line(bytes: &[u8]) -> std::path::PathBuf {
165    let path = if CRLF_LINE_ENDINGS {
166        bytes
167            .strip_suffix(b"\r\n")
168            .or_else(|| bytes.strip_suffix(b"\n"))
169            .unwrap_or(bytes)
170    } else {
171        bytes.strip_suffix(b"\n").unwrap_or(bytes)
172    };
173    path_from_bytes(path)
174}
175
176/// Default state directory policy belongs with the platform's environment names.
177pub(crate) fn state_directory() -> Option<std::path::PathBuf> {
178    resolve_state_directory(
179        environment::var("ARTIFACTIZE_STATE_HOME").map(Into::into),
180        STATE_VARIABLES
181            .iter()
182            .find_map(|name| environment::var(name).filter(|v| !v.is_empty()))
183            .map(Into::into),
184        home_directory(),
185    )
186}
187
188fn resolve_state_directory(
189    artifactize: Option<std::path::PathBuf>,
190    state: Option<std::path::PathBuf>,
191    home: Option<std::path::PathBuf>,
192) -> Option<std::path::PathBuf> {
193    artifactize
194        .filter(|p| !p.as_os_str().is_empty())
195        .or_else(|| {
196            state
197                .filter(|p| !p.as_os_str().is_empty())
198                .map(|p| p.join("artifactize"))
199        })
200        .or_else(|| {
201            home.filter(|p| !p.as_os_str().is_empty())
202                .map(|p| p.join(".local/state/artifactize"))
203        })
204}
205
206/// Printable user metadata, not an authenticated principal.
207pub(crate) fn user_name() -> Option<String> {
208    USER_VARIABLES
209        .iter()
210        .find_map(|name| environment::var_text(name).filter(|value| !value.is_empty()))
211}
212
213/// Strip one platform-supported script line ending and name the accepted endings.
214pub(crate) fn fingerprint_line_ending(stdout: &[u8]) -> (&[u8], &'static str) {
215    if CRLF_LINE_ENDINGS {
216        (
217            stdout
218                .strip_suffix(b"\r\n")
219                .or_else(|| stdout.strip_suffix(b"\n"))
220                .unwrap_or(stdout),
221            "LF or CRLF",
222        )
223    } else {
224        (stdout.strip_suffix(b"\n").unwrap_or(stdout), "LF")
225    }
226}
227
228/// Classify the final component without following links or Windows reparse points.
229pub(crate) fn path_kind(path: &std::path::Path) -> std::io::Result<FileKind> {
230    match (path.parent(), path.file_name()) {
231        (Some(parent), Some(name)) => entry_kind(&open_directory(parent)?, name),
232        _ => open_directory(path).map(|_| FileKind::Directory),
233    }
234}
235
236/// A declared program as text: a path among its spellings is written with `/`.
237pub(crate) fn program_text(command: &str) -> String {
238    path_text(std::path::Path::new(command))
239}
240
241/// Serialize a declared program as [`program_text`].
242pub(crate) fn serialize_program<S: serde::Serializer>(
243    command: &str,
244    serializer: S,
245) -> Result<S::Ok, S::Error> {
246    serializer.serialize_str(&program_text(command))
247}
248
249/// Render serialized path lists with portable separators, just like single saved paths.
250pub(crate) fn serialize_paths<S: serde::Serializer>(
251    paths: &[std::path::PathBuf],
252    serializer: S,
253) -> Result<S::Ok, S::Error> {
254    use serde::Serialize;
255    paths
256        .iter()
257        .map(|path| path_text(path))
258        .collect::<Vec<_>>()
259        .serialize(serializer)
260}
261
262#[cfg(test)]
263mod program_tests {
264    #[test]
265    fn a_declared_program_is_written_with_forward_slashes() {
266        assert_eq!(super::program_text("pnpm"), "pnpm");
267        assert_eq!(super::program_text("tools/run.sh"), "tools/run.sh");
268        #[cfg(windows)]
269        assert_eq!(super::program_text(r"tools\run.cmd"), "tools/run.cmd");
270    }
271}
272
273#[cfg(test)]
274mod state_directory_tests {
275    use super::*;
276    use std::path::PathBuf;
277
278    #[test]
279    fn state_home_precedence_and_empty_values() {
280        let artifactize = PathBuf::from("custom/state");
281        let state = PathBuf::from("state");
282        let home = PathBuf::from("reviewer");
283        assert_eq!(
284            resolve_state_directory(
285                Some(artifactize.clone()),
286                Some(state.clone()),
287                Some(home.clone())
288            ),
289            Some(artifactize)
290        );
291        assert_eq!(
292            resolve_state_directory(None, Some(state.clone()), Some(home.clone())),
293            Some(state.join("artifactize"))
294        );
295        assert_eq!(
296            resolve_state_directory(None, None, Some(home.clone())),
297            Some(home.join(".local/state/artifactize"))
298        );
299        let empty = Some(PathBuf::new());
300        assert_eq!(
301            resolve_state_directory(empty.clone(), empty.clone(), Some(home.clone())),
302            Some(home.join(".local/state/artifactize"))
303        );
304        assert_eq!(
305            resolve_state_directory(empty.clone(), empty.clone(), empty),
306            None
307        );
308        assert_eq!(resolve_state_directory(None, None, None), None);
309    }
310}
311
312mod console;
313pub use console::pause_after_help;
314
315mod interaction;
316pub(crate) use interaction::{is_within, label, paths_equal, private_anonymous_file};
317
318/// Whether an entry exists without following links (including dangling links).
319pub(crate) fn entry_exists(path: &std::path::Path) -> std::io::Result<bool> {
320    let Some(parent) = path.parent() else {
321        return Ok(false);
322    };
323    let directory = match open_directory(parent) {
324        Ok(directory) => directory,
325        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
326        Err(error) => return Err(error),
327    };
328    let Some(name) = path.file_name() else {
329        return Ok(false);
330    };
331    match entry_kind(&directory, name) {
332        Ok(_) => Ok(true),
333        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
334        Err(error) => Err(error),
335    }
336}
337
338/// A no-follow stat of `path`. Unlike [`entry_exists`], this never opens `path`'s parent, so
339/// it still works on a search-only ancestor (executable but unreadable): opening a directory
340/// needs read permission on it, while naming one of its entries by an exact path needs only
341/// search (execute) permission on every ancestor up to it.
342pub(crate) fn marker_metadata(path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
343    path.symlink_metadata()
344}