1use std::ffi::{OsStr, OsString};
8use std::fs::{File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11
12pub const STDOUT_FILE_ARG: &str = "--stdout-file";
14
15pub const STDERR_FILE_ARG: &str = "--stderr-file";
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct StreamRedirectConfig {
21 pub stdout_file: Option<PathBuf>,
23 pub stderr_file: Option<PathBuf>,
25}
26
27impl StreamRedirectConfig {
28 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#[cfg_attr(not(unix), derive(Clone, Debug, PartialEq, Eq))]
52pub struct InstalledStreamRedirect {
53 pub stdout_file: Option<PathBuf>,
55 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
94pub 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
102pub 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
117pub 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
128pub 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#[cfg(unix)]
142pub fn install(config: &StreamRedirectConfig) -> io::Result<InstalledStreamRedirect> {
143 unix::install(config)
144}
145
146#[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}