1use 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
14pub const STDOUT_FILE_ARG: &str = "--stdout-file";
16
17pub const STDERR_FILE_ARG: &str = "--stderr-file";
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct StreamRedirectConfig {
23 pub stdout_file: Option<PathBuf>,
25 pub stderr_file: Option<PathBuf>,
27}
28
29impl StreamRedirectConfig {
30 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#[cfg_attr(not(unix), derive(Clone, Debug, PartialEq, Eq))]
54pub struct InstalledStreamRedirect {
55 pub stdout_file: Option<PathBuf>,
57 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
96pub 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
112pub 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#[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#[cfg(unix)]
147pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
148 unix::install(config)
149}
150
151#[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#[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 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 #[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}