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 `clap` 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 for a resolved config.
125#[cfg(unix)]
126pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
127    unix::install(config)
128}
129
130/// Install stdout/stderr redirection for a resolved config.
131#[cfg(not(unix))]
132pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
133    let _ = config;
134    Err(io::Error::new(
135        io::ErrorKind::Unsupported,
136        "stream redirection is only supported on Unix platforms",
137    ))
138}
139
140fn invalid_input(message: &str) -> io::Error {
141    io::Error::new(io::ErrorKind::InvalidInput, message)
142}
143
144fn validate_optional_file(arg_name: &str, path: Option<&Path>) -> io::Result<()> {
145    let Some(path) = path else {
146        return Ok(());
147    };
148    if path.as_os_str() == OsStr::new("") {
149        return Err(invalid_input(&format!("{arg_name} must not be empty")));
150    }
151    Ok(())
152}
153
154// On non-Unix targets `install` returns Unsupported without ever calling this,
155// but it stays compiled so `File`/`OpenOptions` remain used there.
156#[cfg_attr(not(unix), allow(dead_code))]
157fn open_append(path: &Path) -> io::Result<File> {
158    #[cfg(unix)]
159    {
160        use std::os::unix::fs::OpenOptionsExt;
161        match std::fs::symlink_metadata(path) {
162            Ok(metadata) => {
163                let file_type = metadata.file_type();
164                if file_type.is_symlink() {
165                    return Err(io::Error::new(
166                        io::ErrorKind::InvalidInput,
167                        "stream redirection target must not be a symbolic link",
168                    ));
169                }
170                if !file_type.is_file() {
171                    return Err(io::Error::new(
172                        io::ErrorKind::InvalidInput,
173                        "stream redirection target must be a regular file",
174                    ));
175                }
176                OpenOptions::new()
177                    .append(true)
178                    .custom_flags(libc::O_NOFOLLOW)
179                    .open(path)
180            }
181            Err(err) if err.kind() == io::ErrorKind::NotFound => OpenOptions::new()
182                .append(true)
183                .create_new(true)
184                .mode(0o600)
185                .custom_flags(libc::O_NOFOLLOW)
186                .open(path),
187            Err(err) => Err(err),
188        }
189    }
190    #[cfg(not(unix))]
191    {
192        OpenOptions::new().create(true).append(true).open(path)
193    }
194}
195
196#[derive(Debug, Default)]
197struct RawStreamRedirectArgs {
198    stdout_file: Option<PathBuf>,
199    stderr_file: Option<PathBuf>,
200}
201
202fn parse_raw_args<I, S>(args: I) -> io::Result<RawStreamRedirectArgs>
203where
204    I: IntoIterator<Item = S>,
205    S: Into<OsString>,
206{
207    let mut parsed = RawStreamRedirectArgs::default();
208    let mut iter = args.into_iter().map(Into::into).peekable();
209
210    while let Some(arg) = iter.next() {
211        if arg == OsStr::new("--") {
212            break;
213        }
214        let Some(arg_str) = arg.to_str() else {
215            continue;
216        };
217
218        if arg_str == STDOUT_FILE_ARG {
219            parsed.stdout_file = Some(PathBuf::from(take_raw_value(&mut iter, STDOUT_FILE_ARG)?));
220        } else if let Some(value) = arg_str.strip_prefix("--stdout-file=") {
221            parsed.stdout_file = Some(PathBuf::from(value));
222        } else if arg_str == STDERR_FILE_ARG {
223            parsed.stderr_file = Some(PathBuf::from(take_raw_value(&mut iter, STDERR_FILE_ARG)?));
224        } else if let Some(value) = arg_str.strip_prefix("--stderr-file=") {
225            parsed.stderr_file = Some(PathBuf::from(value));
226        }
227    }
228
229    Ok(parsed)
230}
231
232fn take_raw_value<I>(iter: &mut std::iter::Peekable<I>, arg_name: &str) -> io::Result<OsString>
233where
234    I: Iterator<Item = OsString>,
235{
236    let Some(value) = iter.next() else {
237        return Err(invalid_input(&format!("{arg_name} requires a value")));
238    };
239    if value
240        .to_str()
241        .is_some_and(|value| value.starts_with("--") && value != "--")
242    {
243        return Err(invalid_input(&format!("{arg_name} requires a value")));
244    }
245    Ok(value)
246}
247
248#[cfg(unix)]
249mod unix {
250    use super::{InstalledStreamRedirect, PathBuf, StreamRedirectConfig, Write, io, open_append};
251    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
252    use std::sync::atomic::{AtomicBool, Ordering};
253
254    const STDOUT_FD: RawFd = libc::STDOUT_FILENO;
255    const STDERR_FD: RawFd = libc::STDERR_FILENO;
256
257    static INSTALLED: AtomicBool = AtomicBool::new(false);
258
259    pub(super) fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
260        INSTALLED
261            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
262            .map_err(|_| {
263                io::Error::new(
264                    io::ErrorKind::AlreadyExists,
265                    "stream redirection already installed",
266                )
267            })?;
268
269        match install_once(config) {
270            Ok(installed) => Ok(installed),
271            Err(err) => {
272                INSTALLED.store(false, Ordering::SeqCst);
273                Err(err)
274            }
275        }
276    }
277
278    fn install_once(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
279        let mut stdout_target = prepare_target(STDOUT_FD, config.stdout_file.as_ref())?;
280        let mut stderr_target = prepare_target(STDERR_FD, config.stderr_file.as_ref())?;
281
282        let _ = io::stdout().flush();
283
284        if let Some(target) = &stdout_target {
285            redirect_fd(STDOUT_FD, target.file.as_raw_fd())?;
286        }
287        if let Some(target) = &stderr_target
288            && let Err(err) = redirect_fd(STDERR_FD, target.file.as_raw_fd())
289        {
290            if let Some(stdout_target) = &stdout_target {
291                let _ = redirect_fd(STDOUT_FD, stdout_target.restore.as_raw_fd());
292            }
293            return Err(err);
294        }
295
296        Ok(InstalledStreamRedirect {
297            stdout_file: config.stdout_file.clone(),
298            stderr_file: config.stderr_file.clone(),
299            stdout_restore: stdout_target.take().map(|target| target.restore),
300            stderr_restore: stderr_target.take().map(|target| target.restore),
301        })
302    }
303
304    struct PreparedTarget {
305        file: std::fs::File,
306        restore: OwnedFd,
307    }
308
309    fn prepare_target(
310        target_fd: RawFd,
311        path: Option<&PathBuf>,
312    ) -> io::Result<Option<PreparedTarget>> {
313        let Some(path) = path else {
314            return Ok(None);
315        };
316        let file = open_append(path)?;
317        let restore = dup_fd(target_fd)?;
318        Ok(Some(PreparedTarget { file, restore }))
319    }
320
321    fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
322        let duped = unsafe { libc::dup(fd) };
323        if duped < 0 {
324            return Err(io::Error::last_os_error());
325        }
326        let owned = unsafe { OwnedFd::from_raw_fd(duped) };
327        set_cloexec(owned.as_raw_fd())?;
328        Ok(owned)
329    }
330
331    pub(super) fn redirect_fd(target_fd: RawFd, replacement_fd: RawFd) -> io::Result<()> {
332        let rc = unsafe { libc::dup2(replacement_fd, target_fd) };
333        if rc < 0 {
334            Err(io::Error::last_os_error())
335        } else {
336            Ok(())
337        }
338    }
339
340    pub(super) fn mark_uninstalled() {
341        INSTALLED.store(false, Ordering::SeqCst);
342    }
343
344    fn set_cloexec(fd: RawFd) -> io::Result<()> {
345        let current = unsafe { libc::fcntl(fd, libc::F_GETFD) };
346        if current < 0 {
347            return Err(io::Error::last_os_error());
348        }
349        let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, current | libc::FD_CLOEXEC) };
350        if rc < 0 {
351            Err(io::Error::last_os_error())
352        } else {
353            Ok(())
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    #![allow(clippy::disallowed_methods)]
361    #![allow(clippy::expect_used)]
362
363    use super::*;
364    #[cfg(unix)]
365    use std::{
366        env, fs,
367        os::unix::fs::{PermissionsExt, symlink},
368        process::Command,
369        time::{SystemTime, UNIX_EPOCH},
370    };
371
372    /// Test helper: resolve a config from explicit paths and install it, the
373    /// path-based install the public surface no longer exposes as its own
374    /// function (callers now build a `StreamRedirectConfig` or use raw argv).
375    #[cfg(unix)]
376    fn install_paths(
377        stdout_file_arg: Option<PathBuf>,
378        stderr_file_arg: Option<PathBuf>,
379    ) -> io::Result<Option<InstalledStreamRedirect>> {
380        match StreamRedirectConfig::new(stdout_file_arg, stderr_file_arg)? {
381            Some(config) => install(&config).map(Some),
382            None => Ok(None),
383        }
384    }
385
386    #[test]
387    fn config_builds_optional_paths() {
388        let config =
389            StreamRedirectConfig::new(Some("/tmp/afdata-out.jsonl"), Some("/tmp/afdata.err"))
390                .expect("valid config")
391                .expect("redirection should be enabled");
392        assert_eq!(
393            config.stdout_file,
394            Some(PathBuf::from("/tmp/afdata-out.jsonl"))
395        );
396        assert_eq!(config.stderr_file, Some(PathBuf::from("/tmp/afdata.err")));
397    }
398
399    #[test]
400    fn config_without_files_disables_redirection() {
401        let config = StreamRedirectConfig::new(None::<PathBuf>, None::<PathBuf>)
402            .expect("valid empty config");
403        assert_eq!(config, None);
404    }
405
406    #[test]
407    fn raw_args_support_space_separated_values() {
408        let config = config_from_raw_args([
409            "agent-cli",
410            "--stdout-file",
411            "/tmp/agent-cli.out",
412            "--stderr-file",
413            "/tmp/agent-cli.err",
414            "ping",
415        ])
416        .expect("valid raw args")
417        .expect("stream redirection should be enabled");
418        assert_eq!(
419            config.stdout_file,
420            Some(PathBuf::from("/tmp/agent-cli.out"))
421        );
422        assert_eq!(
423            config.stderr_file,
424            Some(PathBuf::from("/tmp/agent-cli.err"))
425        );
426    }
427
428    #[test]
429    fn raw_args_support_equals_values() {
430        let config = config_from_raw_args([
431            "agent-cli",
432            "--stdout-file=/tmp/agent-cli.out",
433            "--stderr-file=/tmp/agent-cli.err",
434            "ping",
435        ])
436        .expect("valid raw args")
437        .expect("stream redirection should be enabled");
438        assert_eq!(
439            config.stdout_file,
440            Some(PathBuf::from("/tmp/agent-cli.out"))
441        );
442        assert_eq!(
443            config.stderr_file,
444            Some(PathBuf::from("/tmp/agent-cli.err"))
445        );
446    }
447
448    #[test]
449    fn raw_args_accept_single_stream() {
450        let config = config_from_raw_args(["agent-cli", "--stderr-file", "/tmp/agent-cli.err"])
451            .expect("valid raw args")
452            .expect("stderr-only redirection should be enabled");
453        assert_eq!(config.stdout_file, None);
454        assert_eq!(
455            config.stderr_file,
456            Some(PathBuf::from("/tmp/agent-cli.err"))
457        );
458    }
459
460    #[test]
461    fn raw_args_reject_missing_values() {
462        assert!(config_from_raw_args(["agent-cli", "--stdout-file"]).is_err());
463        assert!(config_from_raw_args(["agent-cli", "--stderr-file", "--help"]).is_err());
464    }
465
466    #[test]
467    fn raw_args_disable_redirection_without_file_flags() {
468        assert_eq!(
469            config_from_raw_args(["agent-cli", "ping"]).expect("valid raw args without file flags"),
470            None
471        );
472    }
473
474    #[cfg(not(unix))]
475    #[test]
476    fn install_reports_unsupported_on_non_unix() {
477        let config = StreamRedirectConfig::new(Some("stdout.log"), None::<PathBuf>)
478            .expect("valid config")
479            .expect("redirection should be enabled");
480        let err = install(&config).expect_err("non-unix install must be unsupported");
481        assert_eq!(err.kind(), io::ErrorKind::Unsupported);
482        assert!(err.to_string().contains("only supported on Unix"));
483    }
484
485    #[cfg(unix)]
486    #[test]
487    fn install_redirects_stdout_and_stderr_in_child_process() {
488        let unique = SystemTime::now()
489            .duration_since(UNIX_EPOCH)
490            .expect("system clock should be after unix epoch")
491            .as_nanos();
492        let dir = env::temp_dir().join(format!(
493            "afdata-stream-redirect-{}-{unique}",
494            std::process::id()
495        ));
496        fs::create_dir_all(&dir).expect("create temp directory");
497        let stdout_file = dir.join("stdout.log");
498        let stderr_file = dir.join("stderr.log");
499        fs::write(&stdout_file, "existing stdout\n").expect("prewrite stdout file");
500
501        let status = Command::new(env::current_exe().expect("current test executable"))
502            .arg("--exact")
503            .arg("stream_redirect::tests::stream_redirect_child_writes_to_files")
504            .arg("--nocapture")
505            .env("AFDATA_STREAM_REDIRECT_CHILD", "1")
506            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
507            .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
508            .status()
509            .expect("run child test process");
510        assert!(status.success(), "child test process failed: {status}");
511
512        assert_eq!(
513            fs::read_to_string(&stdout_file).expect("read stdout file"),
514            "existing stdout\nstdout bytes\n"
515        );
516        assert_eq!(
517            fs::read_to_string(&stderr_file).expect("read stderr file"),
518            "stderr bytes\n"
519        );
520        assert_eq!(
521            fs::metadata(&stderr_file)
522                .expect("stderr metadata")
523                .permissions()
524                .mode()
525                & 0o777,
526            0o600
527        );
528        let _ = fs::remove_dir_all(dir);
529    }
530
531    #[cfg(unix)]
532    #[test]
533    fn install_rejects_symbolic_link_targets() {
534        let unique = SystemTime::now()
535            .duration_since(UNIX_EPOCH)
536            .expect("system clock should be after unix epoch")
537            .as_nanos();
538        let dir = env::temp_dir().join(format!(
539            "afdata-stream-redirect-symlink-{}-{unique}",
540            std::process::id()
541        ));
542        fs::create_dir_all(&dir).expect("create temp directory");
543        let real_file = dir.join("real.log");
544        let symlink_file = dir.join("stdout.log");
545        fs::write(&real_file, "").expect("create real file");
546        symlink(&real_file, &symlink_file).expect("create symlink");
547
548        let err = install_paths(Some(symlink_file), None::<PathBuf>)
549            .expect_err("symlink target must be rejected");
550        assert!(
551            err.to_string().contains("symbolic link")
552                || err.to_string().contains("Too many levels"),
553            "{err}"
554        );
555        let _ = fs::remove_dir_all(dir);
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn install_drop_flushes_and_restores_stdout_in_child_process() {
561        let unique = SystemTime::now()
562            .duration_since(UNIX_EPOCH)
563            .expect("system clock should be after unix epoch")
564            .as_nanos();
565        let dir = env::temp_dir().join(format!(
566            "afdata-stream-redirect-restore-{}-{unique}",
567            std::process::id()
568        ));
569        fs::create_dir_all(&dir).expect("create temp directory");
570        let stdout_file = dir.join("stdout.log");
571
572        let output = Command::new(env::current_exe().expect("current test executable"))
573            .arg("--exact")
574            .arg("stream_redirect::tests::stream_redirect_child_restores_stdout_after_drop")
575            .arg("--nocapture")
576            .env("AFDATA_STREAM_REDIRECT_RESTORE_CHILD", "1")
577            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
578            .output()
579            .expect("run child test process");
580        assert!(
581            output.status.success(),
582            "child test process failed: status={} stderr={}",
583            output.status,
584            String::from_utf8_lossy(&output.stderr)
585        );
586
587        assert_eq!(
588            fs::read_to_string(&stdout_file).expect("read stdout file"),
589            "redirected before drop\n"
590        );
591        assert!(
592            String::from_utf8_lossy(&output.stdout).contains("stdout after restore\n"),
593            "restored stdout should reach parent capture: {}",
594            String::from_utf8_lossy(&output.stdout)
595        );
596        let _ = fs::remove_dir_all(dir);
597    }
598
599    #[cfg(unix)]
600    #[test]
601    fn install_reports_existing_redirect_and_recovers_after_drop_in_child_process() {
602        let unique = SystemTime::now()
603            .duration_since(UNIX_EPOCH)
604            .expect("system clock should be after unix epoch")
605            .as_nanos();
606        let dir = env::temp_dir().join(format!(
607            "afdata-stream-redirect-reinstall-{}-{unique}",
608            std::process::id()
609        ));
610        fs::create_dir_all(&dir).expect("create temp directory");
611        let stdout_file = dir.join("stdout.log");
612        let stderr_file = dir.join("stderr.log");
613
614        let output = Command::new(env::current_exe().expect("current test executable"))
615            .arg("--exact")
616            .arg("stream_redirect::tests::stream_redirect_child_reinstalls_after_drop")
617            .arg("--nocapture")
618            .env("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD", "1")
619            .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
620            .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
621            .output()
622            .expect("run child test process");
623        assert!(
624            output.status.success(),
625            "child test process failed: status={} stderr={}",
626            output.status,
627            String::from_utf8_lossy(&output.stderr)
628        );
629
630        assert_eq!(
631            fs::read_to_string(&stdout_file).expect("read stdout file"),
632            "first redirect still usable\n"
633        );
634        assert_eq!(
635            fs::read_to_string(&stderr_file).expect("read stderr file"),
636            "stderr after reinstall\n"
637        );
638        let _ = fs::remove_dir_all(dir);
639    }
640
641    #[cfg(unix)]
642    #[test]
643    fn stream_redirect_child_writes_to_files() {
644        if env::var_os("AFDATA_STREAM_REDIRECT_CHILD").is_none() {
645            return;
646        }
647        let stdout_file =
648            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
649        let stderr_file =
650            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
651        let _redirect = install_paths(Some(stdout_file), Some(stderr_file))
652            .expect("install stream redirect")
653            .expect("stream redirect enabled");
654        io::stdout()
655            .write_all(b"stdout bytes\n")
656            .expect("write stdout bytes");
657        io::stderr()
658            .write_all(b"stderr bytes\n")
659            .expect("write stderr bytes");
660    }
661
662    #[cfg(unix)]
663    #[test]
664    fn stream_redirect_child_restores_stdout_after_drop() {
665        if env::var_os("AFDATA_STREAM_REDIRECT_RESTORE_CHILD").is_none() {
666            return;
667        }
668        let stdout_file =
669            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
670        let redirect = install_paths(Some(stdout_file), None::<PathBuf>)
671            .expect("install stream redirect")
672            .expect("stream redirect enabled");
673        io::stdout()
674            .write_all(b"redirected before drop\n")
675            .expect("write redirected stdout bytes");
676        drop(redirect);
677        io::stdout()
678            .write_all(b"stdout after restore\n")
679            .expect("write restored stdout bytes");
680        io::stdout().flush().expect("flush restored stdout");
681    }
682
683    #[cfg(unix)]
684    #[test]
685    fn stream_redirect_child_reinstalls_after_drop() {
686        if env::var_os("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD").is_none() {
687            return;
688        }
689        let stdout_file =
690            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
691        let stderr_file =
692            PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
693        let first = install_paths(Some(stdout_file), None::<PathBuf>)
694            .expect("install first stream redirect")
695            .expect("first stream redirect enabled");
696        io::stdout()
697            .write_all(b"first redirect still usable\n")
698            .expect("write first redirected stdout bytes");
699
700        let err = install_paths(None::<PathBuf>, Some(stderr_file.clone()))
701            .expect_err("second install must report an active redirect");
702        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
703        assert!(
704            err.to_string().contains("already installed"),
705            "unexpected error message: {err}"
706        );
707
708        drop(first);
709        let second = install_paths(None::<PathBuf>, Some(stderr_file))
710            .expect("install second stream redirect after drop")
711            .expect("second stream redirect enabled");
712        io::stderr()
713            .write_all(b"stderr after reinstall\n")
714            .expect("write reinstalled stderr bytes");
715        drop(second);
716    }
717}