1use std::fs;
29use std::io::Write as _;
30use std::path::{Path, PathBuf};
31
32use crate::crypto::Key;
33use crate::error::{Error, Result};
34use crate::sanitize;
35
36pub(crate) mod file_ops;
37pub(crate) mod keystore;
38pub(crate) mod manifest;
39
40use file_ops as blobs;
41use keystore::{KeyStore, VaultConfig};
42use manifest::{EntryMetadata, ManifestMap};
43
44pub struct Vault {
82 path: PathBuf,
83 keystore: KeyStore,
84}
85
86impl std::fmt::Debug for Vault {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("Vault")
89 .field("path", &self.path)
90 .field("unlocked", &self.keystore.is_unlocked())
91 .finish_non_exhaustive()
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct EntryInfo {
98 pub name: String,
100 pub size: u64,
102 pub is_directory: bool,
104}
105
106#[derive(Debug, Default, Clone)]
108pub struct IntegrityReport {
109 pub total_entries: usize,
111 pub verified: usize,
113 pub missing: Vec<String>,
115 pub corrupted: Vec<String>,
117}
118
119impl Vault {
120 pub fn new(path: PathBuf) -> Self {
122 Self {
123 path,
124 keystore: KeyStore::new(),
125 }
126 }
127
128 pub fn exists(&self) -> bool {
130 self.config_path().exists()
131 }
132
133 pub fn path(&self) -> &Path {
135 &self.path
136 }
137
138 pub fn is_unlocked(&self) -> bool {
140 self.keystore.is_unlocked()
141 }
142
143 fn config_path(&self) -> PathBuf {
144 self.path.join("vault.config")
145 }
146
147 fn load_config(&self) -> Result<VaultConfig> {
148 let json = fs::read_to_string(self.config_path())?;
149 VaultConfig::parse(&json)
150 }
151
152 pub fn init(&mut self, password: &str) -> Result<()> {
163 if self.exists() {
164 return Err(Error::VaultExists);
165 }
166
167 crate::fsutil::create_private_dir(&self.path)?;
168 crate::fsutil::create_private_dir(&self.path.join("d"))?;
169
170 let config = self.keystore.init(password)?;
171
172 let json = serde_json::to_string_pretty(&config).map_err(|_| Error::InvalidVault)?;
173 crate::fsutil::atomic_write(&self.config_path(), json.as_bytes())?;
174
175 manifest::save(&self.path, &ManifestMap::new(), self.keystore.master_key()?)?;
176 Ok(())
177 }
178
179 pub fn unlock(&mut self, password: &str) -> Result<()> {
184 if !self.exists() {
185 return Err(Error::VaultNotFound);
186 }
187 let config = self.load_config()?;
188 self.keystore.unlock(password, &config)
189 }
190
191 pub fn lock(&mut self) {
193 self.keystore.lock();
194 }
195
196 fn require_unlocked(&self) -> Result<Key> {
197 Ok(self.keystore.master_key()?.clone())
198 }
199
200 pub fn add(&mut self, source: &Path, name: Option<&str>) -> Result<()> {
222 let master = self.require_unlocked()?;
223
224 let source = source
225 .canonicalize()
226 .map_err(|_| Error::invalid_name(source.display()))?;
227 if !source.is_file() && !source.is_dir() {
228 return Err(Error::invalid_name(source.display()));
229 }
230
231 let root_name = match name {
232 Some(n) => n.to_string(),
233 None => source
234 .file_name()
235 .map(|n| n.to_string_lossy().to_string())
236 .ok_or_else(|| Error::invalid_name(source.display()))?,
237 };
238 sanitize::validate_new_name(&root_name)?;
239
240 let mut all = manifest::load(&self.path, &master)?;
241 if all.contains_key(&root_name) {
242 return Err(Error::EntryExists(root_name));
243 }
244
245 if source.is_dir() {
248 add_directory_tree(&self.path, &master, &source, &root_name, &mut all)?;
249 } else {
250 let meta = EntryMetadata {
251 original_name: root_name.clone(),
252 original_size: fs::metadata(&source)?.len(),
253 is_directory: false,
254 children: None,
255 };
256 blobs::write_entry(&self.path, &master, &root_name, &meta, Some(&source))?;
257 all.insert(root_name.clone(), meta);
258 }
259
260 manifest::save(&self.path, &all, &master)?;
262 Ok(())
263 }
264
265 pub fn remove(&mut self, name: &str) -> Result<()> {
269 let master = self.require_unlocked()?;
270 sanitize::validate_new_name(name)?;
271
272 let mut all = manifest::load(&self.path, &master)?;
273 if !all.contains_key(name) {
274 return Err(Error::EntryNotFound(preview(name)));
275 }
276
277 remove_subtree(&self.path, &master, name, &mut all);
278 manifest::save(&self.path, &all, &master)?;
279 Ok(())
280 }
281
282 pub fn list(&self) -> Result<Vec<EntryInfo>> {
285 let master = self.require_unlocked()?;
286 let all = manifest::load(&self.path, &master)?;
287 Ok(all
288 .iter()
289 .filter(|(k, _)| !k.contains('/'))
290 .map(|(k, m)| EntryInfo {
291 name: k.clone(),
292 size: m.original_size,
293 is_directory: m.is_directory,
294 })
295 .collect())
296 }
297
298 pub fn extract(&self, name: &str, dest: &Path) -> Result<PathBuf> {
314 let master = self.require_unlocked()?;
315
316 let all = manifest::load(&self.path, &master)?;
317 let meta = all
318 .get(name)
319 .ok_or_else(|| Error::EntryNotFound(preview(name)))?
320 .clone();
321
322 if !meta.is_directory {
323 stream_blob_to_file(&self.path, &master, name, dest, false)?;
324 return Ok(dest.to_path_buf());
325 }
326
327 crate::fsutil::create_private_dir(dest)?;
328 let prefix = format!("{name}/");
329 for (child_path, child_meta) in all.range(prefix.clone()..) {
330 if !child_path.starts_with(&prefix) {
331 break;
332 }
333 let rel = &child_path[prefix.len()..];
334 if rel.is_empty() {
335 continue;
336 }
337 let target = join_sanitized(dest, rel)?;
338 if child_meta.is_directory {
339 crate::fsutil::create_private_dir(&target)?;
340 } else {
341 stream_blob_to_file(&self.path, &master, child_path, &target, false)?;
342 }
343 }
344 Ok(dest.to_path_buf())
345 }
346
347 pub fn change_password(&mut self, old_password: &str, new_password: &str) -> Result<()> {
358 let config = self.load_config()?;
359 config.unwrap_master_key(old_password)?;
362
363 if !self.keystore.is_unlocked() {
364 self.unlock(old_password)?;
365 }
366
367 let new_config = self.keystore.rotate_password(new_password)?;
368 let json = serde_json::to_string_pretty(&new_config).map_err(|_| Error::InvalidVault)?;
369 crate::fsutil::atomic_write(&self.config_path(), json.as_bytes())?;
370 Ok(())
371 }
372
373 pub fn verify(&mut self, password: &str) -> Result<IntegrityReport> {
389 let was_unlocked = self.is_unlocked();
390 if !was_unlocked {
391 self.unlock(password)?;
392 }
393 let result = self.verify_unlocked();
394 if !was_unlocked {
395 self.lock();
396 }
397 result
398 }
399
400 fn verify_unlocked(&mut self) -> Result<IntegrityReport> {
401 let master = self.require_unlocked()?;
402 let all = manifest::load(&self.path, &master)?;
403
404 let mut report = IntegrityReport {
405 total_entries: all.len(),
406 ..IntegrityReport::default()
407 };
408
409 for (name, meta) in all.iter() {
410 match blobs::read_entry(&self.path, &master, name, meta.is_directory, |_| Ok(())) {
411 Ok(_) => report.verified += 1,
412 Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
413 report.missing.push(name.clone());
414 }
415 Err(_) => report.corrupted.push(name.clone()),
416 }
417 }
418
419 Ok(report)
420 }
421}
422
423impl Drop for Vault {
424 fn drop(&mut self) {
425 self.keystore.lock();
426 }
427}
428
429fn add_directory_tree(
432 vault_dir: &Path,
433 master: &Key,
434 source_root: &Path,
435 vault_name: &str,
436 all: &mut ManifestMap,
437) -> Result<()> {
438 use walkdir::WalkDir;
439
440 all.insert(
442 vault_name.to_string(),
443 EntryMetadata {
444 original_name: vault_name.to_string(),
445 original_size: 0,
446 is_directory: true,
447 children: Some(Vec::new()),
448 },
449 );
450 let mut dir_paths: Vec<String> = vec![vault_name.to_string()];
451
452 let entries: Vec<_> = WalkDir::new(source_root)
453 .sort_by_file_name()
454 .into_iter()
455 .collect::<std::result::Result<Vec<_>, _>>()
456 .map_err(|_| Error::invalid_name(source_root.display()))?;
457
458 for entry in entries {
461 let ft = entry.file_type();
462 if ft.is_symlink() {
463 continue;
464 }
465 let rel = entry
466 .path()
467 .strip_prefix(source_root)
468 .map_err(|_| Error::invalid_name(entry.path().display()))?
469 .to_string_lossy()
470 .to_string();
471 if rel.is_empty() {
472 continue;
473 }
474 sanitize::validate_new_name(&rel)?;
475
476 let full = format!("{vault_name}/{rel}");
477 let parent_full = full
478 .rsplit_once('/')
479 .map(|(p, _)| p.to_string())
480 .ok_or(Error::InvalidVault)?;
481
482 if ft.is_dir() {
483 all.insert(
484 full.clone(),
485 EntryMetadata {
486 original_name: full.clone(),
487 original_size: 0,
488 is_directory: true,
489 children: Some(Vec::new()),
490 },
491 );
492 dir_paths.push(full.clone());
493 } else if ft.is_file() {
494 let meta = EntryMetadata {
495 original_name: full.clone(),
496 original_size: entry.metadata().map(|m| m.len()).unwrap_or(0),
497 is_directory: false,
498 children: None,
499 };
500 blobs::write_entry(vault_dir, master, &full, &meta, Some(entry.path()))?;
501 all.insert(full.clone(), meta);
502 }
503
504 if let Some(pmeta) = all.get_mut(&parent_full) {
505 if let Some(children) = pmeta.children.as_mut() {
506 children.push(full);
507 }
508 }
509 }
510
511 for dir_path in &dir_paths {
513 if let Some(meta) = all.get(dir_path) {
514 blobs::write_entry(vault_dir, master, dir_path, meta, None)?;
515 }
516 }
517 Ok(())
518}
519
520fn remove_subtree(vault_dir: &Path, master: &Key, name: &str, all: &mut ManifestMap) {
522 let mut stack = vec![name.to_string()];
523 let mut doomed = Vec::new();
524 while let Some(cur) = stack.pop() {
525 if let Some(meta) = all.get(&cur) {
526 if let Some(children) = &meta.children {
527 stack.extend(children.iter().cloned());
528 }
529 }
530 doomed.push(cur);
531 }
532 for cur in doomed {
533 let _ = blobs::remove_blob(vault_dir, master, &cur);
534 all.remove(&cur);
535 }
536}
537
538fn join_sanitized(base: &Path, rel: &str) -> Result<PathBuf> {
540 sanitize::sanitize_stored_name(rel)?;
541 Ok(base.join(rel))
542}
543
544fn stream_blob_to_file(
546 vault_dir: &Path,
547 master: &Key,
548 entry_path: &str,
549 target: &Path,
550 is_directory: bool,
551) -> Result<()> {
552 let tmp = crate::fsutil::sibling_temp_path(target);
553
554 let outcome = (|| -> Result<()> {
555 {
556 let f = fs::File::create(&tmp)?;
557 crate::fsutil::restrict_perms(&tmp);
558 let mut w = std::io::BufWriter::new(f);
559 blobs::read_entry(vault_dir, master, entry_path, is_directory, |chunk| {
560 w.write_all(chunk)?;
561 Ok(())
562 })?;
563 w.flush()?;
564 w.get_ref().sync_all()?;
565 }
566 #[cfg(windows)]
567 if target.exists() {
568 fs::remove_file(target)?;
569 }
570 fs::rename(&tmp, target)?;
571 crate::fsutil::sync_dir(target.parent().unwrap_or_else(|| Path::new(".")));
572 Ok(())
573 })();
574
575 match outcome {
576 Ok(()) => Ok(()),
577 Err(e) => {
578 let _ = fs::remove_file(&tmp);
579 Err(e)
580 }
581 }
582}
583
584fn preview(name: &str) -> String {
585 name.chars().take(64).collect()
586}