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(STDOUT_FD, config.stdout_file.as_ref())?;
301 let mut stderr_target = prepare_target(STDERR_FD, config.stderr_file.as_ref())?;
302
303 let _ = io::stdout().flush();
304
305 if let Some(target) = &stdout_target {
306 redirect_fd(STDOUT_FD, target.file.as_raw_fd())?;
307 }
308 if let Some(target) = &stderr_target
309 && let Err(err) = redirect_fd(STDERR_FD, target.file.as_raw_fd())
310 {
311 if let Some(stdout_target) = &stdout_target {
312 let _ = redirect_fd(STDOUT_FD, stdout_target.restore.as_raw_fd());
313 }
314 return Err(err);
315 }
316
317 Ok(InstalledStreamRedirect {
318 stdout_file: config.stdout_file.clone(),
319 stderr_file: config.stderr_file.clone(),
320 stdout_restore: stdout_target.take().map(|target| target.restore),
321 stderr_restore: stderr_target.take().map(|target| target.restore),
322 })
323 }
324
325 struct PreparedTarget {
326 file: std::fs::File,
327 restore: OwnedFd,
328 }
329
330 fn prepare_target(
331 target_fd: RawFd,
332 path: Option<&PathBuf>,
333 ) -> io::Result<Option<PreparedTarget>> {
334 let Some(path) = path else {
335 return Ok(None);
336 };
337 let file = open_append(path)?;
338 let restore = dup_fd(target_fd)?;
339 Ok(Some(PreparedTarget { file, restore }))
340 }
341
342 fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
343 let duped = unsafe { libc::dup(fd) };
344 if duped < 0 {
345 return Err(io::Error::last_os_error());
346 }
347 let owned = unsafe { OwnedFd::from_raw_fd(duped) };
348 set_cloexec(owned.as_raw_fd())?;
349 Ok(owned)
350 }
351
352 pub(super) fn redirect_fd(target_fd: RawFd, replacement_fd: RawFd) -> io::Result<()> {
353 let rc = unsafe { libc::dup2(replacement_fd, target_fd) };
354 if rc < 0 {
355 Err(io::Error::last_os_error())
356 } else {
357 Ok(())
358 }
359 }
360
361 pub(super) fn mark_uninstalled() {
362 INSTALLED.store(false, Ordering::SeqCst);
363 }
364
365 fn set_cloexec(fd: RawFd) -> io::Result<()> {
366 let current = unsafe { libc::fcntl(fd, libc::F_GETFD) };
367 if current < 0 {
368 return Err(io::Error::last_os_error());
369 }
370 let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, current | libc::FD_CLOEXEC) };
371 if rc < 0 {
372 Err(io::Error::last_os_error())
373 } else {
374 Ok(())
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 #![allow(clippy::disallowed_methods)]
382 #![allow(clippy::expect_used)]
383
384 use super::*;
385 #[cfg(unix)]
386 use std::{
387 env, fs,
388 os::unix::fs::{PermissionsExt, symlink},
389 process::Command,
390 time::{SystemTime, UNIX_EPOCH},
391 };
392
393 #[cfg(unix)]
397 fn install_paths(
398 stdout_file_arg: Option<PathBuf>,
399 stderr_file_arg: Option<PathBuf>,
400 ) -> io::Result<Option<InstalledStreamRedirect>> {
401 match StreamRedirectConfig::new(stdout_file_arg, stderr_file_arg)? {
402 Some(config) => install(&config).map(Some),
403 None => Ok(None),
404 }
405 }
406
407 #[test]
408 fn config_builds_optional_paths() {
409 let config =
410 StreamRedirectConfig::new(Some("/tmp/afdata-out.jsonl"), Some("/tmp/afdata.err"))
411 .expect("valid config")
412 .expect("redirection should be enabled");
413 assert_eq!(
414 config.stdout_file,
415 Some(PathBuf::from("/tmp/afdata-out.jsonl"))
416 );
417 assert_eq!(config.stderr_file, Some(PathBuf::from("/tmp/afdata.err")));
418 }
419
420 #[test]
421 fn config_without_files_disables_redirection() {
422 let config = StreamRedirectConfig::new(None::<PathBuf>, None::<PathBuf>)
423 .expect("valid empty config");
424 assert_eq!(config, None);
425 }
426
427 #[test]
428 fn raw_args_support_space_separated_values() {
429 let config = config_from_raw_args([
430 "agent-cli",
431 "--stdout-file",
432 "/tmp/agent-cli.out",
433 "--stderr-file",
434 "/tmp/agent-cli.err",
435 "ping",
436 ])
437 .expect("valid raw args")
438 .expect("stream redirection should be enabled");
439 assert_eq!(
440 config.stdout_file,
441 Some(PathBuf::from("/tmp/agent-cli.out"))
442 );
443 assert_eq!(
444 config.stderr_file,
445 Some(PathBuf::from("/tmp/agent-cli.err"))
446 );
447 }
448
449 #[test]
450 fn raw_args_support_equals_values() {
451 let config = config_from_raw_args([
452 "agent-cli",
453 "--stdout-file=/tmp/agent-cli.out",
454 "--stderr-file=/tmp/agent-cli.err",
455 "ping",
456 ])
457 .expect("valid raw args")
458 .expect("stream redirection should be enabled");
459 assert_eq!(
460 config.stdout_file,
461 Some(PathBuf::from("/tmp/agent-cli.out"))
462 );
463 assert_eq!(
464 config.stderr_file,
465 Some(PathBuf::from("/tmp/agent-cli.err"))
466 );
467 }
468
469 #[test]
470 fn raw_args_accept_single_stream() {
471 let config = config_from_raw_args(["agent-cli", "--stderr-file", "/tmp/agent-cli.err"])
472 .expect("valid raw args")
473 .expect("stderr-only redirection should be enabled");
474 assert_eq!(config.stdout_file, None);
475 assert_eq!(
476 config.stderr_file,
477 Some(PathBuf::from("/tmp/agent-cli.err"))
478 );
479 }
480
481 #[test]
482 fn raw_args_reject_missing_values() {
483 assert!(config_from_raw_args(["agent-cli", "--stdout-file"]).is_err());
484 assert!(config_from_raw_args(["agent-cli", "--stderr-file", "--help"]).is_err());
485 }
486
487 #[test]
488 fn raw_args_disable_redirection_without_file_flags() {
489 assert_eq!(
490 config_from_raw_args(["agent-cli", "ping"]).expect("valid raw args without file flags"),
491 None
492 );
493 }
494
495 #[cfg(not(unix))]
496 #[test]
497 fn install_reports_unsupported_on_non_unix() {
498 let config = StreamRedirectConfig::new(Some("stdout.log"), None::<PathBuf>)
499 .expect("valid config")
500 .expect("redirection should be enabled");
501 let err = install(&config).expect_err("non-unix install must be unsupported");
502 assert_eq!(err.kind(), io::ErrorKind::Unsupported);
503 assert!(err.to_string().contains("only supported on Unix"));
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn install_redirects_stdout_and_stderr_in_child_process() {
509 let unique = SystemTime::now()
510 .duration_since(UNIX_EPOCH)
511 .expect("system clock should be after unix epoch")
512 .as_nanos();
513 let dir = env::temp_dir().join(format!(
514 "afdata-stream-redirect-{}-{unique}",
515 std::process::id()
516 ));
517 fs::create_dir_all(&dir).expect("create temp directory");
518 let stdout_file = dir.join("stdout.log");
519 let stderr_file = dir.join("stderr.log");
520 fs::write(&stdout_file, "existing stdout\n").expect("prewrite stdout file");
521
522 let status = Command::new(env::current_exe().expect("current test executable"))
523 .arg("--exact")
524 .arg("stream_redirect::tests::stream_redirect_child_writes_to_files")
525 .arg("--nocapture")
526 .env("AFDATA_STREAM_REDIRECT_CHILD", "1")
527 .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
528 .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
529 .status()
530 .expect("run child test process");
531 assert!(status.success(), "child test process failed: {status}");
532
533 assert_eq!(
534 fs::read_to_string(&stdout_file).expect("read stdout file"),
535 "existing stdout\nstdout bytes\n"
536 );
537 assert_eq!(
538 fs::read_to_string(&stderr_file).expect("read stderr file"),
539 "stderr bytes\n"
540 );
541 assert_eq!(
542 fs::metadata(&stderr_file)
543 .expect("stderr metadata")
544 .permissions()
545 .mode()
546 & 0o777,
547 0o600
548 );
549 let _ = fs::remove_dir_all(dir);
550 }
551
552 #[cfg(unix)]
553 #[test]
554 fn install_rejects_symbolic_link_targets() {
555 let unique = SystemTime::now()
556 .duration_since(UNIX_EPOCH)
557 .expect("system clock should be after unix epoch")
558 .as_nanos();
559 let dir = env::temp_dir().join(format!(
560 "afdata-stream-redirect-symlink-{}-{unique}",
561 std::process::id()
562 ));
563 fs::create_dir_all(&dir).expect("create temp directory");
564 let real_file = dir.join("real.log");
565 let symlink_file = dir.join("stdout.log");
566 fs::write(&real_file, "").expect("create real file");
567 symlink(&real_file, &symlink_file).expect("create symlink");
568
569 let err = install_paths(Some(symlink_file), None::<PathBuf>)
570 .expect_err("symlink target must be rejected");
571 assert!(
572 err.to_string().contains("symbolic link")
573 || err.to_string().contains("Too many levels"),
574 "{err}"
575 );
576 let _ = fs::remove_dir_all(dir);
577 }
578
579 #[cfg(unix)]
580 #[test]
581 fn install_drop_flushes_and_restores_stdout_in_child_process() {
582 let unique = SystemTime::now()
583 .duration_since(UNIX_EPOCH)
584 .expect("system clock should be after unix epoch")
585 .as_nanos();
586 let dir = env::temp_dir().join(format!(
587 "afdata-stream-redirect-restore-{}-{unique}",
588 std::process::id()
589 ));
590 fs::create_dir_all(&dir).expect("create temp directory");
591 let stdout_file = dir.join("stdout.log");
592
593 let output = Command::new(env::current_exe().expect("current test executable"))
594 .arg("--exact")
595 .arg("stream_redirect::tests::stream_redirect_child_restores_stdout_after_drop")
596 .arg("--nocapture")
597 .env("AFDATA_STREAM_REDIRECT_RESTORE_CHILD", "1")
598 .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
599 .output()
600 .expect("run child test process");
601 assert!(
602 output.status.success(),
603 "child test process failed: status={} stderr={}",
604 output.status,
605 String::from_utf8_lossy(&output.stderr)
606 );
607
608 assert_eq!(
609 fs::read_to_string(&stdout_file).expect("read stdout file"),
610 "redirected before drop\n"
611 );
612 assert!(
613 String::from_utf8_lossy(&output.stdout).contains("stdout after restore\n"),
614 "restored stdout should reach parent capture: {}",
615 String::from_utf8_lossy(&output.stdout)
616 );
617 let _ = fs::remove_dir_all(dir);
618 }
619
620 #[cfg(unix)]
621 #[test]
622 fn install_reports_existing_redirect_and_recovers_after_drop_in_child_process() {
623 let unique = SystemTime::now()
624 .duration_since(UNIX_EPOCH)
625 .expect("system clock should be after unix epoch")
626 .as_nanos();
627 let dir = env::temp_dir().join(format!(
628 "afdata-stream-redirect-reinstall-{}-{unique}",
629 std::process::id()
630 ));
631 fs::create_dir_all(&dir).expect("create temp directory");
632 let stdout_file = dir.join("stdout.log");
633 let stderr_file = dir.join("stderr.log");
634
635 let output = Command::new(env::current_exe().expect("current test executable"))
636 .arg("--exact")
637 .arg("stream_redirect::tests::stream_redirect_child_reinstalls_after_drop")
638 .arg("--nocapture")
639 .env("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD", "1")
640 .env("AFDATA_STREAM_REDIRECT_STDOUT", &stdout_file)
641 .env("AFDATA_STREAM_REDIRECT_STDERR", &stderr_file)
642 .output()
643 .expect("run child test process");
644 assert!(
645 output.status.success(),
646 "child test process failed: status={} stderr={}",
647 output.status,
648 String::from_utf8_lossy(&output.stderr)
649 );
650
651 assert_eq!(
652 fs::read_to_string(&stdout_file).expect("read stdout file"),
653 "first redirect still usable\n"
654 );
655 assert_eq!(
656 fs::read_to_string(&stderr_file).expect("read stderr file"),
657 "stderr after reinstall\n"
658 );
659 let _ = fs::remove_dir_all(dir);
660 }
661
662 #[cfg(unix)]
663 #[test]
664 fn stream_redirect_child_writes_to_files() {
665 if env::var_os("AFDATA_STREAM_REDIRECT_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 stderr_file =
671 PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
672 let _redirect = install_paths(Some(stdout_file), Some(stderr_file))
673 .expect("install stream redirect")
674 .expect("stream redirect enabled");
675 io::stdout()
676 .write_all(b"stdout bytes\n")
677 .expect("write stdout bytes");
678 io::stderr()
679 .write_all(b"stderr bytes\n")
680 .expect("write stderr bytes");
681 }
682
683 #[cfg(unix)]
684 #[test]
685 fn stream_redirect_child_restores_stdout_after_drop() {
686 if env::var_os("AFDATA_STREAM_REDIRECT_RESTORE_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 redirect = install_paths(Some(stdout_file), None::<PathBuf>)
692 .expect("install stream redirect")
693 .expect("stream redirect enabled");
694 io::stdout()
695 .write_all(b"redirected before drop\n")
696 .expect("write redirected stdout bytes");
697 drop(redirect);
698 io::stdout()
699 .write_all(b"stdout after restore\n")
700 .expect("write restored stdout bytes");
701 io::stdout().flush().expect("flush restored stdout");
702 }
703
704 #[cfg(unix)]
705 #[test]
706 fn stream_redirect_child_reinstalls_after_drop() {
707 if env::var_os("AFDATA_STREAM_REDIRECT_REINSTALL_CHILD").is_none() {
708 return;
709 }
710 let stdout_file =
711 PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDOUT").expect("stdout path"));
712 let stderr_file =
713 PathBuf::from(env::var_os("AFDATA_STREAM_REDIRECT_STDERR").expect("stderr path"));
714 let first = install_paths(Some(stdout_file), None::<PathBuf>)
715 .expect("install first stream redirect")
716 .expect("first stream redirect enabled");
717 io::stdout()
718 .write_all(b"first redirect still usable\n")
719 .expect("write first redirected stdout bytes");
720
721 let err = install_paths(None::<PathBuf>, Some(stderr_file.clone()))
722 .expect_err("second install must report an active redirect");
723 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
724 assert!(
725 err.to_string().contains("already installed"),
726 "unexpected error message: {err}"
727 );
728
729 drop(first);
730 let second = install_paths(None::<PathBuf>, Some(stderr_file))
731 .expect("install second stream redirect after drop")
732 .expect("second stream redirect enabled");
733 io::stderr()
734 .write_all(b"stderr after reinstall\n")
735 .expect("write reinstalled stderr bytes");
736 drop(second);
737 }
738}