Skip to main content

bw/tools/
file_output.rs

1//! Shared helpers for writing command output to files.
2//!
3//! Ports the legacy CLI's `CliUtils.saveFile` / `CliUtils.saveResultToFile`
4//! (`apps/cli/src/utils.ts`) path-resolution rules, which are part of the user-facing
5//! contract for every flag that takes an output location (`bw receive --obj`,
6//! `bw export --output`, `bw get attachment --output`). Kept separate from the commands
7//! themselves so those rules are defined — and tested — exactly once.
8
9use std::path::{Component, Path, PathBuf};
10
11use chrono::Utc;
12use color_eyre::eyre::{Context as _, Result, eyre};
13
14/// Reject paths containing a `..` segment before they reach the filesystem: a script that
15/// forwards an unsanitized path through to this CLI should not be able to resolve outside the
16/// directory it intended, even though `bw` itself only ever reads and writes with the invoking
17/// user's own permissions.
18///
19/// `flag` names the user-facing argument the path came from so the error points at the input the
20/// caller actually typed.
21pub(crate) fn reject_path_traversal(flag: &str, path: impl AsRef<Path>) -> Result<()> {
22    let path = path.as_ref();
23    let has_parent_dir_segment = path.components().any(|c| c == Component::ParentDir);
24    if has_parent_dir_segment {
25        return Err(eyre!(
26            "Invalid {flag} path: {} (path traversal segments are not allowed).",
27            path.display()
28        ));
29    }
30    Ok(())
31}
32
33/// Resolve where to write a command's file output, porting `CliUtils.saveFile`'s rules exactly:
34///
35/// | `output`                              | result                                        |
36/// | ------------------------------------- | --------------------------------------------- |
37/// | absent / empty                        | `<cwd>/<default_file_name>`, no directories created |
38/// | no path separator (`report.json`)     | `<cwd>/report.json`, no directories created   |
39/// | trailing separator (`out/`)           | `out/<default_file_name>`, `mkdir -p out`     |
40/// | separator, no trailing (`out/x.json`) | `out/x.json`, `mkdir -p out`                  |
41///
42/// The "no separator" case deliberately does *not* create directories — there are none to
43/// create — which is why it is distinct from the third row rather than folded into it.
44///
45/// Relative results are made absolute against the current directory (legacy's
46/// `path.resolve`). The resolved path is checked for `..` segments; the resolution happens
47/// first so a `..` hidden behind a relative prefix is still caught.
48pub(crate) fn resolve_output_path(
49    output: Option<&str>,
50    default_file_name: &str,
51) -> Result<PathBuf> {
52    let cwd = std::env::current_dir().wrap_err("Could not determine the current directory")?;
53
54    let (path, create_parents) = match output.filter(|o| !o.is_empty()) {
55        None => (cwd.join(default_file_name), false),
56        Some(output) => {
57            if contains_separator(output) {
58                let path = if ends_with_separator(output) {
59                    // Trailing separator means "this is a directory"; append the default name.
60                    Path::new(output).join(default_file_name)
61                } else {
62                    PathBuf::from(output)
63                };
64                (path, true)
65            } else {
66                // A bare file name is always relative to the working directory, never to
67                // wherever the default name would have gone.
68                (cwd.join(output), false)
69            }
70        }
71    };
72
73    let path = if path.is_relative() {
74        cwd.join(path)
75    } else {
76        path
77    };
78
79    reject_path_traversal("output", &path)?;
80
81    if create_parents
82        && let Some(parent) = path.parent()
83        && !parent.exists()
84    {
85        create_dir_all_private(parent)
86            .wrap_err_with(|| format!("Could not create directory {}", parent.display()))?;
87    }
88
89    Ok(path)
90}
91
92/// Write `data` to the location [`resolve_output_path`] picks for `(output, default_file_name)`
93/// and return the path written, so the caller can report it back to the user.
94pub(crate) fn save_file(
95    output: Option<&str>,
96    default_file_name: &str,
97    data: &[u8],
98) -> Result<PathBuf> {
99    let path = resolve_output_path(output, default_file_name)?;
100    write_file_private(&path, data)
101        .wrap_err_with(|| format!("Cannot save file to {}", path.display()))?;
102    Ok(path)
103}
104
105/// Derive the file name to save a received Send's file under.
106///
107/// The name comes from the *decrypted* Send, i.e. it is chosen by whoever created the Send, so
108/// it is reduced to its final component before use: without that, a Send named `../../x` or
109/// `/etc/x` would decide where `bw receive` writes. Matches legacy's `path.basename(...)`, with
110/// the same timestamped fallback when the Send carries no file name.
111pub(crate) fn default_send_file_name(file_name: Option<&str>) -> String {
112    file_name
113        .map(Path::new)
114        .and_then(Path::file_name)
115        .map(|name| name.to_string_lossy().to_string())
116        .filter(|name| !name.is_empty())
117        .unwrap_or_else(|| format!("BitwardenSendFile-{}", Utc::now().timestamp_millis()))
118}
119
120/// `true` when `output` contains a path separator. Windows accepts both `\` and `/`, and Rust's
121/// `Path` treats both as separators there, so both are checked rather than just
122/// [`std::path::MAIN_SEPARATOR`].
123fn contains_separator(output: &str) -> bool {
124    output.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && output.contains('/'))
125}
126
127fn ends_with_separator(output: &str) -> bool {
128    output.ends_with(std::path::MAIN_SEPARATOR) || (cfg!(windows) && output.ends_with('/'))
129}
130
131/// Create `dir` and its missing parents, owner-only on unix (legacy creates them `700`).
132fn create_dir_all_private(dir: &Path) -> std::io::Result<()> {
133    #[cfg(unix)]
134    {
135        use std::os::unix::fs::DirBuilderExt as _;
136        std::fs::DirBuilder::new()
137            .recursive(true)
138            .mode(0o700)
139            .create(dir)
140    }
141    #[cfg(not(unix))]
142    {
143        std::fs::create_dir_all(dir)
144    }
145}
146
147/// Write `data` to `path`, owner-only on unix (legacy writes `0o600`).
148///
149/// The mode applies at creation only, so an existing file keeps whatever permissions it already
150/// has — this never widens them.
151fn write_file_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
152    #[cfg(unix)]
153    {
154        use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _};
155        let mut file = std::fs::OpenOptions::new()
156            .write(true)
157            .create(true)
158            .truncate(true)
159            .mode(0o600)
160            .open(path)?;
161        file.write_all(data)
162    }
163    #[cfg(not(unix))]
164    {
165        std::fs::write(path, data)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// `resolve_output_path` resolves relative results against the *process* working
174    /// directory, which is global state; changing it in a test would race every other test in
175    /// the binary. Instead the tests assert on the relationship between the result and the
176    /// current directory.
177    fn cwd() -> PathBuf {
178        std::env::current_dir().expect("cwd is readable")
179    }
180
181    // ---- resolve_output_path: the four legacy branches ----
182
183    #[test]
184    fn no_output_uses_cwd_and_default_name() {
185        let path = resolve_output_path(None, "default.bin").unwrap();
186        assert_eq!(path, cwd().join("default.bin"));
187    }
188
189    #[test]
190    fn empty_output_is_treated_as_absent() {
191        // Legacy checks `output != null && output !== ""`; an empty `--obj ""` must not
192        // produce a path ending in a bare separator.
193        let path = resolve_output_path(Some(""), "default.bin").unwrap();
194        assert_eq!(path, cwd().join("default.bin"));
195    }
196
197    #[test]
198    fn bare_file_name_lands_in_cwd() {
199        let path = resolve_output_path(Some("renamed.bin"), "default.bin").unwrap();
200        assert_eq!(path, cwd().join("renamed.bin"));
201    }
202
203    #[test]
204    fn directory_output_appends_the_default_name_and_creates_the_directory() {
205        let temp = std::env::temp_dir().join(format!(
206            "bw-file-output-dir-{}",
207            Utc::now().timestamp_nanos_opt().unwrap_or_default()
208        ));
209        let with_trailing_separator = format!("{}{}", temp.display(), std::path::MAIN_SEPARATOR);
210
211        let path = resolve_output_path(Some(&with_trailing_separator), "default.bin").unwrap();
212
213        assert_eq!(path, temp.join("default.bin"));
214        assert!(
215            temp.is_dir(),
216            "the target directory should have been created"
217        );
218        std::fs::remove_dir_all(&temp).ok();
219    }
220
221    #[test]
222    fn explicit_path_is_used_verbatim_and_its_parent_created() {
223        let temp = std::env::temp_dir().join(format!(
224            "bw-file-output-explicit-{}",
225            Utc::now().timestamp_nanos_opt().unwrap_or_default()
226        ));
227        let target = temp.join("nested").join("chosen.bin");
228
229        let path = resolve_output_path(Some(&target.to_string_lossy()), "default.bin").unwrap();
230
231        assert_eq!(path, target);
232        assert!(
233            target.parent().expect("has a parent").is_dir(),
234            "the parent directory should have been created"
235        );
236        std::fs::remove_dir_all(&temp).ok();
237    }
238
239    // ---- traversal rejection ----
240
241    #[test]
242    fn resolve_output_path_rejects_parent_dir_segments() {
243        let err = resolve_output_path(
244            Some(&format!("..{}escaped.bin", std::path::MAIN_SEPARATOR)),
245            "default.bin",
246        )
247        .unwrap_err();
248        assert!(
249            err.to_string().contains("path traversal"),
250            "expected a traversal rejection, got: {err}"
251        );
252    }
253
254    #[test]
255    fn reject_path_traversal_accepts_plain_paths() {
256        assert!(reject_path_traversal("--file", "secrets.txt").is_ok());
257        assert!(reject_path_traversal("--file", "/tmp/secrets.txt").is_ok());
258        assert!(reject_path_traversal("--file", "./dir/secrets.txt").is_ok());
259    }
260
261    #[test]
262    fn reject_path_traversal_rejects_parent_dir_segments() {
263        assert!(reject_path_traversal("--file", "../secrets.txt").is_err());
264        assert!(reject_path_traversal("--file", "dir/../../secrets.txt").is_err());
265        assert!(reject_path_traversal("--file", "/tmp/../etc/passwd").is_err());
266    }
267
268    #[test]
269    fn reject_path_traversal_names_the_offending_flag() {
270        let err = reject_path_traversal("--passwordfile", "../pw.txt").unwrap_err();
271        assert!(
272            err.to_string().contains("--passwordfile"),
273            "error should name the flag it came from, got: {err}"
274        );
275    }
276
277    // ---- default_send_file_name ----
278
279    #[test]
280    fn default_send_file_name_uses_the_final_component() {
281        assert_eq!(default_send_file_name(Some("secrets.txt")), "secrets.txt");
282        assert_eq!(
283            default_send_file_name(Some("some/dir/secrets.txt")),
284            "secrets.txt"
285        );
286    }
287
288    /// The Send's file name is attacker-controlled (it comes from whoever created the Send), so
289    /// a traversing or absolute name must be reduced to a bare file name rather than steering
290    /// the write.
291    #[test]
292    fn default_send_file_name_strips_traversal_and_absolute_prefixes() {
293        assert_eq!(
294            default_send_file_name(Some("../../../etc/passwd")),
295            "passwd"
296        );
297        assert_eq!(default_send_file_name(Some("/etc/passwd")), "passwd");
298    }
299
300    #[test]
301    fn default_send_file_name_falls_back_when_absent_or_unusable() {
302        for input in [None, Some(""), Some(".."), Some("/")] {
303            let name = default_send_file_name(input);
304            assert!(
305                name.starts_with("BitwardenSendFile-"),
306                "expected the timestamped fallback for {input:?}, got {name}"
307            );
308        }
309    }
310
311    // ---- save_file ----
312
313    #[test]
314    fn save_file_writes_the_data_and_returns_the_path() {
315        let temp = std::env::temp_dir().join(format!(
316            "bw-file-output-save-{}",
317            Utc::now().timestamp_nanos_opt().unwrap_or_default()
318        ));
319        let target = temp.join("saved.bin");
320
321        let path = save_file(Some(&target.to_string_lossy()), "default.bin", b"contents").unwrap();
322
323        assert_eq!(path, target);
324        assert_eq!(std::fs::read(&path).unwrap(), b"contents");
325
326        #[cfg(unix)]
327        {
328            use std::os::unix::fs::PermissionsExt as _;
329            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
330            assert_eq!(mode, 0o600, "decrypted output must be owner-only");
331        }
332
333        std::fs::remove_dir_all(&temp).ok();
334    }
335}