1use std::path::{Component, Path, PathBuf};
10
11use chrono::Utc;
12use color_eyre::eyre::{Context as _, Result, eyre};
13
14pub(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
33pub(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 Path::new(output).join(default_file_name)
61 } else {
62 PathBuf::from(output)
63 };
64 (path, true)
65 } else {
66 (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
92pub(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
105pub(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
120fn 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
131fn 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
147fn 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 fn cwd() -> PathBuf {
178 std::env::current_dir().expect("cwd is readable")
179 }
180
181 #[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 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 #[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 #[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 #[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 #[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}