Refactor redirect_stdout_stderr into cliutil.

BUG=b:297288535
TEST=portage/tools/run_tests.sh

Change-Id: I7677f8528215b2a3cdca7aa671bf304d864d046f
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/bazel/+/4820053
Auto-Submit: Matt Stark <msta@google.com>
Tested-by: Matt Stark <msta@google.com>
Reviewed-by: Shuhei Takahashi <nya@chromium.org>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/portage/bin/action_wrapper/src/main.rs b/portage/bin/action_wrapper/src/main.rs
index 7b7e3b9..04c1bcc 100644
--- a/portage/bin/action_wrapper/src/main.rs
+++ b/portage/bin/action_wrapper/src/main.rs
@@ -12,7 +12,6 @@
 use serde_json::json;
 use std::collections::HashMap;
 use std::fs::File;
-use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
 use std::os::unix::process::ExitStatusExt;
 use std::path::{Path, PathBuf};
 use std::process::{Command, ExitCode, ExitStatus};
@@ -77,24 +76,6 @@
     Ok(())
 }
 
-/// Redirects stdout and stderr to the specified file, and returns the saved
-/// stdout/stderr file descriptors.
-fn redirect_stdout_stderr(output: &File) -> Result<(OwnedFd, OwnedFd)> {
-    let stdout_fd = std::io::stdout().as_raw_fd();
-    let saved_stdout_fd = nix::fcntl::fcntl(stdout_fd, nix::fcntl::F_DUPFD_CLOEXEC(3))?;
-    let saved_stdout = unsafe { OwnedFd::from_raw_fd(saved_stdout_fd) };
-
-    let stderr_fd = std::io::stderr().as_raw_fd();
-    let saved_stderr_fd = nix::fcntl::fcntl(stderr_fd, nix::fcntl::F_DUPFD_CLOEXEC(3))?;
-    let saved_stderr = unsafe { OwnedFd::from_raw_fd(saved_stderr_fd) };
-
-    let output_fd = output.as_raw_fd();
-    nix::unistd::dup2(output_fd, stdout_fd)?;
-    nix::unistd::dup2(output_fd, stderr_fd)?;
-
-    Ok((saved_stdout, saved_stderr))
-}
-
 fn merge_profiles(input_profiles_dir: &Path, output_profile_file: &Path) -> Result<()> {
     let mut merged_trace = Trace::new();
 
@@ -250,11 +231,8 @@
     std::env::set_var("RUST_BACKTRACE", "1");
 
     // Redirect stdout/stderr to a file if `--log` was specified.
-    let mut saved_output: Option<(File, OwnedFd)> = if let Some(log_name) = &args.log {
-        let file = File::create(log_name).expect("Failed to create the log file");
-        let (_saved_stdout, saved_stderr) =
-            redirect_stdout_stderr(&file).expect("Failed to redirect stdout/stderr");
-        Some((file, saved_stderr))
+    let redirector = if let Some(log_name) = &args.log {
+        Some(cliutil::StdioRedirector::new(log_name).unwrap())
     } else {
         None
     };
@@ -269,11 +247,8 @@
     match status {
         Ok(status) if status.code() == Some(0) => {}
         _ => {
-            if let Some((write_file, saved_stderr)) = saved_output.take() {
-                // Reopen the file to get an independent seek position.
-                let mut read_file = File::open(format!("/proc/self/fd/{}", write_file.as_raw_fd()))
-                    .expect("Failed to reopen the log file");
-                std::io::copy(&mut read_file, &mut File::from(saved_stderr)).ok();
+            if let Some(redirector) = redirector {
+                redirector.flush_to_real_stderr().unwrap()
             }
         }
     }
diff --git a/portage/bin/alchemist/Cargo.lock b/portage/bin/alchemist/Cargo.lock
index 4e56cd9..3634e0c 100644
--- a/portage/bin/alchemist/Cargo.lock
+++ b/portage/bin/alchemist/Cargo.lock
@@ -235,7 +235,9 @@
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "fileutil",
  "itertools",
+ "nix",
  "shell-escape",
  "tracing",
  "tracing-chrome-trace",
@@ -378,6 +380,17 @@
 ]
 
 [[package]]
+name = "fileutil"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "lazy_static",
+ "libc",
+ "tempfile",
+ "walkdir",
+]
+
+[[package]]
 name = "fnv"
 version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/portage/bin/alchemist/shared_crates.bzl b/portage/bin/alchemist/shared_crates.bzl
index e6663ee..6065be7 100644
--- a/portage/bin/alchemist/shared_crates.bzl
+++ b/portage/bin/alchemist/shared_crates.bzl
@@ -7,6 +7,7 @@
 SHARED_CRATES = [
     "//bazel/portage/common/chrome-trace:srcs",
     "//bazel/portage/common/cliutil:srcs",
+    "//bazel/portage/common/fileutil:srcs",
     "//bazel/portage/common/portage/version:srcs",
     "//bazel/portage/common/testutil:srcs",
     "//bazel/portage/common/tracing-chrome-trace:srcs",
diff --git a/portage/bin/alchemist/src.bzl b/portage/bin/alchemist/src.bzl
index d6a094a..a60b4c9 100644
--- a/portage/bin/alchemist/src.bzl
+++ b/portage/bin/alchemist/src.bzl
@@ -39,6 +39,9 @@
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/mod.rs",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/templates/source.BUILD.bazel",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/.presubmitignore",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/empty_dirs.golden.BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/empty_dirs_git.llvm-project.golden.BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/empty_dirs_git.platform2.golden.BUILD.bazel",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/nested/golden/foo/BUILD.bazel",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/nested/golden/foo/bar/BUILD.bazel",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/internal/sources/testdata/nested/golden/foo/bar/baz/BUILD.bazel",
@@ -73,6 +76,108 @@
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/templates/root.BUILD.bazel",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/generate_repo/templates/settings.bzl",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/main.rs",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/.presubmitignore",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/README.md",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/WORKSPACE.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/amd64-generic/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/amd64-generic/make.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/amd64-generic/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/amd64-generic/profiles/base/make.defaults",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/amd64-generic/toolchain.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/chromeos/config/make.conf.common",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/chromeos/config/make.conf.generic-target",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/eclass/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/eclass/myclass.eclass",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/eclass/mysuper.eclass",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/sys-kernel/linux-headers/linux-headers-4.14.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/sys-libs/gcc-libs/gcc-libs-10.2.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/sys-libs/glibc/glibc-2.35-r20.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/sys-libs/libcxx/libcxx-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/sys-libs/llvm-libunwind/llvm-libunwind-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/test-cases/distfiles/Manifest",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/test-cases/distfiles/distfiles-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/test-cases/failure/failure-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/chromiumos/test-cases/inherit/inherit-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/portage-stable/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/portage-stable/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/overlays/portage-stable/virtual/os-headers/os-headers-0-r2.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-kernel/linux-headers/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-kernel/linux-headers/linux-headers-4.14.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/gcc-libs/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/gcc-libs/gcc-libs-10.2.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/glibc/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/glibc/glibc-2.35-r20.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/libcxx/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/libcxx/libcxx-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/llvm-libunwind/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/sys-libs/llvm-libunwind/llvm-libunwind-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/distfiles/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/distfiles/distfiles-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/failure/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/failure/failure-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/inherit/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/chromiumos/test-cases/inherit/inherit-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/portage-stable/virtual/os-headers/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/packages/stage1/target/board/portage-stable/virtual/os-headers/os-headers-0-r2.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/eclean",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/emaint",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/emerge",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/equery",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/make.conf.board",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/make.conf.board_setup",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/pkg-config",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/portageq",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qcheck",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qdepends",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qfile",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qlist",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qmerge",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sdk/stage1/target/board/qsize",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sources/chromite/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sources/chromite/main.py",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sources/src/scripts/hooks/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/internal/sources/src/scripts/hooks/install/hello.sh",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/settings.bzl",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/sys-kernel/linux-headers/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/sys-libs/gcc-libs/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/sys-libs/glibc/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/sys-libs/libcxx/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/sys-libs/llvm-libunwind/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/test-cases/distfiles/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/test-cases/failure/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/test-cases/inherit/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/golden/virtual/os-headers/BUILD.bazel",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/chromite/__pycache__/README.md",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/chromite/main.py",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-generic/make.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-generic/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-generic/profiles/base/make.defaults",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-generic/toolchain.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-host/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/overlays/overlay-amd64-host/toolchain.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/scripts/hooks/install/hello.sh",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/chromeos/config/make.conf.common",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/chromeos/config/make.conf.generic-target",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/eclass/myclass.eclass",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/eclass/mysuper.eclass",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/sys-kernel/linux-headers/linux-headers-4.14.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/sys-libs/gcc-libs/gcc-libs-10.2.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/sys-libs/glibc/glibc-2.35-r20.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/sys-libs/libcxx/libcxx-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/sys-libs/llvm-libunwind/llvm-libunwind-16.0_pre484197.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/test-cases/distfiles/Manifest",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/test-cases/distfiles/distfiles-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/test-cases/failure/failure-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/chromiumos-overlay/test-cases/inherit/inherit-1.0.ebuild",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/portage-stable/metadata/layout.conf",
+    "//bazel/portage/bin/alchemist:src/bin/alchemist/testdata/input/src/third_party/portage-stable/virtual/os-headers/os-headers-0-r2.ebuild",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/ver_rs.rs",
     "//bazel/portage/bin/alchemist:src/bin/alchemist/ver_test.rs",
     "//bazel/portage/bin/alchemist:src/common.rs",
@@ -117,6 +222,14 @@
     "//bazel/portage/common/chrome-trace:src/lib.rs",
     "//bazel/portage/common/cliutil:Cargo.toml",
     "//bazel/portage/common/cliutil:src/lib.rs",
+    "//bazel/portage/common/cliutil:src/stdio_redirector.rs",
+    "//bazel/portage/common/fileutil:Cargo.toml",
+    "//bazel/portage/common/fileutil:src/dualpath.rs",
+    "//bazel/portage/common/fileutil:src/lib.rs",
+    "//bazel/portage/common/fileutil:src/move.rs",
+    "//bazel/portage/common/fileutil:src/remove.rs",
+    "//bazel/portage/common/fileutil:src/symlink_forest.rs",
+    "//bazel/portage/common/fileutil:src/tempdir.rs",
     "//bazel/portage/common/portage/version:Cargo.toml",
     "//bazel/portage/common/portage/version:src/lib.rs",
     "//bazel/portage/common/portage/version:src/version.rs",
diff --git a/portage/common/cliutil/BUILD.bazel b/portage/common/cliutil/BUILD.bazel
index 39dbdf2..311597e 100644
--- a/portage/common/cliutil/BUILD.bazel
+++ b/portage/common/cliutil/BUILD.bazel
@@ -19,11 +19,13 @@
     srcs = glob(["src/*.rs"]),
     crate_name = "cliutil",
     rustc_flags = RUSTC_DEBUG_FLAGS,
-    visibility = ["//bazel:internal"],
+    visibility = ["//visibility:public"],
     deps = [
+        "//bazel/portage/common/fileutil",
         "//bazel/portage/common/tracing-chrome-trace",
         "@alchemy_crates//:anyhow",
         "@alchemy_crates//:itertools",
+        "@alchemy_crates//:nix",
         "@alchemy_crates//:shell-escape",
         "@alchemy_crates//:tracing",
         "@alchemy_crates//:tracing-subscriber",
diff --git a/portage/common/cliutil/Cargo.toml b/portage/common/cliutil/Cargo.toml
index 8d05760..abd67d9 100644
--- a/portage/common/cliutil/Cargo.toml
+++ b/portage/common/cliutil/Cargo.toml
@@ -7,9 +7,11 @@
 
 [dependencies]
 tracing-chrome-trace = { path = "../tracing-chrome-trace" }
+fileutil = { path = "../fileutil" }
 
 anyhow.workspace = true
 itertools.workspace = true
+nix.workspace = true
 shell-escape.workspace = true
 tracing.workspace = true
 tracing-subscriber.workspace = true
diff --git a/portage/common/cliutil/src/lib.rs b/portage/common/cliutil/src/lib.rs
index 41f5eb1..2de7e5c 100644
--- a/portage/common/cliutil/src/lib.rs
+++ b/portage/common/cliutil/src/lib.rs
@@ -18,6 +18,10 @@
 use tracing_chrome_trace::ChromeTraceLayer;
 use tracing_subscriber::prelude::*;
 
+mod stdio_redirector;
+
+pub use crate::stdio_redirector::StdioRedirector;
+
 /// Wraps a CLI main function to provide the common startup/cleanup logic.
 ///
 /// Most programs implementing Alchemy actions likely want to call this function
diff --git a/portage/common/cliutil/src/stdio_redirector.rs b/portage/common/cliutil/src/stdio_redirector.rs
new file mode 100644
index 0000000..5c75118
--- /dev/null
+++ b/portage/common/cliutil/src/stdio_redirector.rs
@@ -0,0 +1,61 @@
+// Copyright 2023 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use anyhow::{Context, Result};
+use std::{fs::File, os::fd::AsRawFd, os::fd::FromRawFd, os::fd::OwnedFd, path::Path};
+
+/// Redirects stdout and stderr to the specified file, and returns the saved
+/// stdout/stderr file descriptors.
+fn redirect_stdout_stderr(output: &File) -> Result<(OwnedFd, OwnedFd)> {
+    let stdout_fd = std::io::stdout().as_raw_fd();
+    let saved_stdout_fd = nix::fcntl::fcntl(stdout_fd, nix::fcntl::F_DUPFD_CLOEXEC(3))?;
+    let saved_stdout = unsafe { OwnedFd::from_raw_fd(saved_stdout_fd) };
+
+    let stderr_fd = std::io::stderr().as_raw_fd();
+    let saved_stderr_fd = nix::fcntl::fcntl(stderr_fd, nix::fcntl::F_DUPFD_CLOEXEC(3))?;
+    let saved_stderr = unsafe { OwnedFd::from_raw_fd(saved_stderr_fd) };
+
+    let output_fd = output.as_raw_fd();
+    nix::unistd::dup2(output_fd, stdout_fd)?;
+    nix::unistd::dup2(output_fd, stderr_fd)?;
+
+    Ok((saved_stdout, saved_stderr))
+}
+
+pub struct StdioRedirector {
+    file: File,
+    saved_stdout: OwnedFd,
+    saved_stderr: File,
+}
+
+impl StdioRedirector {
+    /// Redirects stdout and stderr to the specified path.
+    pub fn new(path: &Path) -> Result<Self> {
+        let file = File::create(path).context("Failed to create the log file")?;
+        let (saved_stdout, saved_stderr) =
+            redirect_stdout_stderr(&file).context("Failed to redirect stdout/stderr")?;
+
+        Ok(Self {
+            file,
+            saved_stdout,
+            saved_stderr: saved_stderr.into(),
+        })
+    }
+
+    /// Prints the contents of the file to the real stderr.
+    /// Also consumes the redirector, which restores the original stdout/stderr.
+    pub fn flush_to_real_stderr(mut self) -> Result<()> {
+        // Reopen the file to get an independent seek position.
+        let read_file = File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd()));
+        std::io::copy(&mut read_file?, &mut self.saved_stderr)?;
+        Ok(())
+    }
+}
+
+impl Drop for StdioRedirector {
+    fn drop(&mut self) {
+        nix::unistd::dup2(self.saved_stdout.as_raw_fd(), std::io::stdout().as_raw_fd()).unwrap();
+        nix::unistd::dup2(self.saved_stderr.as_raw_fd(), std::io::stderr().as_raw_fd()).unwrap();
+    }
+}
diff --git a/portage/common/fileutil/BUILD.bazel b/portage/common/fileutil/BUILD.bazel
index 10dac7c..e1cab64 100644
--- a/portage/common/fileutil/BUILD.bazel
+++ b/portage/common/fileutil/BUILD.bazel
@@ -5,6 +5,15 @@
 load("//bazel/portage/build_defs:common.bzl", "RUSTC_DEBUG_FLAGS")
 load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
 
+filegroup(
+    name = "srcs",
+    srcs = glob(
+        ["**"],
+        exclude = ["BUILD.bazel"],
+    ),
+    visibility = ["//bazel/portage/bin/alchemist:__pkg__"],
+)
+
 rust_library(
     name = "fileutil",
     srcs = glob(["src/*.rs"]),
diff --git a/workspace_root/alchemy/Cargo.lock b/workspace_root/alchemy/Cargo.lock
index e250d48..31f7d5f 100644
--- a/workspace_root/alchemy/Cargo.lock
+++ b/workspace_root/alchemy/Cargo.lock
@@ -236,7 +236,9 @@
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "fileutil",
  "itertools",
+ "nix",
  "shell-escape",
  "tempfile",
  "tracing",