From 1afe4a0f9d506a5aea7338b81f9a2960927a3cc2 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 18:55:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(storage):=20Dateisystem-H=C3=A4rtung,=20Tr?= =?UTF-8?q?ash,=20Rolling-Backup=20und=20Instance-Lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 11 + Cargo.toml | 1 + src/autosave.rs | 28 +- src/main.rs | 110 +++++++- src/storage.rs | 675 +++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 85 ++++-- 6 files changed, 858 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1775ac7..6bb08d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1044,6 +1054,7 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "fs2", "glam", "octotablet", "pollster", diff --git a/Cargo.toml b/Cargo.toml index 19730fd..046d196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ sha2 = "0.10" ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } url = "2.5" octotablet = "0.1.0" +fs2 = "0.4" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/autosave.rs b/src/autosave.rs index bfd24d2..24f596c 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -236,6 +236,7 @@ pub struct AutosaveController { clock: Arc, cmd_tx: Sender, resp_rx: Receiver, + read_only: bool, worker_handle: Option>, worker_restart_count: usize, } @@ -288,14 +289,30 @@ impl AutosaveController { clock, cmd_tx, resp_rx, + read_only: false, worker_handle: Some(worker_handle), worker_restart_count: 0, } } + /// Aktiviert oder deaktiviert den Nur-Lese-Modus (z. B. wenn das Notizbuch von einer anderen Instanz gelockt ist). + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + if read_only { + self.pending_pages.clear(); + self.pending_manifest = None; + self.status = AutosaveStatus::Saved; + } + } + + /// Prüft, ob der Nur-Lese-Modus aktiv ist. + pub fn is_read_only(&self) -> bool { + self.read_only + } + /// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster. pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) { - if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { + if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { return; } let page_data = PageData::from(page); @@ -315,6 +332,9 @@ impl AutosaveController { /// Markiert das Manifest als geändert (z. B. bei Seitenwechsel oder Umbenennen). pub fn mark_manifest_dirty(&mut self, manifest: &NotebookManifest) { + if self.read_only { + return; + } self.pending_manifest = Some(manifest.clone()); let now = self.clock.now(); @@ -361,6 +381,9 @@ impl AutosaveController { /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf von Debounce oder MaxWait an. pub fn update(&mut self) -> AutosaveStatus { + if self.read_only { + return AutosaveStatus::Saved; + } let now = self.clock.now(); // 1. Antworten des Hintergrund-Workers verarbeiten @@ -504,6 +527,9 @@ impl AutosaveController { /// Führt einen synchronen Flush aller anstehenden Änderungen durch. /// Gibt `Result<(), AutosaveError>` mit allen Fehlermeldungen zurück. pub fn flush(&mut self) -> Result<(), AutosaveError> { + if self.read_only { + return Ok(()); + } // Backoff für den Flush ignorieren, damit sofort geschrieben wird self.retries.clear(); diff --git a/src/main.rs b/src/main.rs index dcb68dd..3c7d478 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,6 +119,10 @@ fn main() -> Result<(), Box> { // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) let storage_base_dir = storage::default_storage_dir(); + let _ = storage::cleanup_trash( + &storage_base_dir, + std::time::Duration::from_secs(30 * 24 * 3600), + ); let available_notebooks = storage::list_notebooks(&storage_base_dir); let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() { @@ -176,6 +180,24 @@ fn main() -> Result<(), Box> { ui_state.available_notebooks = available_notebooks; let mut selection_state = SelectionState::new(); + let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { + Ok(lock) => { + autosave.set_read_only(false); + Some(lock) + } + Err(storage::StorageError::AlreadyLocked(msg)) => { + eprintln!("[Storage-Lock] {msg}"); + autosave.set_read_only(true); + ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + None + } + Err(e) => { + eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); + autosave.set_read_only(true); + None + } + }; + // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren canvas.sync_page(notebook.current_page_mut()); canvas.set_stroke_color(ui_state.pen_color); @@ -2011,6 +2033,24 @@ fn main() -> Result<(), Box> { let new_notebook_dir = storage_base_dir.join(&id); match storage::load_notebook(&new_notebook_dir) { Ok((loaded_nb, loaded_manifest)) => { + let _ = autosave.flush(); + match storage::NotebookLock::acquire(&new_notebook_dir) { + Ok(lock) => { + _notebook_lock = Some(lock); + autosave.set_read_only(false); + } + Err(storage::StorageError::AlreadyLocked(msg)) => { + eprintln!("[Storage-Lock] {msg}"); + _notebook_lock = None; + autosave.set_read_only(true); + ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + } + Err(e) => { + eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); + _notebook_lock = None; + autosave.set_read_only(true); + } + } autosave.switch_notebook_dir(new_notebook_dir.clone()); notebook = loaded_nb; manifest = loaded_manifest; @@ -2022,7 +2062,9 @@ fn main() -> Result<(), Box> { ui_state.is_document_menu_open = false; canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + if _notebook_lock.is_some() { + ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + } window.request_redraw(); } Err(err) => { @@ -2036,6 +2078,8 @@ fn main() -> Result<(), Box> { let _ = autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { Ok((new_nb, new_manifest, new_dir)) => { + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); autosave.switch_notebook_dir(new_dir.clone()); notebook = new_nb; manifest = new_manifest; @@ -2088,14 +2132,34 @@ fn main() -> Result<(), Box> { UiAction::DeleteNotebook(id) => { let target_dir = storage_base_dir.join(&id); let is_current = id == manifest.id; - match storage::delete_notebook(&target_dir) { - Ok(()) => { + let deleted_title = ui_state + .available_notebooks + .iter() + .find(|s| s.id == id) + .map(|s| s.title.clone()) + .unwrap_or_else(|| id.clone()); + + if is_current { + let _ = autosave.flush(); + drop(_notebook_lock.take()); + } + + match storage::delete_notebook(&target_dir, &storage_base_dir) { + Ok(trash_path) => { + ui_state.last_deleted_notebook = Some((trash_path, deleted_title)); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); if is_current { - if let Some(first) = ui_state.available_notebooks.first().cloned() { + if let Some(first) = ui_state + .available_notebooks + .iter() + .find(|s| s.status != storage::NotebookStatus::Damaged) + .cloned() + { let new_notebook_dir = storage_base_dir.join(&first.id); match storage::load_notebook(&new_notebook_dir) { Ok((loaded_nb, loaded_manifest)) => { + _notebook_lock = storage::NotebookLock::acquire(&new_notebook_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); autosave.switch_notebook_dir(new_notebook_dir.clone()); notebook = loaded_nb; manifest = loaded_manifest; @@ -2112,6 +2176,8 @@ fn main() -> Result<(), Box> { } } } else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") { + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); autosave.switch_notebook_dir(new_dir.clone()); notebook = new_nb; manifest = new_manifest; @@ -2125,7 +2191,7 @@ fn main() -> Result<(), Box> { ui_state.set_export_message("Standard-Notizbuch erstellt".to_string()); } } else { - ui_state.set_export_message("Dokument gelöscht".to_string()); + ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string()); } window.request_redraw(); } @@ -2135,6 +2201,40 @@ fn main() -> Result<(), Box> { } } } + UiAction::UndoDeleteNotebook => { + if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() { + match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) { + Ok(_restored_dir) => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message("Notizbuch wiederhergestellt".to_string()); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}"); + ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}")); + } + } + } + } + UiAction::RestoreNotebookBackup(id) => { + let target_dir = storage_base_dir.join(&id); + match storage::restore_manifest_from_backup(&target_dir) { + Ok(()) => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string()); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}"); + ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}")); + } + } + } + UiAction::OpenNotebookFolder(id) => { + let target_dir = storage_base_dir.join(&id); + ui_state.set_export_message(format!("Ordner: {}", target_dir.display())); + window.request_redraw(); + } UiAction::CheckForUpdates => { updater.check_for_updates(); window.request_redraw(); diff --git a/src/storage.rs b/src/storage.rs index e518473..c302b5d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -229,6 +229,8 @@ pub enum StorageError { CorruptedPage(u64, String), PageNotFound(u64), UnsupportedVersion { found: u32, supported: u32 }, + AlreadyLocked(String), + PathTraversal(String), } impl fmt::Display for StorageError { @@ -245,6 +247,8 @@ impl fmt::Display for StorageError { f, "Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})" ), + StorageError::AlreadyLocked(msg) => write!(f, "Notizbuch ist gesperrt: {msg}"), + StorageError::PathTraversal(msg) => write!(f, "Ungültiger Pfad (Path Traversal): {msg}"), } } } @@ -321,7 +325,19 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> .collect() } +/// Erstellt ein Verzeichnis mit gesicherten Rechten (0700 unter Unix). +pub fn create_secure_dir_all(path: &Path) -> io::Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700)); + } + Ok(()) +} + /// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`). +/// Setzt Berechtigung 0600 unter Unix und synchronisiert das übergeordnete Verzeichnis nach dem Rename. pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let dir = path.parent().ok_or_else(|| { io::Error::new( @@ -329,7 +345,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { "Übergeordnetes Verzeichnis nicht gefunden", ) })?; - fs::create_dir_all(dir)?; + create_secure_dir_all(dir)?; let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); let random_suffix: u64 = std::time::SystemTime::now() @@ -339,9 +355,14 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id()); let temp_path = dir.join(temp_filename); - // In temporäre Datei schreiben und auf Datenträger flushen + // In temporäre Datei schreiben, Rechte 0600 setzen und auf Datenträger flushen { let mut file = File::create(&temp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)); + } file.write_all(content)?; file.sync_all()?; } @@ -352,6 +373,15 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { return Err(e); } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + if let Ok(dir_file) = File::open(dir) { + let _ = dir_file.sync_all(); + } + } + Ok(()) } @@ -363,6 +393,7 @@ pub fn atomic_write_json(path: &Path, value: &T) -> Result<(), Sto } /// Speichert das Manifest atomar in `manifest.json`. +/// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben. pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> { let path = notebook_dir.join("manifest.json"); if path.exists() { @@ -376,20 +407,15 @@ pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result } } } + // Rolling Backup: Vor Überschreiben sichern + let rolling_bak = notebook_dir.join("manifest.json.bak"); + let _ = fs::copy(&path, &rolling_bak); } atomic_write_json(&path, manifest) } -/// Lädt das Manifest aus `manifest.json`. -pub fn load_manifest(notebook_dir: &Path) -> Result { - let path = notebook_dir.join("manifest.json"); - if !path.exists() { - return Err(StorageError::InvalidManifest(format!( - "Manifestdatei nicht gefunden: {}", - path.display() - ))); - } - let mut file = File::open(&path)?; +fn load_manifest_from_file(path: &Path) -> Result { + let mut file = File::open(path)?; let mut json_str = String::new(); file.read_to_string(&mut json_str)?; let mut manifest: NotebookManifest = serde_json::from_str(&json_str)?; @@ -411,6 +437,39 @@ pub fn load_manifest(notebook_dir: &Path) -> Result Result { + let path = notebook_dir.join("manifest.json"); + if !path.exists() { + let bak_path = notebook_dir.join("manifest.json.bak"); + if bak_path.exists() { + eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak"); + return load_manifest_from_file(&bak_path); + } + return Err(StorageError::InvalidManifest(format!( + "Manifestdatei nicht gefunden: {}", + path.display() + ))); + } + + match load_manifest_from_file(&path) { + Ok(manifest) => Ok(manifest), + Err(e) => { + let bak_path = notebook_dir.join("manifest.json.bak"); + if bak_path.exists() { + eprintln!( + "[Storage-Warnung] manifest.json ist korrupt ({e}). Versuche Wiederherstellung aus manifest.json.bak" + ); + if let Ok(bak_manifest) = load_manifest_from_file(&bak_path) { + return Ok(bak_manifest); + } + } + Err(e) + } + } +} + /// Verschiebt eine defekte Seitendatei nach `page_.json.corrupt-`. /// Nur bei Parse-/Schemafehlern aufrufen, nicht bei I/O-Fehlern! pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result { @@ -493,7 +552,7 @@ pub fn save_notebook( notebook: &Notebook, manifest: &mut NotebookManifest, ) -> Result<(), StorageError> { - fs::create_dir_all(notebook_dir)?; + create_secure_dir_all(notebook_dir)?; manifest.sync_from_notebook(notebook); @@ -511,6 +570,7 @@ pub fn save_notebook( /// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading). pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> { + let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600)); let manifest = load_manifest(notebook_dir)?; let page_ids = manifest.page_ids(); if page_ids.is_empty() { @@ -677,6 +737,14 @@ pub fn default_storage_dir() -> PathBuf { PathBuf::from("./data/notebooks") } +/// Status eines Notizbuchs in der Dokumentenliste. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum NotebookStatus { + #[default] + Normal, + Damaged, +} + /// Zusammenfassende Metadaten eines Notizbuchs für die Dokumentenliste. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NotebookSummary { @@ -684,9 +752,91 @@ pub struct NotebookSummary { pub title: String, pub page_count: usize, pub modified_at: u64, + #[serde(default)] + pub status: NotebookStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Dateibasierter Lock für ein geöffnetes Notizbuch, um parallele Schreibvorgänge mehrerer Instanzen zu verhindern. +#[derive(Debug)] +pub struct NotebookLock { + _file: File, + pub path: PathBuf, +} + +impl NotebookLock { + /// Versucht, einen exklusiven, nicht-blockierenden Lock auf `notebook_dir/.lock` zu erwerben. + pub fn acquire(notebook_dir: &Path) -> Result { + create_secure_dir_all(notebook_dir)?; + let lock_path = notebook_dir.join(".lock"); + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(StorageError::Io)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&lock_path, fs::Permissions::from_mode(0o600)); + } + + use fs2::FileExt; + match file.try_lock_exclusive() { + Ok(()) => Ok(Self { + _file: file, + path: lock_path, + }), + Err(e) => { + let kind = e.kind(); + if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) { + Err(StorageError::AlreadyLocked(format!( + "Notizbuch '{}' wird bereits von einer anderen Instanz verwendet", + notebook_dir.display() + ))) + } else { + Err(StorageError::Io(e)) + } + } + } + } +} + +/// Räumt verwaiste temporäre Dateien (*.tmp.*) auf, die älter als `max_age` sind. +pub fn cleanup_orphaned_temp_files( + notebook_dir: &Path, + max_age: std::time::Duration, +) -> io::Result { + let mut cleaned = 0; + if let Ok(entries) = fs::read_dir(notebook_dir) { + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.contains(".tmp.") { + if let Ok(metadata) = entry.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(age) = now.duration_since(modified) { + if age >= max_age && fs::remove_file(&path).is_ok() { + cleaned += 1; + } + } + } + } + } + } + } + } + } + Ok(cleaned) } /// Durchsucht das Basis-Verzeichnis nach allen vorhandenen Notizbüchern. +/// Beschädigte Notizbücher werden als `NotebookStatus::Damaged` aufgeführt. `.trash` wird ignoriert. pub fn list_notebooks(base_dir: &Path) -> Vec { let mut list = Vec::new(); let Ok(entries) = fs::read_dir(base_dir) else { @@ -696,16 +846,66 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + + // Versteckte Ordner (wie .trash, .recovery) ignorieren + if dir_name.starts_with('.') { + continue; + } + let manifest_path = path.join("manifest.json"); if manifest_path.exists() { - if let Ok(manifest) = load_manifest(&path) { - list.push(NotebookSummary { - id: manifest.id, - title: manifest.title, - page_count: manifest.page_ids.len(), - modified_at: manifest.modified_at, - }); + match load_manifest(&path) { + Ok(manifest) => { + let page_count = manifest.page_ids().len(); + list.push(NotebookSummary { + id: manifest.id, + title: manifest.title, + page_count, + modified_at: manifest.modified_at, + status: NotebookStatus::Normal, + error: None, + }); + } + Err(err) => { + let modified_at = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + list.push(NotebookSummary { + id: dir_name.to_string(), + title: format!("⚠️ {dir_name} (Beschädigt)"), + page_count: 0, + modified_at, + status: NotebookStatus::Damaged, + error: Some(err.to_string()), + }); + } } + } else { + let modified_at = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + list.push(NotebookSummary { + id: dir_name.to_string(), + title: format!("⚠️ {dir_name} (Beschädigt - kein Manifest)"), + page_count: 0, + modified_at, + status: NotebookStatus::Damaged, + error: Some("Keine manifest.json vorhanden".to_string()), + }); } } } @@ -716,11 +916,12 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { } /// Erstellt ein neues Notizbuch mit dem angegebenen Titel und initialisiert Seite 1. +/// Verhindert Kollisionen durch automatischen Suffix-Inkrement. pub fn create_new_notebook( base_dir: &Path, title: &str, ) -> Result<(Notebook, NotebookManifest, PathBuf), StorageError> { - fs::create_dir_all(base_dir)?; + create_secure_dir_all(base_dir)?; let now_ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -741,14 +942,28 @@ pub fn create_new_notebook( .collect(); let clean_slug = clean_slug.trim_matches('_'); - let id = if clean_slug.is_empty() { + let base_id = if clean_slug.is_empty() { format!("nb_{now_ts}") } else { format!("{clean_slug}_{now_ts}") }; - let notebook_dir = base_dir.join(&id); - fs::create_dir_all(¬ebook_dir)?; + let mut id = base_id.clone(); + let mut notebook_dir = base_dir.join(&id); + let mut counter = 1; + while notebook_dir.exists() { + id = format!("{base_id}_{counter}"); + notebook_dir = base_dir.join(&id); + counter += 1; + if counter > 100 { + return Err(StorageError::Io(io::Error::new( + io::ErrorKind::AlreadyExists, + "Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", + ))); + } + } + + create_secure_dir_all(¬ebook_dir)?; let notebook = Notebook::new(); let mut manifest = NotebookManifest::new(&id, title); @@ -773,12 +988,174 @@ pub fn rename_notebook( Ok(()) } -/// Löscht ein Notizbuch vollständig vom Datenträger. -pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> { - if notebook_dir.exists() { - fs::remove_dir_all(notebook_dir)?; +/// Verschiebt ein Notizbuch sicher in den Papierkorb (`/.trash/_`). +/// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält. +pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result { + if !notebook_dir.exists() { + return Err(StorageError::InvalidManifest(format!( + "Verzeichnis '{}' existiert nicht", + notebook_dir.display() + ))); } - Ok(()) + + // Prüfen auf Symlink + let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?; + if symlink_meta.file_type().is_symlink() { + return Err(StorageError::InvalidManifest(format!( + "Pfad '{}' ist ein Symlink und darf nicht gelöscht werden", + notebook_dir.display() + ))); + } + + let canon_base = base_dir.canonicalize().map_err(StorageError::Io)?; + let canon_target = notebook_dir.canonicalize().map_err(StorageError::Io)?; + + // Prüfen, dass der Pfad direkt unter base_dir liegt + if canon_target.parent() != Some(&canon_base) { + return Err(StorageError::PathTraversal(format!( + "Pfad '{}' liegt nicht direkt unter Basis-Verzeichnis '{}'", + notebook_dir.display(), + base_dir.display() + ))); + } + + // Prüfen, dass manifest.json vorhanden ist + if !canon_target.join("manifest.json").exists() { + return Err(StorageError::InvalidManifest(format!( + "Verzeichnis '{}' enthält kein manifest.json", + notebook_dir.display() + ))); + } + + let notebook_id = canon_target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + let trash_dir = canon_base.join(".trash"); + create_secure_dir_all(&trash_dir)?; + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let trash_target = trash_dir.join(format!("{notebook_id}_{ts}")); + + fs::rename(&canon_target, &trash_target)?; + + Ok(trash_target) +} + +/// Stellt ein Notizbuch aus dem Papierkorb wieder her (`/.trash/` -> `/`). +pub fn restore_notebook_from_trash( + base_dir: &Path, + trash_target: &Path, +) -> Result { + if !trash_target.exists() { + return Err(StorageError::Io(io::Error::new( + io::ErrorKind::NotFound, + format!( + "Papierkorb-Eintrag '{}' existiert nicht", + trash_target.display() + ), + ))); + } + + let canon_base = base_dir.canonicalize().map_err(StorageError::Io)?; + let canon_trash = trash_target.canonicalize().map_err(StorageError::Io)?; + + let expected_trash_dir = canon_base.join(".trash"); + if !expected_trash_dir.exists() { + return Err(StorageError::InvalidManifest( + "Papierkorb-Verzeichnis existiert nicht".to_string(), + )); + } + let canon_trash_base = expected_trash_dir + .canonicalize() + .map_err(StorageError::Io)?; + + if canon_trash.parent() != Some(&canon_trash_base) { + return Err(StorageError::PathTraversal(format!( + "Papierkorb-Pfad '{}' liegt nicht im Papierkorb", + trash_target.display() + ))); + } + + let manifest_path = canon_trash.join("manifest.json"); + let target_id = if manifest_path.exists() { + if let Ok(content) = fs::read_to_string(&manifest_path) { + serde_json::from_str::(&content) + .map(|m| m.id) + .unwrap_or_else(|_| { + canon_trash + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("restored") + .to_string() + }) + } else { + "restored".to_string() + } + } else { + "restored".to_string() + }; + + let mut restored_dir = canon_base.join(&target_id); + if restored_dir.exists() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + restored_dir = canon_base.join(format!("{target_id}_restored_{ts}")); + } + + fs::rename(&canon_trash, &restored_dir)?; + Ok(restored_dir) +} + +/// Stellt ein beschädigtes Manifest aus der Rolling-Backup-Datei (`manifest.json.bak`) wieder her. +pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageError> { + let bak_path = notebook_dir.join("manifest.json.bak"); + let manifest_path = notebook_dir.join("manifest.json"); + if bak_path.exists() { + fs::copy(&bak_path, &manifest_path).map_err(StorageError::Io)?; + Ok(()) + } else { + Err(StorageError::InvalidManifest( + "Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(), + )) + } +} + +/// Entfernt Einträge aus `.trash`, die älter als `max_age` sind. +pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Result { + let trash_dir = base_dir.join(".trash"); + if !trash_dir.exists() { + return Ok(0); + } + let mut removed = 0; + let now = std::time::SystemTime::now(); + if let Ok(entries) = fs::read_dir(&trash_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(metadata) = entry.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(age) = now.duration_since(modified) { + if age >= max_age { + if path.is_dir() { + if fs::remove_dir_all(&path).is_ok() { + removed += 1; + } + } else if fs::remove_file(&path).is_ok() { + removed += 1; + } + } + } + } + } + } + } + Ok(removed) } #[cfg(test)] @@ -1005,7 +1382,7 @@ mod tests { .any(|s| s.id == m1.id && s.title == "Höhere Mathematik")); // 5. Löschen - delete_notebook(&dir2).expect("Löschen muss klappen"); + delete_notebook(&dir2, base_dir).expect("Löschen muss klappen"); assert!(!dir2.exists()); let final_summaries = list_notebooks(base_dir); @@ -1245,4 +1622,242 @@ mod tests { assert_eq!(fs::read(&dest_manifest).unwrap(), orig_manifest_content); assert_eq!(fs::read(&dest_page).unwrap(), orig_page_content); } + + #[test] + #[cfg(unix)] + fn test_permissions_0600_0700() { + use std::os::unix::fs::PermissionsExt; + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + let (_nb, _m, nb_dir) = create_new_notebook(base_dir, "PermTest").unwrap(); + let dir_mode = fs::metadata(&nb_dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + dir_mode, 0o700, + "Notizbuch-Verzeichnis muss 0700 Rechte haben" + ); + + let manifest_path = nb_dir.join("manifest.json"); + let manifest_mode = fs::metadata(&manifest_path).unwrap().permissions().mode() & 0o777; + assert_eq!(manifest_mode, 0o600, "manifest.json muss 0600 Rechte haben"); + + let page_path = nb_dir.join("page_1.json"); + let page_mode = fs::metadata(&page_path).unwrap().permissions().mode() & 0o777; + assert_eq!(page_mode, 0o600, "page_1.json muss 0600 Rechte haben"); + } + + #[test] + fn test_cleanup_orphaned_temp_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let old_temp = nb_dir.join("manifest.json.tmp.111.222"); + let new_temp = nb_dir.join("manifest.json.tmp.333.444"); + fs::write(&old_temp, b"old").unwrap(); + fs::write(&new_temp, b"new").unwrap(); + + // 1 Stunde alt simulieren + let cleaned = cleanup_orphaned_temp_files(nb_dir, Duration::ZERO).unwrap(); + assert_eq!( + cleaned, 2, + "Beide temporären Dateien müssen aufgeräumt werden" + ); + assert!(!old_temp.exists()); + assert!(!new_temp.exists()); + } + + #[test] + fn test_corrupted_manifest_fallback_to_bak() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let valid_manifest = NotebookManifest::new("nb_test", "Backup Test"); + let bak_path = nb_dir.join("manifest.json.bak"); + atomic_write_json(&bak_path, &valid_manifest).unwrap(); + + let broken_manifest_path = nb_dir.join("manifest.json"); + fs::write(&broken_manifest_path, b"{ broken json ... ").unwrap(); + + let loaded = load_manifest(nb_dir).expect("Muss aus .bak geladen werden"); + assert_eq!(loaded.id, "nb_test"); + assert_eq!(loaded.title, "Backup Test"); + } + + #[test] + fn test_create_notebook_collision_generates_new_id() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + let (_nb1, m1, dir1) = create_new_notebook(base_dir, "Notiz").unwrap(); + assert!(dir1.exists()); + + // Erzwinge absichtlich existierendes Verzeichnis mit demselben ID-Schema + let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Notiz").unwrap(); + assert!(dir2.exists()); + assert_ne!(m1.id, m2.id, "IDs müssen bei Kollision eindeutig sein"); + assert_ne!(dir1, dir2, "Verzeichnisse dürfen sich nicht überschreiben"); + } + + #[test] + fn test_delete_notebook_path_traversal_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path().join("storage"); + let outside_dir = temp_dir.path().join("secret"); + fs::create_dir_all(&base_dir).unwrap(); + fs::create_dir_all(&outside_dir).unwrap(); + + let malicious_path = base_dir.join("../secret"); + let res = delete_notebook(&malicious_path, &base_dir); + assert!(res.is_err(), "Path-Traversal muss abgewehrt werden"); + match res.unwrap_err() { + StorageError::PathTraversal(_) => {} + other => panic!("Erwartet PathTraversal Fehler, erhalten: {other:?}"), + } + assert!( + outside_dir.exists(), + "Verzeichnis außerhalb darf nicht berührt werden" + ); + } + + #[test] + fn test_delete_notebook_moves_to_trash_and_can_be_restored() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let (_nb, m, nb_dir) = create_new_notebook(base_dir, "TrashTest").unwrap(); + assert!(nb_dir.exists()); + + let trash_target = + delete_notebook(&nb_dir, base_dir).expect("Löschen muss erfolgreich sein"); + assert!( + !nb_dir.exists(), + "Originalverzeichnis muss verschwunden sein" + ); + assert!(trash_target.exists(), "Papierkorb-Eintrag muss existieren"); + + let trash_dir = base_dir.join(".trash"); + assert!(trash_dir.exists(), ".trash Ordner muss existieren"); + let entries: Vec<_> = fs::read_dir(&trash_dir).unwrap().flatten().collect(); + assert_eq!( + entries.len(), + 1, + "Gelöschtes Notizbuch muss im Papierkorb liegen" + ); + assert!(entries[0].file_name().to_string_lossy().starts_with(&m.id)); + + // Wiederherstellung prüfen + let restored_dir = restore_notebook_from_trash(base_dir, &trash_target) + .expect("Wiederherstellung aus Papierkorb muss erfolgreich sein"); + assert!( + restored_dir.exists(), + "Wiederhergestelltes Verzeichnis muss existieren" + ); + assert!(restored_dir.join("manifest.json").exists()); + assert!( + !trash_target.exists(), + "Eintrag darf nicht mehr im Papierkorb liegen" + ); + } + + #[test] + fn test_delete_notebook_missing_manifest_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let no_manifest_dir = base_dir.join("no_manifest"); + fs::create_dir_all(&no_manifest_dir).unwrap(); + + let res = delete_notebook(&no_manifest_dir, base_dir); + assert!( + res.is_err(), + "Löschen ohne manifest.json muss abgewiesen werden" + ); + match res.unwrap_err() { + StorageError::InvalidManifest(_) => {} + other => panic!("Erwartet InvalidManifest Fehler, erhalten: {other:?}"), + } + assert!( + no_manifest_dir.exists(), + "Verzeichnis ohne manifest.json darf nicht gelöscht werden" + ); + } + + #[cfg(unix)] + #[test] + fn test_delete_notebook_symlink_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let (_nb, _m, real_dir) = create_new_notebook(base_dir, "RealNB").unwrap(); + + let symlink_path = base_dir.join("symlink_nb"); + std::os::unix::fs::symlink(&real_dir, &symlink_path).unwrap(); + + let res = delete_notebook(&symlink_path, base_dir); + assert!(res.is_err(), "Löschen von Symlinks muss abgewiesen werden"); + match res.unwrap_err() { + StorageError::InvalidManifest(_) => {} + other => panic!("Erwartet InvalidManifest Fehler, erhalten: {other:?}"), + } + assert!( + real_dir.exists(), + "Reales Notizbuch darf nicht angetastet werden" + ); + assert!( + symlink_path.exists(), + "Symlink darf nicht angetastet werden" + ); + } + + #[test] + fn test_list_notebooks_includes_damaged_and_ignores_trash() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + // 1. Intaktes Notizbuch + let (_nb, _m, _dir) = create_new_notebook(base_dir, "Intakt").unwrap(); + + // 2. Beschädigtes Notizbuch (defektes manifest.json) + let damaged_dir = base_dir.join("damaged_nb"); + fs::create_dir_all(&damaged_dir).unwrap(); + fs::write(damaged_dir.join("manifest.json"), b"corrupt").unwrap(); + + // 3. .trash Ordner + let trash_dir = base_dir.join(".trash"); + fs::create_dir_all(&trash_dir).unwrap(); + + let summaries = list_notebooks(base_dir); + // .trash darf nicht auftauchen! + assert!(!summaries.iter().any(|s| s.id == ".trash")); + + // damaged_nb MUSS auftauchen mit Status Damaged + let damaged_summary = summaries.iter().find(|s| s.id == "damaged_nb"); + assert!( + damaged_summary.is_some(), + "Beschädigtes Notizbuch muss aufgelistet werden" + ); + assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged); + } + + #[test] + fn test_notebook_instance_lock_prevents_concurrent_open() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let lock1 = NotebookLock::acquire(nb_dir).expect("Erster Lock muss gelingen"); + let lock2 = NotebookLock::acquire(nb_dir); + assert!( + lock2.is_err(), + "Zweiter Lock auf selbes Verzeichnis muss scheitern" + ); + match lock2.unwrap_err() { + StorageError::AlreadyLocked(_) => {} + other => panic!("Erwartet AlreadyLocked, erhalten: {other:?}"), + } + + // Lock 1 freigeben + drop(lock1); + let lock3 = NotebookLock::acquire(nb_dir); + assert!( + lock3.is_ok(), + "Nach Drop muss Lock wieder erworben werden können" + ); + } } diff --git a/src/ui.rs b/src/ui.rs index ba813c7..70b2642 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -113,6 +113,9 @@ pub enum UiAction { new_title: String, }, DeleteNotebook(String), + UndoDeleteNotebook, + RestoreNotebookBackup(String), + OpenNotebookFolder(String), RefreshNotebooks, // Gitea Release & Updater CheckForUpdates, @@ -168,6 +171,8 @@ pub struct UiState { pub update_modal_rect: Option, /// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom) pub touch_draw_enabled: bool, + /// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel) + pub last_deleted_notebook: Option<(std::path::PathBuf, String)>, /// Fehler beim Flushen vor dem Beenden pub shutdown_error: Option, } @@ -201,6 +206,7 @@ impl Default for UiState { show_updater_config: false, update_modal_rect: None, touch_draw_enabled: false, + last_deleted_notebook: None, shutdown_error: None, } } @@ -1555,11 +1561,17 @@ pub fn render_ui( .size(13.0) .color(egui::Color32::WHITE), ); - if is_current { + if summary.status == crate::storage::NotebookStatus::Damaged { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠️ Beschädigt"); + } else if is_current { ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv"); } }); - let subtext = format!("{} Seite(n)", summary.page_count); + let subtext = if let Some(ref err) = summary.error { + format!("Fehler: {err}") + } else { + format!("{} Seite(n)", summary.page_count) + }; ui.label( egui::RichText::new(subtext) .size(11.0) @@ -1568,27 +1580,47 @@ pub fn render_ui( }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if state.available_notebooks.len() > 1 - && ui - .button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) - .on_hover_text("Notizbuch unwiderruflich löschen") + if summary.status == crate::storage::NotebookStatus::Damaged { + if ui + .button(egui::RichText::new("🔄 Backup").size(11.0).color(egui::Color32::from_rgb(100, 200, 255))) + .on_hover_text("Aus manifest.json.bak wiederherstellen") .clicked() - { - actions.push(UiAction::DeleteNotebook(summary.id.clone())); - } + { + actions.push(UiAction::RestoreNotebookBackup(summary.id.clone())); + } + if ui + .button(egui::RichText::new("📁 Ordner").size(11.0)) + .on_hover_text("Pfad in Statusleiste anzeigen") + .clicked() + { + actions.push(UiAction::OpenNotebookFolder(summary.id.clone())); + } + } else { + if state.available_notebooks.len() > 1 + && ui + .button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) + .on_hover_text("Notizbuch in Papierkorb verschieben") + .clicked() + { + actions.push(UiAction::DeleteNotebook(summary.id.clone())); + } - if ui.button(egui::RichText::new("✏").size(11.0)) - .on_hover_text("Titel umbenennen") - .clicked() - { - state.renaming_notebook_id = Some(summary.id.clone()); - state.renaming_title_input = summary.title.clone(); + if ui.button(egui::RichText::new("✏").size(11.0)) + .on_hover_text("Titel umbenennen") + .clicked() + { + state.renaming_notebook_id = Some(summary.id.clone()); + state.renaming_title_input = summary.title.clone(); + } } }); }); }); - if frame_resp.response.interact(egui::Sense::click()).clicked() && !is_current { + if summary.status != crate::storage::NotebookStatus::Damaged + && frame_resp.response.interact(egui::Sense::click()).clicked() + && !is_current + { actions.push(UiAction::OpenNotebook(summary.id.clone())); state.is_document_menu_open = false; } @@ -1598,6 +1630,23 @@ pub fn render_ui( } }); + if let Some((_, ref title)) = state.last_deleted_notebook { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("'{title}' gelöscht")) + .size(11.0) + .color(egui::Color32::from_rgb(255, 180, 100)), + ); + if ui + .button(egui::RichText::new("↩ Rückgängig").size(11.0).color(egui::Color32::from_rgb(100, 200, 255))) + .clicked() + { + actions.push(UiAction::UndoDeleteNotebook); + } + }); + } + ui.add_space(8.0); ui.separator(); ui.add_space(4.0); @@ -2244,12 +2293,16 @@ mod tests { title: "Mathematik I".to_string(), page_count: 3, modified_at: 100, + status: crate::storage::NotebookStatus::Normal, + error: None, }, NotebookSummary { id: "nb-2".to_string(), title: "Physik II".to_string(), page_count: 5, modified_at: 200, + status: crate::storage::NotebookStatus::Normal, + error: None, }, ]; state.current_notebook_id = "nb-1".to_string();