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_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
104pub 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
119pub 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
130pub 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#[cfg(unix)]
144pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
145 unix::install(config)
146}
147
148#[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#[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}