Skip to main content

p4cli_20251/
lib.rs

1use std::io::{BufReader, Read, Write};
2use std::path::PathBuf;
3use std::process::{Child, Command, ExitStatus, Stdio};
4use std::thread;
5use std::time::Duration;
6
7/// A P4 CLI wrapper that extracts the embedded p4 binary to an isolated temporary directory.
8///
9/// Each `P4Cli` instance gets its own temp directory, eliminating cross-process races.
10/// A content-addressed cache avoids repeated zstd decompression across instances.
11/// Stale temp directories (older than 1 hour) are cleaned up on construction.
12pub struct P4Cli {
13    bin_path: PathBuf,
14    _temp_dir: PathBuf,
15}
16
17/// Collected output from a single `p4` invocation.
18///
19/// Holds raw stdout/stderr bytes (supports binary content) and the exit code.
20/// The child process is guaranteed to have been reaped before this struct is returned.
21pub struct P4Output {
22    exit_code: i32,
23    stdout: Vec<u8>,
24    stderr: Vec<u8>,
25}
26
27impl P4Output {
28    pub fn exit_code(&self) -> i32 {
29        self.exit_code
30    }
31
32    /// Returns `true` if the exit code is `0`.
33    pub fn success(&self) -> bool {
34        self.exit_code == 0
35    }
36
37    /// Raw stdout bytes (may be binary).
38    pub fn stdout(&self) -> &[u8] {
39        &self.stdout
40    }
41
42    /// Raw stderr bytes (may be binary).
43    pub fn stderr(&self) -> &[u8] {
44        &self.stderr
45    }
46
47    /// Decode stdout as UTF-8.
48    pub fn stdout_str(&self) -> Result<&str, std::str::Utf8Error> {
49        std::str::from_utf8(&self.stdout)
50    }
51
52    /// Decode stderr as UTF-8.
53    pub fn stderr_str(&self) -> Result<&str, std::str::Utf8Error> {
54        std::str::from_utf8(&self.stderr)
55    }
56
57    /// Lines of stdout (UTF-8 text only).
58    pub fn stdout_lines(&self) -> Result<Vec<&str>, std::str::Utf8Error> {
59        let s = self.stdout_str()?;
60        if s.is_empty() {
61            Ok(Vec::new())
62        } else {
63            Ok(s.lines().collect())
64        }
65    }
66
67    /// Lines of stderr (UTF-8 text only).
68    pub fn stderr_lines(&self) -> Result<Vec<&str>, std::str::Utf8Error> {
69        let s = self.stderr_str()?;
70        if s.is_empty() {
71            Ok(Vec::new())
72        } else {
73            Ok(s.lines().collect())
74        }
75    }
76}
77
78impl std::fmt::Debug for P4Output {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("P4Output")
81            .field("exit_code", &self.exit_code)
82            .field("stdout_len", &self.stdout.len())
83            .field("stderr_len", &self.stderr.len())
84            .finish()
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Cache & temporary-directory helpers
90// ---------------------------------------------------------------------------
91
92/// The base directory under `TMP` / `/tmp` where everything lives.
93fn base_dir() -> PathBuf {
94    std::env::temp_dir().join("p4cli-20251")
95}
96
97/// A simple content fingerprint for the embedded zstd payload.
98///
99/// Uses `(length, first 64 bytes)` – sufficient for cache invalidation
100/// since the payload is embedded at compile time and never tampered with.
101fn fingerprint(data: &[u8]) -> String {
102    let n = data.len();
103    let prefix = &data[..data.len().min(64)];
104    let hex: String = prefix.iter().map(|b| format!("{:02x}", b)).collect();
105    format!("{}_{}", n, hex)
106}
107
108/// Remove instance directories whose modification time is older than `cutoff`.
109///
110/// The `.cache` directory is never removed here – it is managed separately.
111fn cleanup_stale_dirs(base: &PathBuf, cutoff: std::time::SystemTime) {
112    let Ok(entries) = std::fs::read_dir(base) else {
113        return;
114    };
115    for entry in entries.flatten() {
116        let path = entry.path();
117        if !path.is_dir() {
118            continue;
119        }
120        // Skip the cache directory.
121        if path.file_name().is_some_and(|n| n == ".cache") {
122            continue;
123        }
124        if let Ok(meta) = path.metadata()
125            && let Ok(mtime) = meta.modified()
126            && mtime < cutoff
127        {
128            let _ = std::fs::remove_dir_all(&path);
129        }
130    }
131}
132
133/// Ensure the p4 binary exists on disk inside a fresh per-instance directory.
134///
135/// Caching
136/// -------
137/// The decompressed binary is cached at `base/.cache/{fingerprint}/p4_binary`.
138/// Only the very first `P4Cli` on a given build host pays the decompression
139/// cost; all subsequent instances copy (not decompress) from the cache.
140fn write_p4_cli_to_disk() -> std::io::Result<(PathBuf, PathBuf)> {
141    let zst_data = get_p4_cli_zst();
142    let fp = fingerprint(&zst_data);
143
144    let base = base_dir();
145    std::fs::create_dir_all(&base)?;
146
147    // Clean up stale instance directories (older than 1 hour).
148    let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
149    cleanup_stale_dirs(&base, cutoff);
150
151    // Ensure the cache entry exists (race-safe: unique tmp name per PID).
152    let cache_dir = base.join(".cache").join(&fp);
153    let cache_bin = cache_dir.join("p4_binary");
154    if !cache_bin.exists() {
155        std::fs::create_dir_all(&cache_dir)?;
156        let binary_data = decompress_zst(&zst_data)?;
157        let tmp = cache_dir.join(format!(".tmp.{}", std::process::id()));
158        {
159            let mut f = std::fs::File::create(&tmp)?;
160            f.write_all(&binary_data)?;
161            f.sync_all()?;
162        }
163        std::fs::rename(&tmp, &cache_bin)?;
164        set_executable_perms(&cache_bin)?;
165    }
166
167    // Per-instance directory.
168    let dir = base.join(format!(
169        "{}_{}",
170        std::process::id(),
171        std::time::SystemTime::now()
172            .duration_since(std::time::UNIX_EPOCH)
173            .map(|d| d.as_nanos())
174            .unwrap_or(0)
175    ));
176    std::fs::create_dir(&dir)?;
177    let bin_path = dir.join("p4_binary");
178
179    // Copy (not hardlink – avoids cross-device link errors on some setups).
180    std::fs::copy(&cache_bin, &bin_path)?;
181    set_executable_perms(&bin_path)?;
182
183    Ok((bin_path, dir))
184}
185
186fn decompress_zst(zst_data: &[u8]) -> std::io::Result<Vec<u8>> {
187    let mut decoder = zstd::stream::Decoder::new(zst_data)?;
188    let mut buf = Vec::new();
189    std::io::copy(&mut decoder, &mut buf)?;
190    Ok(buf)
191}
192
193#[cfg(unix)]
194fn set_executable_perms(path: &std::path::Path) -> std::io::Result<()> {
195    use std::os::unix::fs::PermissionsExt;
196    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
197}
198
199#[cfg(not(unix))]
200fn set_executable_perms(_path: &std::path::Path) -> std::io::Result<()> {
201    Ok(())
202}
203
204// ---------------------------------------------------------------------------
205// Platform-specific binary accessors
206// ---------------------------------------------------------------------------
207
208fn get_p4_cli_zst() -> Vec<u8> {
209    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
210    {
211        use p4cli_20251_win_x64::get_p4_cli_zst;
212        get_p4_cli_zst()
213    }
214
215    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
216    {
217        use p4cli_20251_mac_arm64::get_p4_cli_zst;
218        get_p4_cli_zst()
219    }
220
221    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
222    {
223        use p4cli_20251_mac_x64::get_p4_cli_zst;
224        get_p4_cli_zst()
225    }
226
227    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
228    {
229        use p4cli_20251_linux_x64::get_p4_cli_zst;
230        get_p4_cli_zst()
231    }
232
233    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
234    {
235        use p4cli_20251_linux_arm64::get_p4_cli_zst;
236        get_p4_cli_zst()
237    }
238
239    #[cfg(not(any(
240        all(target_os = "windows", target_arch = "x86_64"),
241        all(target_os = "macos", target_arch = "aarch64"),
242        all(target_os = "macos", target_arch = "x86_64"),
243        all(target_os = "linux", target_arch = "x86_64"),
244        all(target_os = "linux", target_arch = "aarch64")
245    )))]
246    {
247        compile_error!(format!(
248            "Unsupported platform: {}-{}",
249            std::env::consts::OS,
250            std::env::consts::ARCH
251        ));
252        Vec::new()
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Builder for a single p4 invocation
258// ---------------------------------------------------------------------------
259
260/// Builder-style interface for running a single `p4` command.
261///
262/// Obtain one via [`P4Cli::command()`] and chain configuration calls
263/// before calling [`run`](P4Command::run).
264pub struct P4Command<'a> {
265    cli: &'a P4Cli,
266    args: Vec<std::ffi::OsString>,
267    timeout: Option<Duration>,
268    cwd: Option<PathBuf>,
269    envs: Vec<(std::ffi::OsString, std::ffi::OsString)>,
270    stdin_data: Option<Vec<u8>>,
271}
272
273impl<'a> P4Command<'a> {
274    fn new(cli: &'a P4Cli) -> Self {
275        Self {
276            cli,
277            args: Vec::new(),
278            timeout: None,
279            cwd: None,
280            envs: Vec::new(),
281            stdin_data: None,
282        }
283    }
284
285    /// Append a single argument.
286    pub fn arg(&mut self, arg: impl AsRef<std::ffi::OsStr>) -> &mut Self {
287        self.args.push(arg.as_ref().to_os_string());
288        self
289    }
290
291    /// Append all arguments from a slice.
292    pub fn args(&mut self, args: &[impl AsRef<std::ffi::OsStr>]) -> &mut Self {
293        self.args
294            .extend(args.iter().map(|a| a.as_ref().to_os_string()));
295        self
296    }
297
298    /// Maximum wall-clock time the process is allowed to run.
299    /// When exceeded the process is killed.
300    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
301        self.timeout = Some(timeout);
302        self
303    }
304
305    /// Working directory for the child process.
306    pub fn cwd(&mut self, path: impl Into<PathBuf>) -> &mut Self {
307        self.cwd = Some(path.into());
308        self
309    }
310
311    /// Set an environment variable for the child process.
312    pub fn env(
313        &mut self,
314        key: impl Into<std::ffi::OsString>,
315        val: impl Into<std::ffi::OsString>,
316    ) -> &mut Self {
317        self.envs.push((key.into(), val.into()));
318        self
319    }
320
321    /// Provide data to be piped to the child's stdin.
322    pub fn stdin(&mut self, data: impl Into<Vec<u8>>) -> &mut Self {
323        self.stdin_data = Some(data.into());
324        self
325    }
326
327    /// Execute the command and collect output.
328    ///
329    /// Stdout and stderr are read concurrently in separate OS threads to
330    /// prevent pipe-full deadlocks. If a [`timeout`](Self::timeout) was set,
331    /// the process is killed once the deadline is reached.
332    pub fn run(&mut self) -> std::io::Result<P4Output> {
333        let mut cmd = Command::new(&self.cli.bin_path);
334        cmd.args(&self.args)
335            .stdout(Stdio::piped())
336            .stderr(Stdio::piped());
337
338        if self.stdin_data.is_some() {
339            cmd.stdin(Stdio::piped());
340        } else {
341            cmd.stdin(Stdio::null());
342        }
343
344        if let Some(ref cwd) = self.cwd {
345            cmd.current_dir(cwd);
346        }
347        for (k, v) in &self.envs {
348            cmd.env(k, v);
349        }
350
351        let mut child = cmd.spawn()?;
352
353        // Write stdin in a background thread if data was provided.
354        if let Some(data) = self.stdin_data.take()
355            && let Some(mut stdin) = child.stdin.take()
356        {
357            thread::spawn(move || {
358                let _ = stdin.write_all(&data);
359            });
360        }
361
362        let stdout = child
363            .stdout
364            .take()
365            .ok_or_else(|| std::io::Error::other("stdout was not captured"))?;
366        let stderr = child
367            .stderr
368            .take()
369            .ok_or_else(|| std::io::Error::other("stderr was not captured"))?;
370
371        // Read stdout and stderr concurrently in dedicated threads.
372        let stdout_handle = thread::spawn(move || {
373            let mut buf = Vec::new();
374            BufReader::new(stdout).read_to_end(&mut buf)?;
375            Ok::<_, std::io::Error>(buf)
376        });
377
378        let stderr_handle = thread::spawn(move || {
379            let mut buf = Vec::new();
380            BufReader::new(stderr).read_to_end(&mut buf)?;
381            Ok::<_, std::io::Error>(buf)
382        });
383
384        // Wait for the process (with optional timeout).
385        let exit_status = wait_process(&mut child, self.timeout)?;
386
387        let stdout_buf = stdout_handle
388            .join()
389            .map_err(|_| std::io::Error::other("stdout reader thread panicked"))?
390            .map_err(|e| std::io::Error::other(format!("stdout read failed: {e}")))?;
391        let stderr_buf = stderr_handle
392            .join()
393            .map_err(|_| std::io::Error::other("stderr reader thread panicked"))?
394            .map_err(|e| std::io::Error::other(format!("stderr read failed: {e}")))?;
395
396        Ok(P4Output {
397            exit_code: exit_status.code().unwrap_or(-1),
398            stdout: stdout_buf,
399            stderr: stderr_buf,
400        })
401    }
402}
403
404/// Block until `child` exits, optionally killing it after `timeout`.
405fn wait_process(child: &mut Child, timeout: Option<Duration>) -> std::io::Result<ExitStatus> {
406    match timeout {
407        None => child.wait(),
408        Some(t) => wait_with_timeout(child, t),
409    }
410}
411
412fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::io::Result<ExitStatus> {
413    let start = std::time::Instant::now();
414    loop {
415        if let Some(status) = child.try_wait()? {
416            return Ok(status);
417        }
418        if start.elapsed() >= timeout {
419            child.kill()?;
420            return child.wait();
421        }
422        thread::sleep(Duration::from_millis(50));
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Public API
428// ---------------------------------------------------------------------------
429
430impl P4Cli {
431    /// Create a new `P4Cli` instance.
432    ///
433    /// The embedded p4 binary is decompressed once and cached on disk.
434    /// Subsequent instances on the same host reuse the cache.
435    /// Stale per-instance directories (older than 1 hour) are cleaned up.
436    pub fn new() -> std::io::Result<Self> {
437        let (bin_path, temp_dir) = write_p4_cli_to_disk()?;
438        Ok(Self {
439            bin_path,
440            _temp_dir: temp_dir,
441        })
442    }
443
444    /// Convenience method: run p4 with the given arguments.
445    ///
446    /// This is equivalent to `self.command().args(args).run()`.
447    pub fn run<S: AsRef<std::ffi::OsStr>>(&self, args: &[S]) -> std::io::Result<P4Output> {
448        self.command().args(args).run()
449    }
450
451    /// Obtain a [`P4Command`] builder for fine-grained control over
452    /// working directory, environment variables, stdin, and timeout.
453    pub fn command(&self) -> P4Command<'_> {
454        P4Command::new(self)
455    }
456}
457
458impl Drop for P4Cli {
459    fn drop(&mut self) {
460        let _ = std::fs::remove_dir_all(&self._temp_dir);
461    }
462}
463
464// ---------------------------------------------------------------------------
465// Tests
466// ---------------------------------------------------------------------------
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_run_help() -> std::io::Result<()> {
474        let p4 = P4Cli::new()?;
475        let output = p4.run(&["--help"])?;
476        assert!(output.success(), "p4 --help should exit with 0");
477        let stdout = output
478            .stdout_str()
479            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
480        assert!(
481            stdout.contains("Usage:"),
482            "expected --help output to contain 'Usage:'"
483        );
484        Ok(())
485    }
486
487    #[test]
488    fn test_run_error() -> std::io::Result<()> {
489        let p4 = P4Cli::new()?;
490        let output = p4.run(&["--nonexistent-flag"])?;
491        assert!(!output.success(), "unknown flag should exit non-zero");
492        let stderr = output
493            .stderr_str()
494            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
495        assert!(
496            stderr.contains("Invalid option") || stderr.contains("error"),
497            "expected error output, got: {stderr}"
498        );
499        Ok(())
500    }
501
502    #[test]
503    fn test_multiple_instances() -> std::io::Result<()> {
504        let p4_a = P4Cli::new()?;
505        let p4_b = P4Cli::new()?;
506        assert!(p4_a.run(&["--help"])?.success());
507        assert!(p4_b.run(&["--help"])?.success());
508        Ok(())
509    }
510
511    #[test]
512    fn test_command_builder() -> std::io::Result<()> {
513        let p4 = P4Cli::new()?;
514        let output = p4.command().arg("--help").run()?;
515        assert!(output.success());
516        Ok(())
517    }
518
519    #[test]
520    fn test_timeout_kills() -> std::io::Result<()> {
521        let p4 = P4Cli::new()?;
522        // Run with a very short timeout – should be killed.
523        let output = p4
524            .command()
525            .arg("help")
526            .timeout(Duration::from_millis(1))
527            .run()?;
528        // After kill the exit code is typically non-zero (e.g. -1 or a signal number).
529        // We only verify the call does not hang.
530        assert!(!output.success() || output.exit_code() == 0);
531        Ok(())
532    }
533}