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, spawn_windowless, 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/// This system's directory for temporary files.
177pub(crate) fn temporary_directory() -> std::path::PathBuf {
178    std::env::temp_dir()
179}
180
181/// Default state directory policy belongs with the platform's environment names.
182pub(crate) fn state_directory() -> Option<std::path::PathBuf> {
183    resolve_state_directory(
184        environment::var("ARTIFACTIZE_STATE_HOME").map(Into::into),
185        STATE_VARIABLES
186            .iter()
187            .find_map(|name| environment::var(name).filter(|v| !v.is_empty()))
188            .map(Into::into),
189        home_directory(),
190    )
191}
192
193fn resolve_state_directory(
194    artifactize: Option<std::path::PathBuf>,
195    state: Option<std::path::PathBuf>,
196    home: Option<std::path::PathBuf>,
197) -> Option<std::path::PathBuf> {
198    artifactize
199        .filter(|p| !p.as_os_str().is_empty())
200        .or_else(|| {
201            state
202                .filter(|p| !p.as_os_str().is_empty())
203                .map(|p| p.join("artifactize"))
204        })
205        .or_else(|| {
206            home.filter(|p| !p.as_os_str().is_empty())
207                .map(|p| p.join(".local/state/artifactize"))
208        })
209}
210
211/// Printable user metadata, not an authenticated principal.
212pub(crate) fn user_name() -> Option<String> {
213    USER_VARIABLES
214        .iter()
215        .find_map(|name| environment::var_text(name).filter(|value| !value.is_empty()))
216}
217
218/// Strip one platform-supported script line ending and name the accepted endings.
219pub(crate) fn fingerprint_line_ending(stdout: &[u8]) -> (&[u8], &'static str) {
220    if CRLF_LINE_ENDINGS {
221        (
222            stdout
223                .strip_suffix(b"\r\n")
224                .or_else(|| stdout.strip_suffix(b"\n"))
225                .unwrap_or(stdout),
226            "LF or CRLF",
227        )
228    } else {
229        (stdout.strip_suffix(b"\n").unwrap_or(stdout), "LF")
230    }
231}
232
233/// Classify the final component without following links or Windows reparse points.
234pub(crate) fn path_kind(path: &std::path::Path) -> std::io::Result<FileKind> {
235    match (path.parent(), path.file_name()) {
236        (Some(parent), Some(name)) => entry_kind(&open_directory(parent)?, name),
237        _ => open_directory(path).map(|_| FileKind::Directory),
238    }
239}
240
241/// A declared program as text: a path among its spellings is written with `/`.
242pub(crate) fn program_text(command: &str) -> String {
243    path_text(std::path::Path::new(command))
244}
245
246/// Serialize a declared program as [`program_text`].
247pub(crate) fn serialize_program<S: serde::Serializer>(
248    command: &str,
249    serializer: S,
250) -> Result<S::Ok, S::Error> {
251    serializer.serialize_str(&program_text(command))
252}
253
254/// Render serialized path lists with portable separators, just like single saved paths.
255pub(crate) fn serialize_paths<S: serde::Serializer>(
256    paths: &[std::path::PathBuf],
257    serializer: S,
258) -> Result<S::Ok, S::Error> {
259    use serde::Serialize;
260    paths
261        .iter()
262        .map(|path| path_text(path))
263        .collect::<Vec<_>>()
264        .serialize(serializer)
265}
266
267#[cfg(test)]
268mod process_tests;
269
270#[cfg(test)]
271mod program_tests {
272    #[test]
273    fn a_declared_program_is_written_with_forward_slashes() {
274        assert_eq!(super::program_text("pnpm"), "pnpm");
275        assert_eq!(super::program_text("tools/run.sh"), "tools/run.sh");
276        #[cfg(windows)]
277        assert_eq!(super::program_text(r"tools\run.cmd"), "tools/run.cmd");
278    }
279}
280
281#[cfg(test)]
282mod state_directory_tests {
283    use super::*;
284    use std::path::PathBuf;
285
286    #[test]
287    fn state_home_precedence_and_empty_values() {
288        let artifactize = PathBuf::from("custom/state");
289        let state = PathBuf::from("state");
290        let home = PathBuf::from("reviewer");
291        assert_eq!(
292            resolve_state_directory(
293                Some(artifactize.clone()),
294                Some(state.clone()),
295                Some(home.clone())
296            ),
297            Some(artifactize)
298        );
299        assert_eq!(
300            resolve_state_directory(None, Some(state.clone()), Some(home.clone())),
301            Some(state.join("artifactize"))
302        );
303        assert_eq!(
304            resolve_state_directory(None, None, Some(home.clone())),
305            Some(home.join(".local/state/artifactize"))
306        );
307        let empty = Some(PathBuf::new());
308        assert_eq!(
309            resolve_state_directory(empty.clone(), empty.clone(), Some(home.clone())),
310            Some(home.join(".local/state/artifactize"))
311        );
312        assert_eq!(
313            resolve_state_directory(empty.clone(), empty.clone(), empty),
314            None
315        );
316        assert_eq!(resolve_state_directory(None, None, None), None);
317    }
318}
319
320mod console;
321pub use console::pause_after_help;
322
323mod interaction;
324pub(crate) use interaction::{is_within, label, paths_equal, private_anonymous_file};
325
326/// Whether an entry exists without following links (including dangling links).
327pub(crate) fn entry_exists(path: &std::path::Path) -> std::io::Result<bool> {
328    let Some(parent) = path.parent() else {
329        return Ok(false);
330    };
331    let directory = match open_directory(parent) {
332        Ok(directory) => directory,
333        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
334        Err(error) => return Err(error),
335    };
336    let Some(name) = path.file_name() else {
337        return Ok(false);
338    };
339    match entry_kind(&directory, name) {
340        Ok(_) => Ok(true),
341        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
342        Err(error) => Err(error),
343    }
344}
345
346/// A no-follow stat of `path`. Unlike [`entry_exists`], this never opens `path`'s parent, so
347/// it still works on a search-only ancestor (executable but unreadable): opening a directory
348/// needs read permission on it, while naming one of its entries by an exact path needs only
349/// search (execute) permission on every ancestor up to it.
350pub(crate) fn marker_metadata(path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
351    path.symlink_metadata()
352}