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