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