Skip to main content

vta_cli_common/
secure_file.rs

1//! Cross-platform file / directory permission tightening for secret-bearing
2//! paths (bootstrap seeds, keystores, export bundles).
3//!
4//! The implementation is homed in [`vti_common::secure_file`] so it can be
5//! shared by every consumer (CLIs, services, and the `vti-secrets` crate's
6//! plaintext backend) without duplication. This module re-exports it for
7//! backwards-compatible `vta_cli_common::secure_file::*` call sites, and adds
8//! the CLI-facing helpers the export commands share.
9
10use std::io::ErrorKind;
11use std::path::Path;
12
13pub use vti_common::secure_file::{
14    restrict_dir_to_owner, restrict_file_to_owner, write_secret_file,
15};
16
17/// Longest slug [`did_filename_slug`] returns, so a long DID cannot push a
18/// default file name past file-system name limits.
19const MAX_SLUG_LEN: usize = 64;
20
21/// Fail early when an explicit export path already exists and `force` is not
22/// set.
23///
24/// Call this before asking the server for the export, so the operator does not
25/// enter a password and wait for an export only for the write to be refused.
26/// [`write_secret_export`] still makes the authoritative check when it creates
27/// the file.
28pub fn check_export_path(path: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
29    // `symlink_metadata` so a dangling symlink counts as existing, matching
30    // what `create_new` will do.
31    if !force && std::fs::symlink_metadata(path).is_ok() {
32        return Err(already_exists(path));
33    }
34    Ok(())
35}
36
37/// Write a secret-bearing export (such as a backup envelope) to `path`, owner
38/// read/write only.
39///
40/// Wraps [`write_secret_file`], so an existing file is never silently
41/// truncated. With `force`, an existing file at `path` is removed first and a
42/// new one created in its place; without it, an existing file is an error
43/// that names `--force`.
44pub fn write_secret_export(
45    path: &Path,
46    bytes: &[u8],
47    force: bool,
48) -> Result<(), Box<dyn std::error::Error>> {
49    if force {
50        match std::fs::remove_file(path) {
51            Ok(()) => {}
52            Err(e) if e.kind() == ErrorKind::NotFound => {}
53            Err(e) => {
54                return Err(
55                    format!("could not remove existing file {}: {e}", path.display()).into(),
56                );
57            }
58        }
59    }
60    write_secret_file(path, bytes).map_err(|e| {
61        if e.kind() == ErrorKind::AlreadyExists {
62            already_exists(path)
63        } else {
64            format!("could not write {}: {e}", path.display()).into()
65        }
66    })
67}
68
69fn already_exists(path: &Path) -> Box<dyn std::error::Error> {
70    format!(
71        "{} already exists. Choose another path with --output, or pass --force to replace it.",
72        path.display()
73    )
74    .into()
75}
76
77/// A file-name-safe slug from the last `:`-separated segment of `did`.
78///
79/// Used for default export file names. The DID comes from a server response,
80/// so it is not trusted to be a safe path component: only `[A-Za-z0-9._-]` is
81/// kept, the result is capped at 64 characters, and `fallback` is returned when
82/// nothing usable is left (no DID, an empty segment, or only dots).
83pub fn did_filename_slug(did: Option<&str>, fallback: &str) -> String {
84    let slug: String = did
85        .and_then(|d| d.rsplit(':').next())
86        .unwrap_or("")
87        .chars()
88        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
89        .take(MAX_SLUG_LEN)
90        .collect();
91    if slug.chars().all(|c| c == '.') {
92        fallback.to_string()
93    } else {
94        slug
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn tmp_dir() -> std::path::PathBuf {
103        let dir = std::env::temp_dir().join(format!("vta-test-export-{}", rand::random::<u32>()));
104        std::fs::create_dir_all(&dir).unwrap();
105        dir
106    }
107
108    #[test]
109    fn slug_keeps_an_ordinary_did_segment() {
110        assert_eq!(
111            did_filename_slug(Some("did:webvh:QmScid:example.com"), "vtc"),
112            "example.com"
113        );
114        assert_eq!(
115            did_filename_slug(Some("did:key:z6MkAbc_1-2"), "vta"),
116            "z6MkAbc_1-2"
117        );
118    }
119
120    #[test]
121    fn slug_strips_path_separators_and_other_characters() {
122        for did in [
123            "did:web:../../x",
124            "did:web:..\\..\\x",
125            "did:web:a/b c\0d%2Fe",
126            "did:web:caf\u{e9}\n",
127        ] {
128            let slug = did_filename_slug(Some(did), "vtc");
129            assert!(
130                slug.chars()
131                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
132                "{did:?} -> {slug:?}"
133            );
134            let name = format!("vtc-backup-{slug}-20260101.vtcbak");
135            assert_eq!(
136                Path::new(&name).components().count(),
137                1,
138                "{did:?} must yield a single path component, got {name:?}"
139            );
140        }
141        assert_eq!(did_filename_slug(Some("did:web:../../x"), "vtc"), "....x");
142    }
143
144    #[test]
145    fn slug_falls_back_when_nothing_usable_is_left() {
146        assert_eq!(did_filename_slug(None, "vta"), "vta");
147        assert_eq!(did_filename_slug(Some("did:web:"), "vtc"), "vtc");
148        assert_eq!(did_filename_slug(Some("did:web:.."), "vtc"), "vtc");
149        assert_eq!(did_filename_slug(Some("did:web:/\\"), "vtc"), "vtc");
150    }
151
152    #[test]
153    fn slug_is_capped() {
154        let did = format!("did:web:{}", "a".repeat(500));
155        assert_eq!(did_filename_slug(Some(&did), "vtc").len(), MAX_SLUG_LEN);
156    }
157
158    #[test]
159    fn export_refuses_an_existing_file_without_force() {
160        let dir = tmp_dir();
161        let f = dir.join("backup.vtabak");
162        std::fs::write(&f, b"original").unwrap();
163
164        assert!(check_export_path(&f, false).is_err());
165        let err = write_secret_export(&f, b"new", false).unwrap_err();
166        assert!(err.to_string().contains("--force"), "{err}");
167        assert_eq!(std::fs::read(&f).unwrap(), b"original");
168        let _ = std::fs::remove_dir_all(&dir);
169    }
170
171    #[test]
172    fn export_replaces_an_existing_file_with_force() {
173        let dir = tmp_dir();
174        let f = dir.join("backup.vtabak");
175        std::fs::write(&f, b"original").unwrap();
176
177        check_export_path(&f, true).unwrap();
178        write_secret_export(&f, b"new", true).unwrap();
179        assert_eq!(std::fs::read(&f).unwrap(), b"new");
180        #[cfg(unix)]
181        {
182            use std::os::unix::fs::PermissionsExt;
183            let mode = std::fs::metadata(&f).unwrap().permissions().mode();
184            assert_eq!(mode & 0o777, 0o600);
185        }
186        let _ = std::fs::remove_dir_all(&dir);
187    }
188
189    #[test]
190    fn export_creates_a_new_file() {
191        let dir = tmp_dir();
192        let f = dir.join("backup.vtabak");
193        check_export_path(&f, false).unwrap();
194        write_secret_export(&f, b"data", false).unwrap();
195        assert_eq!(std::fs::read(&f).unwrap(), b"data");
196        let _ = std::fs::remove_dir_all(&dir);
197    }
198}