From 06b3aeb3110d26f4176071a2f034e2788347cb10 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 16:58:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(storage):=20kein=20stilles=20=C3=9Cberschre?= =?UTF-8?q?iben=20bei=20Ladefehlern=20und=20Quarant=C3=A4ne=20defekter=20S?= =?UTF-8?q?eiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/autosave.rs | 3 + src/document.rs | 30 ++++ src/main.rs | 8 ++ src/storage.rs | 353 +++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 90 ++++++++++++ 5 files changed, 464 insertions(+), 20 deletions(-) diff --git a/src/autosave.rs b/src/autosave.rs index 6e4a9f9..8125c62 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -139,6 +139,9 @@ impl AutosaveController { /// 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 { + return; + } let page_data = PageData::from(page); self.pending_pages.insert(page.id, page_data); self.pending_manifest = Some(manifest.clone()); diff --git a/src/document.rs b/src/document.rs index 996285a..063563c 100644 --- a/src/document.rs +++ b/src/document.rs @@ -304,6 +304,22 @@ pub enum PageBackground { DotGrid, } +/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PersistState { + #[default] + Normal, + ReadOnlyAfterLoadError, +} + +/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler). +#[derive(Debug, Clone, PartialEq)] +pub struct PageLoadWarning { + pub page_id: u64, + pub message: String, + pub quarantine_path: Option, +} + /// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes. #[derive(Debug, Clone)] pub struct Page { @@ -314,6 +330,7 @@ pub struct Page { pub stroke_ranges: Vec>, pub is_dirty: bool, pub is_loaded: bool, + pub persist: PersistState, pub history: HistoryStack, } @@ -327,6 +344,7 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: true, + persist: PersistState::Normal, history: HistoryStack::new(), } } @@ -341,10 +359,19 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: false, + persist: PersistState::Normal, history: HistoryStack::new(), } } + pub fn is_readonly(&self) -> bool { + self.persist == PersistState::ReadOnlyAfterLoadError + } + + pub fn unlock_after_load_error(&mut self) { + self.persist = PersistState::Normal; + } + /// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack. pub fn add_stroke(&mut self, stroke: Stroke) { self.history.push(HistoryAction::AddStroke(stroke.clone())); @@ -587,6 +614,7 @@ pub struct Notebook { pub current_page_index: usize, pub next_stroke_id: u64, pub next_page_id: u64, + pub load_warnings: std::collections::HashMap, } impl Notebook { @@ -596,6 +624,7 @@ impl Notebook { current_page_index: 0, next_stroke_id: 1, next_page_id: 1, + load_warnings: std::collections::HashMap::new(), }; // Erste Standardseite anlegen (5mm Grid) nb.add_page(PageBackground::Grid5mm); @@ -614,6 +643,7 @@ impl Notebook { current_page_index, next_stroke_id, next_page_id, + load_warnings: std::collections::HashMap::new(), }; if nb.pages.is_empty() { nb.add_page(PageBackground::Grid5mm); diff --git a/src/main.rs b/src/main.rs index b3580e1..352136d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2106,6 +2106,14 @@ fn main() -> Result<(), Box> { ui_state.set_export_message("Server-Einstellungen gespeichert"); window.request_redraw(); } + UiAction::UnlockCorruptedPage(page_id) => { + notebook.load_warnings.remove(&page_id); + if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) { + page.unlock_after_load_error(); + } + autosave.mark_page_dirty(notebook.current_page(), &manifest); + window.request_redraw(); + } } } diff --git a/src/storage.rs b/src/storage.rs index 4271ec8..9280166 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::canvas::InkVertex; -use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample}; +use crate::document::{ + Notebook, Page, PageBackground, PageLoadWarning, PersistState, Stroke, StylusSample, +}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; @@ -119,6 +121,20 @@ impl PageData { } } +/// Metadaten einer einzelnen Seite im Manifest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PageMeta { + pub id: u64, + #[serde(default)] + pub background: Option, +} + +impl PageMeta { + pub fn new(id: u64, background: Option) -> Self { + Self { id, background } + } +} + /// Metadaten des gesamten Notizbuchs (`manifest.json`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NotebookManifest { @@ -127,6 +143,9 @@ pub struct NotebookManifest { pub created_at: u64, pub modified_at: u64, pub current_page_index: usize, + #[serde(default)] + pub pages: Vec, + #[serde(default)] pub page_ids: Vec, pub next_stroke_id: u64, pub next_page_id: u64, @@ -144,11 +163,46 @@ impl NotebookManifest { created_at: now, modified_at: now, current_page_index: 0, + pages: vec![PageMeta::new(1, Some(PageBackground::Grid5mm))], page_ids: vec![1], next_stroke_id: 1, next_page_id: 2, } } + + pub fn page_ids(&self) -> Vec { + if !self.pages.is_empty() { + self.pages.iter().map(|p| p.id).collect() + } else { + self.page_ids.clone() + } + } + + pub fn background_for_page(&self, page_id: u64) -> Option { + self.pages + .iter() + .find(|p| p.id == page_id) + .and_then(|p| p.background) + } + + pub fn sync_from_notebook(&mut self, notebook: &Notebook) { + self.pages = notebook + .pages + .iter() + .map(|p| PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); + self.page_ids = self.pages.iter().map(|p| p.id).collect(); + self.current_page_index = notebook.current_page_index; + self.next_stroke_id = notebook.next_stroke_id; + self.next_page_id = notebook.next_page_id; + self.modified_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + } } /// Mögliche Fehler bei Speicher- und Ladeoperationen. @@ -306,10 +360,43 @@ pub fn load_manifest(notebook_dir: &Path) -> Result.json.corrupt-`. +/// Nur bei Parse-/Schemafehlern aufrufen, nicht bei I/O-Fehlern! +pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result { + let source_path = notebook_dir.join(format!("page_{page_id}.json")); + if !source_path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Datei existiert nicht", + )); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}")); + if target_path.exists() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}-{nanos}")); + } + fs::rename(&source_path, &target_path)?; + Ok(target_path) +} + /// Speichert eine Seite atomar in `page_.json`. pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> { let path = notebook_dir.join(format!("page_{}.json", page.id)); @@ -347,18 +434,11 @@ pub fn save_notebook( ) -> Result<(), StorageError> { fs::create_dir_all(notebook_dir)?; - manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect(); - manifest.current_page_index = notebook.current_page_index; - manifest.next_stroke_id = notebook.next_stroke_id; - manifest.next_page_id = notebook.next_page_id; - manifest.modified_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + manifest.sync_from_notebook(notebook); - // Alle bereits geladenen Seiten speichern + // Alle bereits geladenen Seiten speichern, schreibgeschützte Seiten nach Ladefehler überspringen! for page in ¬ebook.pages { - if page.is_loaded { + if page.is_loaded && page.persist == PersistState::Normal { let page_data = PageData::from(page); save_page(notebook_dir, &page_data)?; } @@ -371,35 +451,82 @@ 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 manifest = load_manifest(notebook_dir)?; - if manifest.page_ids.is_empty() { + let page_ids = manifest.page_ids(); + if page_ids.is_empty() { let nb = Notebook::new(); return Ok((nb, manifest)); } - let mut pages = Vec::with_capacity(manifest.page_ids.len()); + let mut pages = Vec::with_capacity(page_ids.len()); + let mut load_warnings = std::collections::HashMap::new(); // Erste Seite sofort laden und initialisieren - let first_id = manifest.page_ids[0]; + let first_id = page_ids[0]; + let first_bg = manifest + .background_for_page(first_id) + .unwrap_or(PageBackground::Grid5mm); + let first_page = match load_page(notebook_dir, first_id) { Ok(data) => data.to_page(), + Err(StorageError::CorruptedPage(id, err_msg)) => { + let q_res = quarantine_corrupted_page(notebook_dir, id); + let q_path = q_res.ok(); + eprintln!( + "[Storage] Erste Seite {id} ist beschädigt ({err_msg}). Quarantänisiert nach: {:?}", + q_path + ); + load_warnings.insert( + id, + PageLoadWarning { + page_id: id, + message: err_msg, + quarantine_path: q_path, + }, + ); + let mut page = Page::new(id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page + } + Err(StorageError::Io(e)) => { + eprintln!( + "[Storage] I/O-Fehler beim Laden von Seite {first_id}: {e}. Seite wird schreibgeschützt." + ); + load_warnings.insert( + first_id, + PageLoadWarning { + page_id: first_id, + message: format!("I/O-Fehler: {e}"), + quarantine_path: None, + }, + ); + let mut page = Page::new(first_id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page + } Err(e) => { eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); - Page::new(first_id, PageBackground::Grid5mm) + let mut page = Page::new(first_id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page } }; pages.push(first_page); // Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading) - for &page_id in &manifest.page_ids[1..] { - pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm)); + for &page_id in &page_ids[1..] { + let bg = manifest + .background_for_page(page_id) + .unwrap_or(PageBackground::Grid5mm); + pages.push(Page::new_unloaded(page_id, bg)); } - let notebook = Notebook::from_pages( + let mut notebook = Notebook::from_pages( pages, manifest.current_page_index, manifest.next_stroke_id, manifest.next_page_id, ); + notebook.load_warnings = load_warnings; Ok((notebook, manifest)) } @@ -424,10 +551,46 @@ pub fn ensure_page_loaded( notebook.pages[page_idx] = data.to_page(); Ok(()) } + Err(StorageError::CorruptedPage(id, err_msg)) => { + let q_res = quarantine_corrupted_page(notebook_dir, id); + let q_path = q_res.ok(); + eprintln!( + "[Storage] Lazy Loading von Seite {id} fehlgeschlagen ({err_msg}). Quarantäne: {:?}", + q_path + ); + notebook.load_warnings.insert( + id, + PageLoadWarning { + page_id: id, + message: err_msg.clone(), + quarantine_path: q_path, + }, + ); + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; + notebook.pages[page_idx].rebuild_cache_if_dirty(); + Err(StorageError::CorruptedPage(id, err_msg)) + } + Err(StorageError::Io(e)) => { + eprintln!("[Storage] I/O-Fehler beim Lazy Loading von Seite {page_id}: {e}"); + let err_str = e.to_string(); + notebook.load_warnings.insert( + page_id, + PageLoadWarning { + page_id, + message: format!("I/O-Fehler: {err_str}"), + quarantine_path: None, + }, + ); + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; + notebook.pages[page_idx].rebuild_cache_if_dirty(); + Err(StorageError::Io(e)) + } Err(e) => { eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}"); - // Ersatzseite als geladen markieren, damit die App nicht hängt notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; notebook.pages[page_idx].rebuild_cache_if_dirty(); Err(e) } @@ -773,4 +936,154 @@ mod tests { assert_eq!(final_summaries.len(), 1); assert_eq!(final_summaries[0].id, m1.id); } + + #[test] + fn test_load_corrupted_page_quarantines_and_does_not_overwrite() { + use crate::document::PersistState; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_corrupt", "Test Corrupt"); + save_manifest(path, &manifest).unwrap(); + + let corrupted_content = br#"{"id": 1, "background": "Grid5mm", "strokes": INVALID_JSON"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, corrupted_content).unwrap(); + + // Load notebook + let (nb, mut m) = + load_notebook(path).expect("load_notebook should succeed with fallback page"); + + // (1) Check that page_1.json was quarantined + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + let corrupt_entry = dir_entries + .iter() + .find(|name| name.starts_with("page_1.json.corrupt-")); + assert!( + corrupt_entry.is_some(), + "Corrupt file must be renamed to page_1.json.corrupt-, found entries: {dir_entries:?}" + ); + let corrupt_file = path.join(corrupt_entry.unwrap()); + assert_eq!( + fs::read(&corrupt_file).unwrap(), + corrupted_content, + "Quarantine file must contain original corrupt content" + ); + + // (2) The replacement page must be in ReadOnlyAfterLoadError state + assert_eq!( + nb.pages[0].persist, + PersistState::ReadOnlyAfterLoadError, + "Page must be marked ReadOnlyAfterLoadError" + ); + + // (3) Calling save_notebook must NOT overwrite or create a blank page_1.json! + save_notebook(path, &nb, &mut m).unwrap(); + + assert!( + !page_file.exists(), + "save_notebook must NOT recreate page_1.json from an unedited/locked replacement page" + ); + assert!( + corrupt_file.exists(), + "Quarantined file must remain intact after save_notebook" + ); + } + + #[test] + #[cfg(unix)] + fn test_io_error_does_not_quarantine_and_does_not_overwrite() { + use crate::document::PersistState; + use std::os::unix::fs::PermissionsExt; + + // Skip test if running as root + if std::env::var("USER").unwrap_or_default() == "root" { + return; + } + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_io", "Test IO"); + save_manifest(path, &manifest).unwrap(); + + let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, original_content).unwrap(); + + // Make file unreadable + fs::set_permissions(&page_file, fs::Permissions::from_mode(0o000)).unwrap(); + + let (nb, mut m) = + load_notebook(path).expect("load_notebook should succeed with fallback page"); + + // Restore permissions for inspection + fs::set_permissions(&page_file, fs::Permissions::from_mode(0o600)).unwrap(); + + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + let corrupt_entry = dir_entries.iter().find(|name| name.contains("corrupt")); + assert!( + corrupt_entry.is_none(), + "I/O error must NOT trigger quarantine, found: {corrupt_entry:?}" + ); + + assert_eq!( + nb.pages[0].persist, + PersistState::ReadOnlyAfterLoadError, + "Page must be marked ReadOnlyAfterLoadError on I/O error" + ); + + save_notebook(path, &nb, &mut m).unwrap(); + assert_eq!( + fs::read(&page_file).unwrap(), + original_content, + "Original file content must not be overwritten" + ); + } + + #[test] + fn test_edit_replacement_saves_only_after_unlock() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_unlock", "Test Unlock"); + save_manifest(path, &manifest).unwrap(); + + let corrupted_content = br#"CORRUPTED_JSON_DATA"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, corrupted_content).unwrap(); + + let (mut nb, mut m) = load_notebook(path).expect("load_notebook"); + + save_notebook(path, &nb, &mut m).unwrap(); + assert!(!page_file.exists()); + + // Now user explicitly acknowledges and unlocks + nb.pages[0].unlock_after_load_error(); + nb.pages[0].add_stroke(Stroke::new(1, vec![], vec![], [1.0, 0.0, 0.0, 1.0])); + + save_notebook(path, &nb, &mut m).unwrap(); + assert!( + page_file.exists(), + "Page must be saved after explicit unlock and edit" + ); + + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!(dir_entries + .iter() + .any(|name| name.starts_with("page_1.json.corrupt-"))); + } } diff --git a/src/ui.rs b/src/ui.rs index c2911c7..2d64660 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -123,6 +123,8 @@ pub enum UiAction { server_url: String, auth_token: Option, }, + // Datenintegrität / Quarantäne + UnlockCorruptedPage(u64), } /// Zustand der grafischen Benutzeroberfläche. @@ -1932,6 +1934,94 @@ pub fn render_ui( } } + // 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1) + if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) { + egui::Window::new("⚠️ Datenintegritäts-Warnung") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 245)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.5_f32, + egui::Color32::from_rgb(235, 87, 87), + )) + .inner_margin(egui::Margin::symmetric(24.0, 20.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 24.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(180), + }), + ) + .show(ctx, |ui| { + ui.set_width(420.0); + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.label( + egui::RichText::new(format!( + "Seite {} konnte nicht fehlerfrei geladen werden!", + warning.page_id + )) + .strong() + .size(15.0) + .color(egui::Color32::WHITE), + ); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("Fehlermeldung: {}", warning.message)) + .size(12.0) + .color(egui::Color32::from_gray(200)), + ); + ui.add_space(8.0); + if let Some(ref q_path) = warning.quarantine_path { + ui.label( + egui::RichText::new("Die beschädigte Originaldatei wurde in Quarantäne gesichert:") + .size(12.0) + .color(egui::Color32::from_gray(220)), + ); + ui.add_space(4.0); + ui.monospace( + egui::RichText::new(q_path.display().to_string()) + .size(11.0) + .color(egui::Color32::from_rgb(255, 215, 0)), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new( + "Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\ + Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.", + ) + .size(11.5) + .color(egui::Color32::from_gray(180)), + ); + ui.add_space(12.0); + if ui + .button( + egui::RichText::new("Als leere Seite bearbeiten (Schreibschutz aufheben)") + .color(egui::Color32::WHITE) + .size(12.5), + ) + .clicked() + { + actions.push(UiAction::UnlockCorruptedPage(warning.page_id)); + } + } else { + ui.label( + egui::RichText::new( + "Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\ + Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", + ) + .size(12.0) + .color(egui::Color32::from_gray(200)), + ); + } + }); + } + actions }