Skip to main content

agent_first_data/
stream_redirect.rs

1//! Optional process stdout/stderr file redirection.
2//!
3//! This is a CLI/deployment helper, not an AFDATA protocol formatter. It
4//! redirects stdout and/or stderr to caller-provided files without converting
5//! diagnostics such as Rust panic output into JSON.
6
7use std::ffi::{OsStr, OsString};
8use std::fs::{File, OpenOptions};
9use std::io;
10#[cfg(unix)]
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14/// Canonical CLI argument for redirecting stdout.
15pub const STDOUT_FILE_ARG: &str = "--stdout-file";
16
17/// Canonical CLI argument for redirecting stderr.
18pub const STDERR_FILE_ARG: &str = "--stderr-file";
19
20/// Resolved process stream redirection configuration.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct StreamRedirectConfig {
23    /// File receiving stdout bytes, if stdout redirection is enabled.
24    pub stdout_file: Option<PathBuf>,
25    /// File receiving stderr bytes, if stderr redirection is enabled.
26    pub stderr_file: Option<PathBuf>,
27}
28
29impl StreamRedirectConfig {
30    /// Build a config from explicit stdout/stderr file paths.
31    pub fn new(
32        stdout_file: Option<impl Into<PathBuf>>,
33        stderr_file: Option<impl Into<PathBuf>>,
34    ) -> io::Result<Option<Self>> {
35        let stdout_file = stdout_file.map(Into::into);
36        let stderr_file = stderr_file.map(Into::into);
37        validate_optional_file(STDOUT_FILE_ARG, stdout_file.as_deref())?;
38        validate_optional_file(STDERR_FILE_ARG, stderr_file.as_deref())?;
39        if stdout_file.is_none() && stderr_file.is_none() {
40            return Ok(None);
41        }
42        Ok(Some(Self {
43            stdout_file,
44            stderr_file,
45        }))
46    }
47}
48
49/// Guard for installed stream redirection.
50///
51/// Keep this value alive for as long as stdout/stderr should stay redirected.
52/// On drop it restores the original process fds.
53#[cfg_attr(not(unix), derive(Clone, Debug, PartialEq, Eq))]
54pub struct InstalledStreamRedirect {
55    /// File receiving stdout bytes, if stdout redirection is enabled.
56    pub stdout_file: Option<PathBuf>,
57    /// File receiving stderr bytes, if stderr redirection is enabled.
58    pub stderr_file: Option<PathBuf>,
59    #[cfg(unix)]
60    stdout_restore: Option<std::os::fd::OwnedFd>,
61    #[cfg(unix)]
62    stderr_restore: Option<std::os::fd::OwnedFd>,
63}
64
65#[cfg(unix)]
66impl std::fmt::Debug for InstalledStreamRedirect {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("InstalledStreamRedirect")
69            .field("stdout_file", &self.stdout_file)
70            .field("stderr_file", &self.stderr_file)
71            .finish_non_exhaustive()
72    }
73}
74
75#[cfg(unix)]
76impl Drop for InstalledStreamRedirect {
77    fn drop(&mut self) {
78        let _ = io::stdout().flush();
79
80        if let Some(stdout_restore) = &self.stdout_restore {
81            let _ = unix::redirect_fd(libc::STDOUT_FILENO, stdout_restore.as_raw_fd());
82        }
83        if let Some(stderr_restore) = &self.stderr_restore {
84            let _ = unix::redirect_fd(libc::STDERR_FILENO, stderr_restore.as_raw_fd());
85        }
86
87        self.stdout_restore.take();
88        self.stderr_restore.take();
89        unix::mark_uninstalled();
90    }
91}
92
93#[cfg(unix)]
94use std::os::fd::AsRawFd;
95
96/// Resolve raw CLI argv into a config (the single blessed entry).
97///
98/// This parser intentionally has no CLI-parser dependency so callers can install
99/// redirection before help/version handling emits early output. It recognizes
100/// `--stdout-file VALUE`, `--stdout-file=VALUE`, `--stderr-file VALUE`, and
101/// `--stderr-file=VALUE`. Pair it with [`install`] when you need the config
102/// first; otherwise use [`install_from_raw_args`] directly.
103pub fn config_from_raw_args<I, S>(args: I) -> io::Result<Option<StreamRedirectConfig>>
104where
105    I: IntoIterator<Item = S>,
106    S: Into<OsString>,
107{
108    let raw = parse_raw_args(args)?;
109    StreamRedirectConfig::new(raw.stdout_file, raw.stderr_file)
110}
111
112/// Install stdout/stderr redirection from raw CLI argv.
113pub fn install_from_raw_args<I, S>(args: I) -> io::Result<Option<InstalledStreamRedirect>>
114where
115    I: IntoIterator<Item = S>,
116    S: Into<OsString>,
117{
118    match config_from_raw_args(args)? {
119        Some(config) => install(&config).map(Some),
120        None => Ok(None),
121    }
122}
123
124/// Install stdout/stderr redirection from a resolved [`crate::OutputPlan`].
125///
126/// The registry-based counterpart to [`install_from_raw_args`], and the one to
127/// prefer when the application has a `CliSpec`: the plan already carries the
128/// file sinks, so this needs no second pass over argv. `install_from_raw_args`
129/// exists for applications that must install redirection *before* any parser
130/// runs, which a registry application does not.
131///
132/// Returns `Ok(None)` when the plan names no file sink.
133#[cfg(feature = "cli")]
134pub fn install_from_plan(plan: &crate::OutputPlan) -> io::Result<Option<InstalledStreamRedirect>> {
135    let config = StreamRedirectConfig::new(
136        plan.stdout_file().map(std::path::Path::to_path_buf),
137        plan.stderr_file().map(std::path::Path::to_path_buf),
138    )?;
139    match config {
140        Some(config) => install(&config).map(Some),
141        None => Ok(None),
142    }
143}
144
145/// Install stdout/stderr redirection for a resolved config.
146#[cfg(unix)]
147pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
148    unix::install(config)
149}
150
151/// Install stdout/stderr redirection for a resolved config.
152#[cfg(not(unix))]
153pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
154    let _ = config;
155    Err(io::Error::new(
156        io::ErrorKind::Unsupported,
157        "stream redirection is only supported on Unix platforms",
158    ))
159}
160
161fn invalid_input(message: &str) -> io::Error {
162    io::Error::new(io::ErrorKind::InvalidInput, message)
163}
164
165fn validate_optional_file(arg_name: &str, path: Option<&Path>) -> io::Result<()> {
166    let Some(path) = path else {
167        return Ok(());
168    };
169    if path.as_os_str() == OsStr::new("") {
170        return Err(invalid_input(&format!("{arg_name} must not be empty")));
171    }
172    Ok(())
173}
174
175// On non-Unix targets `install` returns Unsupported without ever calling this,
176// but it stays compiled so `File`/`OpenOptions` remain used there.
177#[cfg_attr(not(unix), allow(dead_code))]
178fn open_append(path: &Path) -> io::Result<File> {
179    #[cfg(unix)]
180    {
181        use std::os::unix::fs::OpenOptionsExt;
182        match std::fs::symlink_metadata(path) {
183            Ok(metadata) => {
184                let file_type = metadata.file_type();
185                if file_type.is_symlink() {
186                    return Err(io::Error::new(
187                        io::ErrorKind::InvalidInput,
188                        "stream redirection target must not be a symbolic link",
189                    ));
190                }
191                if !file_type.is_file() {
192                    return Err(io::Error::new(
193                        io::ErrorKind::InvalidInput,
194                        "stream redirection target must be a regular file",
195                    ));
196                }
197                OpenOptions::new()
198                    .append(true)
199                    .custom_flags(libc::O_NOFOLLOW)
200                    .open(path)
201            }
202            Err(err) if err.kind() == io::ErrorKind::NotFound => OpenOptions::new()
203                .append(true)
204                .create_new(true)
205                .mode(0o600)
206                .custom_flags(libc::O_NOFOLLOW)
207                .open(path),
208            Err(err) => Err(err),
209        }
210    }
211    #[cfg(not(unix))]
212    {
213        OpenOptions::new().create(true).append(true).open(path)
214    }
215}
216
217#[derive(Debug, Default)]
218struct RawStreamRedirectArgs {
219    stdout_file: Option<PathBuf>,
220    stderr_file: Option<PathBuf>,
221}
222
223fn parse_raw_args<I, S>(args: I) -> io::Result<RawStreamRedirectArgs>
224where
225    I: IntoIterator<Item = S>,
226    S: Into<OsString>,
227{
228    let mut parsed = RawStreamRedirectArgs::default();
229    let mut iter = args.into_iter().map(Into::into).peekable();
230
231    while let Some(arg) = iter.next() {
232        if arg == OsStr::new("--") {
233            break;
234        }
235        let Some(arg_str) = arg.to_str() else {
236            continue;
237        };
238
239        if arg_str == STDOUT_FILE_ARG {
240            parsed.stdout_file = Some(PathBuf::from(take_raw_value(&mut iter, STDOUT_FILE_ARG)?));
241        } else if let Some(value) = arg_str.strip_prefix("--stdout-file=") {
242            parsed.stdout_file = Some(PathBuf::from(value));
243        } else if arg_str == STDERR_FILE_ARG {
244            parsed.stderr_file = Some(PathBuf::from(take_raw_value(&mut iter, STDERR_FILE_ARG)?));
245        } else if let Some(value) = arg_str.strip_prefix("--stderr-file=") {
246            parsed.stderr_file = Some(PathBuf::from(value));
247        }
248    }
249
250    Ok(parsed)
251}
252
253fn take_raw_value<I>(iter: &mut std::iter::Peekable<I>, arg_name: &str) -> io::Result<OsString>
254where
255    I: Iterator<Item = OsString>,
256{
257    let Some(value) = iter.next() else {
258        return Err(invalid_input(&format!("{arg_name} requires a value")));
259    };
260    if value
261        .to_str()
262        .is_some_and(|value| value.starts_with("--") && value != "--")
263    {
264        return Err(invalid_input(&format!("{arg_name} requires a value")));
265    }
266    Ok(value)
267}
268
269#[cfg(unix)]
270mod unix {
271    use super::{InstalledStreamRedirect, PathBuf, StreamRedirectConfig, Write, io, open_append};
272    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
273    use std::sync::atomic::{AtomicBool, Ordering};
274
275    const STDOUT_FD: RawFd = libc::STDOUT_FILENO;
276    const STDERR_FD: RawFd = libc::STDERR_FILENO;
277
278    static INSTALLED: AtomicBool = AtomicBool::new(false);
279
280    pub(super) fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
281        INSTALLED
282            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
283            .map_err(|_| {
284                io::Error::new(
285                    io::ErrorKind::AlreadyExists,
286                    "stream redirection already installed",
287                )
288            })?;
289
290        match install_once(config) {
291            Ok(installed) => Ok(installed),
292            Err(err) => {
293                INSTALLED.store(false, Ordering::SeqCst);
294                Err(err)
295            }
296        }
297    }
298
299    fn install_once(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
300        let mut stdout_target = prepare_target(
301            STDOUT_FD,
302            super::STDOUT_FILE_ARG,
303            config.stdout_file.as_ref(),
304        )?;
305        let mut stderr_target = prepare_target(
306            STDERR_FD,
307            super::STDERR_FILE_ARG,
308            config.stderr_file.as_ref(),
309        )?;
310
311        let _ = io::stdout().flush();
312
313        if let Some(target) = &stdout_target {
314            redirect_fd(STDOUT_FD, target.file.as_raw_fd())?;
315        }
316        if let Some(target) = &stderr_target
317            && let Err(err) = redirect_fd(STDERR_FD, target.file.as_raw_fd())
318        {
319            if let Some(stdout_target) = &stdout_target {
320                let _ = redirect_fd(STDOUT_FD, stdout_target.restore.as_raw_fd());
321            }
322            return Err(err);
323        }
324
325        Ok(InstalledStreamRedirect {
326            stdout_file: config.stdout_file.clone(),
327            stderr_file: config.stderr_file.clone(),
328            stdout_restore: stdout_target.take().map(|target| target.restore),
329            stderr_restore: stderr_target.take().map(|target| target.restore),
330        })
331    }
332
333    struct PreparedTarget {
334        file: std::fs::File,
335        restore: OwnedFd,
336    }
337
338    /// Open the sink for `arg_name`. A failure names the flag and the path:
339    /// an OS error alone ("No such file or directory") does not say which of
340    /// two sinks failed, nor where it was looked for.
341    fn prepare_target(
342        target_fd: RawFd,
343        arg_name: &str,
344        path: Option<&PathBuf>,
345    ) -> io::Result<Option<PreparedTarget>> {
346        let Some(path) = path else {
347            return Ok(None);
348        };
349        let file = open_append(path).map_err(|err| {
350            io::Error::new(err.kind(), format!("{arg_name} {}: {err}", path.display()))
351        })?;
352        let restore = dup_fd(target_fd)?;
353        Ok(Some(PreparedTarget { file, restore }))
354    }
355
356    fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
357        let duped = unsafe { libc::dup(fd) };
358        if duped < 0 {
359            return Err(io::Error::last_os_error());
360        }
361        let owned = unsafe { OwnedFd::from_raw_fd(duped) };
362        set_cloexec(owned.as_raw_fd())?;
363        Ok(owned)
364    }
365
366    pub(super) fn redirect_fd(target_fd: RawFd, replacement_fd: RawFd) -> io::Result<()> {
367        let rc = unsafe { libc::dup2(replacement_fd, target_fd) };
368        if rc < 0 {
369            Err(io::Error::last_os_error())
370        } else {
371            Ok(())
372        }
373    }
374
375    pub(super) fn mark_uninstalled() {
376        INSTALLED.store(false, Ordering::SeqCst);
377    }
378
379    fn set_cloexec(fd: RawFd) -> io::Result<()> {
380        let current = unsafe { libc::fcntl(fd, libc::F_GETFD) };
381        if current < 0 {
382            return Err(io::Error::last_os_error());
383        }
384        let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, current | libc::FD_CLOEXEC) };
385        if rc < 0 {
386            Err(io::Error::last_os_error())
387        } else {
388            Ok(())
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    #![allow(clippy::disallowed_methods)]
396    #![allow(clippy::expect_used)]
397
398    use super::*;
399    #[cfg(unix)]
400    use std::{
401        env, fs,
402        os::unix::fs::{PermissionsExt, symlink},
403        process::Command,
404        time::{SystemTime, UNIX_EPOCH},
405    };
406
407    /// Test helper: resolve a config from explicit paths and install it, the
408    /// path-based install the public surface no longer exposes as its own
409    /// function (callers now build a `StreamRedirectConfig` or use raw argv).
410    #[cfg(unix)]
411    fn install_paths(
412        stdout_file_arg: Option<PathBuf>,
413        stderr_file_arg: Option<PathBuf>,
414    ) -> io::Result<Option<InstalledStreamRedirect>> {
415        match StreamRedirectConfig::new(stdout_file_arg, stderr_file_arg)? {
416            Some(config) => install(&config).map(Some),
417            None => Ok(None),
418        }
419    }
420
421    #[test]
422    fn config_builds_optional_paths() {
423        let config =
424            StreamRedirectConfig::new(Some("/tmp/afdata-out.jsonl"), Some("/tmp/afdata.err"))
425                .expect("valid config")
426                .expect("redirection should be enabled");
427        assert_eq!(
428            config.stdout_file,
429            Some(PathBuf::from("/tmp/afdata-out.jsonl"))
430        );
431        assert_eq!(config.stderr_file, Some(PathBuf::from("/tmp/afdata.err")));
432    }
433
434    #[test]
435    fn config_without_files_disables_redirection() {
436        let config = StreamRedirectConfig::new(None::<PathBuf>, None::<PathBuf>)
437            .expect("valid empty config");
438        assert_eq!(config, None);
439    }
440
441    #[test]
442    fn raw_args_support_space_separated_values() {
443        let config = config_from_raw_args([
444            "agent-cli",
445            "--stdout-file",
446            "/tmp/agent-cli.out",
447            "--stderr-file",
448            "/tmp/agent-cli.err",
449            "ping",
450        ])
451        .expect("valid raw args")
452        .expect("stream redirection should be enabled");
453        assert_eq!(
454            config.stdout_file,
455            Some(PathBuf::from("/tmp/agent-cli.out"))
456        );
457        assert_eq!(
458            config.stderr_file,
459            Some(PathBuf::from("/tmp/agent-cli.err"))
460        );
461    }
462
463    #[test]
464    fn raw_args_support_equals_values() {
465        let config = config_from_raw_args([
466            "agent-cli",
467            "--stdout-file=/tmp/agent-cli.out",
468            "--stderr-file=/tmp/agent-cli.err",
469            "ping",
470        ])
471        .expect("valid raw args")
472        .expect("stream redirection should be enabled");
473        assert_eq!(
474            config.stdout_file,
475            Some(PathBuf::from("/tmp/agent-cli.out"))
476        );
477        assert_eq!(
478            config.stderr_file,
479            Some(PathBuf::from("/tmp/agent-cli.err"))
480        );
481    }
482
483    #[test]
484    fn raw_args_accept_single_stream() {
485        let config = config_from_raw_args(["agent-cli", "--stderr-file", "/tmp/agent-cli.err"])
486            .expect("valid raw args")
487            .expect("stderr-only redirection should be enabled");
488        assert_eq!(config.stdout_file, None);
489        assert_eq!(
490            config.stderr_file,
491            Some(PathBuf::from("/tmp/agent-cli.err"))
492        );
493    }
494
495    #[test]
496    fn raw_args_reject_missing_values() {
497        assert!(config_from_raw_args(["agent-cli", "--stdout-file"]).is_err());
498        assert!(config_from_raw_args(["agent-cli", "--stderr-file", "--help"]).is_err());
499    }
500
501    #[test]
502    fn raw_args_disable_redirection_without_file_flags() {
503        assert_eq!(
504            config_from_raw_args(["agent-cli", "ping"]).expect("valid raw args without file flags"),
505            None
506        );
507    }
508
509    #[cfg(not(unix))]
510    #[test]
511    fn install_reports_unsupported_on_non_unix() {
512        let config = StreamRedirectConfig::new(Some("stdout.log"), None::<PathBuf>)
513            .expect("valid config")
514            .expect("redirection should be enabled");
515        let err = install(&config).expect_err("non-unix install must be unsupported");
516        assert_eq!(err.kind(), io::ErrorKind::Unsupported);
517        assert!(err.to_string().contains("only supported on Unix"));
518    }
519
520    #[cfg(unix)]
521    #[test]
522    fn install_redirects_stdout_and_stderr_in_child_process() {
523        let unique = SystemTime::now()
524            .duration_since(UNIX_EPOCH)
525            .expect("system clock should be after unix epoch")
526            .as_nanos();
527        let dir = env::temp_dir().join(format!(
528            "afdata-stream-redirect-{}-{unique}",
529            std::process::id()
530        ));
531        fs::create_dir_all(&dir).expect("create temp directory");
532        let stdout_file = dir.join("stdout.log");
533        let stderr_file = dir.join("stderr.log");
534        fs::write(&stdout_file, "existing stdout\n").expect("prewrite stdout file");
535
536        let status = Command::new(env::current_exe().expect("current test executable"))
537            .arg("--exact")
538            .arg("stream_redirect::tests::stream_redirect_child_writes_to_files")
539            .arg("--nocapture")
540            .env("AFDATA_STREAM_REDIRECT_CHILD", "1")
541            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
542            .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
543            .status()
544            .expect("run child test process");
545        assert!(status.success(), "child test process failed: {status}");
546
547        assert_eq!(
548            fs::read_to_string(&stdout_file).expect("read stdout file"),
549            "existing stdout\nstdout bytes\n"
550        );
551        assert_eq!(
552            fs::read_to_string(&stderr_file).expect("read stderr file"),
553            "stderr bytes\n"
554        );
555        assert_eq!(
556            fs::metadata(&stderr_file)
557                .expect("stderr metadata")
558                .permissions()
559                .mode()
560                & 0o777,
561            0o600
562        );
563        let _ = fs::remove_dir_all(dir);
564    }
565
566    #[cfg(unix)]
567    #[test]
568    fn install_rejects_symbolic_link_targets() {
569        let unique = SystemTime::now()
570            .duration_since(UNIX_EPOCH)
571            .expect("system clock should be after unix epoch")
572            .as_nanos();
573        let dir = env::temp_dir().join(format!(
574            "afdata-stream-redirect-symlink-{}-{unique}",
575            std::process::id()
576        ));
577        fs::create_dir_all(&dir).expect("create temp directory");
578        let real_file = dir.join("real.log");
579        let symlink_file = dir.join("stdout.log");
580        fs::write(&real_file, "").expect("create real file");
581        symlink(&real_file, &symlink_file).expect("create symlink");
582
583        let err = install_paths(Some(symlink_file), None::<PathBuf>)
584            .expect_err("symlink target must be rejected");
585        assert!(
586            err.to_string().contains("symbolic link")
587                || err.to_string().contains("Too many levels"),
588            "{err}"
589        );
590        let _ = fs::remove_dir_all(dir);
591    }
592
593    #[cfg(unix)]
594    #[test]
595    fn install_drop_flushes_and_restores_stdout_in_child_process() {
596        let unique = SystemTime::now()
597            .duration_since(UNIX_EPOCH)
598            .expect("system clock should be after unix epoch")
599            .as_nanos();
600        let dir = env::temp_dir().join(format!(
601            "afdata-stream-redirect-restore-{}-{unique}",
602            std::process::id()
603        ));
604        fs::create_dir_all(&dir).expect("create temp directory");
605        let stdout_file = dir.join("stdout.log");
606
607        let output = Command::new(env::current_exe().expect("current test executable"))
608            .arg("--exact")
609            .arg("stream_redirect::tests::stream_redirect_child_restores_stdout_after_drop")
610            .arg("--nocapture")
611            .env("AFDATA_STREAM_REDIRECT_RESTORE_CHILD", "1")
612            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
613            .output()
614            .expect("run child test process");
615        assert!(
616            output.status.success(),
617            "child test process failed: status={} stderr={}",
618            output.status,
619            String::from_utf8_lossy(&output.stderr)
620        );
621
622        assert_eq!(
623            fs::read_to_string(&stdout_file).expect("read stdout file"),
624            "redirected before drop\n"
625        );
626        assert!(
627            String::from_utf8_lossy(&output.stdout).contains("stdout after restore\n"),
628            "restored stdout should reach parent capture: {}",
629            String::from_utf8_lossy(&output.stdout)
630        );
631        let _ = fs::remove_dir_all(dir);
632    }
633
634    #[cfg(unix)]
635    #[test]
636    fn install_reports_existing_redirect_and_recovers_after_drop_in_child_process() {
637        let unique = SystemTime::now()
638            .duration_since(UNIX_EPOCH)
639            .expect("system clock should be after unix epoch")
640            .as_nanos();
641        let dir = env::temp_dir().join(format!(
642            "afdata-stream-redirect-reinstall-{}-{unique}",
643            std::process::id()
644        ));
645        fs::create_dir_all(&dir).expect("create temp directory");
646        let stdout_file = dir.join("stdout.log");
647        let stderr_file = dir.join("stderr.log");
648
649        let output = Command::new(env::current_exe().expect("current test executable"))
650            .arg("--exact")
651            .arg("stream_redirect::tests::stream_redirect_child_reinstalls_after_drop")
652            .arg("--nocapture")
653            .env("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD", "1")
654            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
655            .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
656            .output()
657            .expect("run child test process");
658        assert!(
659            output.status.success(),
660            "child test process failed: status={} stderr={}",
661            output.status,
662            String::from_utf8_lossy(&output.stderr)
663        );
664
665        assert_eq!(
666            fs::read_to_string(&stdout_file).expect("read stdout file"),
667            "first redirect still usable\n"
668        );
669        assert_eq!(
670            fs::read_to_string(&stderr_file).expect("read stderr file"),
671            "stderr after reinstall\n"
672        );
673        let _ = fs::remove_dir_all(dir);
674    }
675
676    #[cfg(unix)]
677    #[test]
678    fn stream_redirect_child_writes_to_files() {
679        if env::var_os("AFDATA_STREAM_REDIRECT_CHILD").is_none() {
680            return;
681        }
682        let stdout_file =
683            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
684        let stderr_file =
685            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
686        let _redirect = install_paths(Some(stdout_file), Some(stderr_file))
687            .expect("install stream redirect")
688            .expect("stream redirect enabled");
689        io::stdout()
690            .write_all(b"stdout bytes\n")
691            .expect("write stdout bytes");
692        io::stderr()
693            .write_all(b"stderr bytes\n")
694            .expect("write stderr bytes");
695    }
696
697    #[cfg(unix)]
698    #[test]
699    fn stream_redirect_child_restores_stdout_after_drop() {
700        if env::var_os("AFDATA_STREAM_REDIRECT_RESTORE_CHILD").is_none() {
701            return;
702        }
703        let stdout_file =
704            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
705        let redirect = install_paths(Some(stdout_file), None::<PathBuf>)
706            .expect("install stream redirect")
707            .expect("stream redirect enabled");
708        io::stdout()
709            .write_all(b"redirected before drop\n")
710            .expect("write redirected stdout bytes");
711        drop(redirect);
712        io::stdout()
713            .write_all(b"stdout after restore\n")
714            .expect("write restored stdout bytes");
715        io::stdout().flush().expect("flush restored stdout");
716    }
717
718    #[cfg(unix)]
719    #[test]
720    fn stream_redirect_child_reinstalls_after_drop() {
721        if env::var_os("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD").is_none() {
722            return;
723        }
724        let stdout_file =
725            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
726        let stderr_file =
727            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
728        let first = install_paths(Some(stdout_file), None::<PathBuf>)
729            .expect("install first stream redirect")
730            .expect("first stream redirect enabled");
731        io::stdout()
732            .write_all(b"first redirect still usable\n")
733            .expect("write first redirected stdout bytes");
734
735        let err = install_paths(None::<PathBuf>, Some(stderr_file.clone()))
736            .expect_err("second install must report an active redirect");
737        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
738        assert!(
739            err.to_string().contains("already installed"),
740            "unexpected error message: {err}"
741        );
742
743        drop(first);
744        let second = install_paths(None::<PathBuf>, Some(stderr_file))
745            .expect("install second stream redirect after drop")
746            .expect("second stream redirect enabled");
747        io::stderr()
748            .write_all(b"stderr after reinstall\n")
749            .expect("write reinstalled stderr bytes");
750        drop(second);
751    }
752}