vta_cli_common/
secure_file.rs1use 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
17const MAX_SLUG_LEN: usize = 64;
20
21pub fn check_export_path(path: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
29 if !force && std::fs::symlink_metadata(path).is_ok() {
32 return Err(already_exists(path));
33 }
34 Ok(())
35}
36
37pub 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
77pub 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}