Files
ink-notes/src/storage.rs
T
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00

2171 lines
76 KiB
Rust

//! Robuste lokale Notizbuch-Persistenz mit atomaren Writes (Crash-Resistenz)
//! und semantischer Datenspeicherung (ohne gecachte GPU-Vertizes).
use std::error::Error;
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{
Notebook, Page, PageBackground, PageLoadWarning, PersistState, Stroke, StylusSample,
};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SampleData {
pub x: f32,
pub y: f32,
pub p: f32,
pub t: f64,
}
impl From<&StylusSample> for SampleData {
fn from(s: &StylusSample) -> Self {
Self {
x: s.position.x,
y: s.position.y,
p: s.pressure,
t: s.timestamp,
}
}
}
impl From<SampleData> for StylusSample {
fn from(d: SampleData) -> Self {
Self {
position: glam::Vec2::new(d.x, d.y),
pressure: d.p,
timestamp: d.t,
}
}
}
/// Standardwert für die Strichbreite bei Migration älterer Dateien (0.5 mm in Einheiten).
pub fn default_stroke_width() -> f32 {
5.0
}
/// Standardwert für den Finalisierungsstatus bei Migration älterer Dateien.
pub fn default_finalized() -> bool {
true
}
/// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StrokeData {
pub id: u64,
pub color: [f32; 4],
pub samples: Vec<SampleData>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<crate::geometry::RecognizedShape>,
#[serde(default)]
pub tool: crate::document::StrokeTool,
#[serde(default = "default_stroke_width")]
pub width: f32,
#[serde(default = "default_finalized")]
pub finalized: bool,
}
impl From<&Stroke> for StrokeData {
fn from(stroke: &Stroke) -> Self {
Self {
id: stroke.id,
color: stroke.color,
samples: stroke.points.iter().map(SampleData::from).collect(),
shape: stroke.shape.clone(),
tool: stroke.tool,
width: stroke.width,
finalized: stroke.finalized,
}
}
}
impl StrokeData {
/// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um,
/// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB.
pub fn to_stroke(&self) -> Stroke {
let stylus_samples: Vec<StylusSample> = self
.samples
.iter()
.copied()
.map(StylusSample::from)
.collect();
let width = if self.width > 0.0 {
self.width
} else if self.shape.is_some() {
20.0
} else {
5.0
};
let vertices = if let Some(ref shape) = self.shape {
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, width, self.color);
shape_verts
} else {
reconstruct_stroke_vertices(
&stylus_samples,
self.color,
width,
self.tool,
self.finalized,
)
};
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone();
stroke.tool = self.tool;
stroke.width = width;
stroke.finalized = self.finalized;
stroke
}
}
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
pub const CURRENT_FORMAT_VERSION: u32 = 2;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
0
}
/// Serialisierbare semantische Seitendaten.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageData {
#[serde(default = "legacy_format_version")]
pub format_version: u32,
pub id: u64,
pub background: PageBackground,
pub strokes: Vec<StrokeData>,
}
impl From<&Page> for PageData {
fn from(page: &Page) -> Self {
Self {
format_version: CURRENT_FORMAT_VERSION,
id: page.id,
background: page.background,
strokes: page.strokes.iter().map(StrokeData::from).collect(),
}
}
}
impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background);
for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke());
}
page.is_dirty = true;
page.rebuild_cache_if_dirty();
page.is_loaded = true;
page
}
}
/// Metadaten einer einzelnen Seite im Manifest.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageMeta {
pub id: u64,
#[serde(default)]
pub background: Option<PageBackground>,
}
impl PageMeta {
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
Self { id, background }
}
}
/// Metadaten des gesamten Notizbuchs (`manifest.json`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NotebookManifest {
#[serde(default = "legacy_format_version")]
pub format_version: u32,
pub id: String,
pub title: String,
pub created_at: u64,
pub modified_at: u64,
pub current_page_index: usize,
#[serde(default)]
pub pages: Vec<PageMeta>,
#[serde(default)]
pub page_ids: Vec<u64>,
pub next_stroke_id: u64,
pub next_page_id: u64,
}
impl NotebookManifest {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
format_version: CURRENT_FORMAT_VERSION,
id: id.into(),
title: title.into(),
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<u64> {
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<PageBackground> {
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.background)
}
pub fn sync_from_notebook(&mut self, notebook: &Notebook) {
self.format_version = CURRENT_FORMAT_VERSION;
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.
#[derive(Debug)]
pub enum StorageError {
Io(io::Error),
Serialization(serde_json::Error),
InvalidManifest(String),
CorruptedPage(u64, String),
PageNotFound(u64),
UnsupportedVersion { found: u32, supported: u32 },
AlreadyLocked(String),
PathTraversal(String),
}
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"),
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"),
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"),
StorageError::CorruptedPage(id, msg) => {
write!(f, "Beschädigte Seite {id}: {msg}")
}
StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"),
StorageError::UnsupportedVersion { found, supported } => write!(
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}"),
}
}
}
impl Error for StorageError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
StorageError::Io(e) => Some(e),
StorageError::Serialization(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for StorageError {
fn from(e: io::Error) -> Self {
StorageError::Io(e)
}
}
impl From<serde_json::Error> for StorageError {
fn from(e: serde_json::Error) -> Self {
StorageError::Serialization(e)
}
}
/// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples.
pub fn reconstruct_stroke_vertices(
samples: &[StylusSample],
color: [f32; 4],
width: f32,
_tool: crate::document::StrokeTool,
finalized: bool,
) -> Vec<InkVertex> {
if samples.is_empty() {
return Vec::new();
}
let config = SplineConfig::from_width_units(width);
if finalized {
let corners = spline::detect_corners(samples, spline::CORNER_ANGLE_THRESHOLD_DEG);
let strip = spline::generate_finalized_triangle_strip(samples, &corners, &config);
strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect()
} else {
if samples.len() == 1 {
let pt = samples[0];
let radius =
spline::calculate_radius(pt.pressure, config.min_radius, config.max_radius);
let segments = 8;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
vertices.push(InkVertex::new(
pt.position.x + offset.x,
pt.position.y + offset.y,
color,
));
}
return vertices;
}
let mut filter = OneEuroFilter::default();
let mut control_points: Vec<ControlPoint> = Vec::with_capacity(samples.len());
for s in samples {
let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp);
let pt = ControlPoint::new(fx, fy, fp);
if let Some(last) = control_points.last() {
if (pt.position - last.position).length_squared() < 1.0 {
continue;
}
}
control_points.push(pt);
}
let raw_strip = spline::generate_triangle_strip(&control_points, &config);
raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.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(
io::ErrorKind::NotFound,
"Übergeordnetes Verzeichnis nicht gefunden",
)
})?;
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()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id());
let temp_path = dir.join(temp_filename);
// 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()?;
}
// Atomares Rename
if let Err(e) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
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(())
}
/// Serialisiert einen Wert als kompaktes JSON (`serde_json::to_vec`) und schreibt ihn atomar.
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
let bytes = serde_json::to_vec(value)?;
atomic_write_file(path, &bytes)?;
Ok(())
}
/// 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() {
let bak_path = notebook_dir.join("manifest.json.v0.bak");
if !bak_path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(old_m) = serde_json::from_str::<NotebookManifest>(&content) {
if old_m.format_version == 0 {
let _ = fs::copy(&path, &bak_path);
}
}
}
}
// 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)
}
fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError> {
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)?;
if manifest.format_version > CURRENT_FORMAT_VERSION {
return Err(StorageError::UnsupportedVersion {
found: manifest.format_version,
supported: CURRENT_FORMAT_VERSION,
});
}
if manifest.pages.is_empty() && !manifest.page_ids.is_empty() {
manifest.pages = manifest
.page_ids
.iter()
.map(|&id| PageMeta::new(id, None))
.collect();
}
Ok(manifest)
}
/// Lädt das Manifest aus `manifest.json`.
/// Fällt bei Fehlern/Beschädigung automatisch auf `manifest.json.bak` zurück.
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
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_<id>.json.corrupt-<unix_ts>`.
/// Nur bei Parse-/Schemafehlern aufrufen, nicht bei I/O-Fehlern!
pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<PathBuf, io::Error> {
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_<id>.json`.
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
let path = notebook_dir.join(format!("page_{}.json", page.id));
if path.exists() {
let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id));
if !bak_path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(old_p) = serde_json::from_str::<PageData>(&content) {
if old_p.format_version == 0 {
let _ = fs::copy(&path, &bak_path);
}
}
}
}
}
atomic_write_json(&path, page)
}
/// Lädt eine Seite aus `page_<id>.json`.
pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result<PageData, StorageError> {
let path = notebook_dir.join(format!("page_{page_id}.json"));
if !path.exists() {
return Err(StorageError::PageNotFound(page_id));
}
let mut file = File::open(&path)?;
let mut json_str = String::new();
file.read_to_string(&mut json_str)?;
let page_data = match serde_json::from_str::<PageData>(&json_str) {
Ok(data) => data,
Err(e) => {
eprintln!(
"[Storage-Warnung] Seite {page_id} in '{}' ist beschädigt: {e}",
path.display()
);
return Err(StorageError::CorruptedPage(page_id, e.to_string()));
}
};
if page_data.format_version > CURRENT_FORMAT_VERSION {
return Err(StorageError::UnsupportedVersion {
found: page_data.format_version,
supported: CURRENT_FORMAT_VERSION,
});
}
Ok(page_data)
}
/// Speichert ein gesamtes Notebook synchron auf die Festplatte (z. B. initial oder beim Flush).
pub fn save_notebook(
notebook_dir: &Path,
notebook: &Notebook,
manifest: &mut NotebookManifest,
) -> Result<(), StorageError> {
create_secure_dir_all(notebook_dir)?;
manifest.sync_from_notebook(notebook);
// Alle bereits geladenen Seiten speichern, schreibgeschützte Seiten nach Ladefehler überspringen!
for page in &notebook.pages {
if page.is_loaded && page.persist == PersistState::Normal {
let page_data = PageData::from(page);
save_page(notebook_dir, &page_data)?;
}
}
save_manifest(notebook_dir, manifest)?;
Ok(())
}
/// 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() {
let nb = Notebook::new();
return Ok((nb, manifest));
}
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 = 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.");
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 &page_ids[1..] {
let bg = manifest
.background_for_page(page_id)
.unwrap_or(PageBackground::Grid5mm);
pages.push(Page::new_unloaded(page_id, bg));
}
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))
}
/// Stellt sicher, dass die Seite am Index `page_idx` geladen ist (Lazy Loading beim Seitenwechsel).
pub fn ensure_page_loaded(
notebook_dir: &Path,
notebook: &mut Notebook,
page_idx: usize,
) -> Result<(), StorageError> {
if page_idx >= notebook.pages.len() {
return Err(StorageError::PageNotFound(page_idx as u64));
}
if notebook.pages[page_idx].is_loaded {
return Ok(());
}
let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) {
Ok(data) => {
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}");
notebook.pages[page_idx].is_loaded = true;
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
notebook.pages[page_idx].rebuild_cache_if_dirty();
Err(e)
}
}
}
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
pub fn default_storage_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("ink-notes").join("notebooks");
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("ink-notes")
.join("notebooks");
}
}
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 {
pub id: String,
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<String>,
}
/// 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<Self, StorageError> {
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<usize> {
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<NotebookSummary> {
let mut list = Vec::new();
let Ok(entries) = fs::read_dir(base_dir) else {
return list;
};
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() {
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()),
});
}
}
}
// Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst)
list.sort_by_key(|a| std::cmp::Reverse(a.modified_at));
list
}
/// 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> {
create_secure_dir_all(base_dir)?;
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let clean_slug: String = title
.chars()
.filter_map(|c| {
if c.is_alphanumeric() {
Some(c.to_ascii_lowercase())
} else if c == ' ' || c == '-' || c == '_' {
Some('_')
} else {
None
}
})
.collect();
let clean_slug = clean_slug.trim_matches('_');
let base_id = if clean_slug.is_empty() {
format!("nb_{now_ts}")
} else {
format!("{clean_slug}_{now_ts}")
};
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(&notebook_dir)?;
let notebook = Notebook::new();
let mut manifest = NotebookManifest::new(&id, title);
save_notebook(&notebook_dir, &notebook, &mut manifest)?;
Ok((notebook, manifest, notebook_dir))
}
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
pub fn rename_notebook(
notebook_dir: &Path,
manifest: &mut NotebookManifest,
new_title: &str,
) -> Result<(), StorageError> {
manifest.title = new_title.to_string();
manifest.modified_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
save_manifest(notebook_dir, manifest)?;
Ok(())
}
/// Verschiebt ein Notizbuch sicher in den Papierkorb (`<base_dir>/.trash/<id>_<ts>`).
/// 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<PathBuf, StorageError> {
if !notebook_dir.exists() {
return Err(StorageError::InvalidManifest(format!(
"Verzeichnis '{}' existiert nicht",
notebook_dir.display()
)));
}
// 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 (`<base_dir>/.trash/<eintrag>` -> `<base_dir>/<id>`).
pub fn restore_notebook_from_trash(
base_dir: &Path,
trash_target: &Path,
) -> Result<PathBuf, StorageError> {
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::<NotebookManifest>(&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<usize> {
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)
}
/// Lädt alle noch nicht geladenen Seiten ohne Ersatzseiten (z. B. für den PDF-Export).
/// Schlägt fehl, sobald eine Seite nicht vollständig verfügbar ist.
pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<(), StorageError> {
for page in &mut notebook.pages {
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
return Err(StorageError::CorruptedPage(
page.id,
"Seite konnte nicht geladen werden".to_string(),
));
}
if !page.is_loaded {
*page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
Err(e) => return Err(e),
};
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::autosave::AutosaveController;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn test_atomic_save_and_reload() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let path = temp_dir.path();
let mut page = Page::new(42, PageBackground::DotGrid);
let sample1 = StylusSample {
position: glam::Vec2::new(120.0, 340.0),
pressure: 0.45,
timestamp: 1.0,
};
let sample2 = StylusSample {
position: glam::Vec2::new(135.0, 360.0),
pressure: 0.85,
timestamp: 1.02,
};
let sample3 = StylusSample {
position: glam::Vec2::new(160.0, 390.0),
pressure: 0.65,
timestamp: 1.05,
};
let stroke = Stroke::new(
101,
vec![sample1, sample2, sample3],
vec![
InkVertex::new(118.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(122.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(132.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(138.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(158.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(162.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
],
[0.1, 0.2, 0.8, 1.0],
);
page.add_stroke(stroke);
// Speichern
let page_data = PageData::from(&page);
let save_res = save_page(path, &page_data);
assert!(save_res.is_ok(), "Speichern der Seite muss gelingen");
// Datei existiert
let file_path = path.join("page_42.json");
assert!(file_path.exists());
// Neu laden
let load_res = load_page(path, 42);
assert!(load_res.is_ok(), "Laden der Seite muss gelingen");
let loaded_data = load_res.unwrap();
let loaded_page = loaded_data.to_page();
// Überprüfungen
assert_eq!(loaded_page.id, 42);
assert_eq!(loaded_page.background, PageBackground::DotGrid);
assert_eq!(loaded_page.strokes.len(), 1);
let loaded_stroke = &loaded_page.strokes[0];
assert_eq!(loaded_stroke.id, 101);
assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]);
assert_eq!(loaded_stroke.points.len(), 3);
for (orig, loaded) in page.strokes[0]
.points
.iter()
.zip(loaded_stroke.points.iter())
{
assert!((orig.position.x - loaded.position.x).abs() < 1e-4);
assert!((orig.position.y - loaded.position.y).abs() < 1e-4);
assert!((orig.pressure - loaded.pressure).abs() < 1e-4);
assert!((orig.timestamp - loaded.timestamp).abs() < 1e-4);
}
// Vertizes wurden deterministisch regeneriert
assert!(!loaded_stroke.vertices.is_empty());
assert!(!loaded_page.vertex_cache.is_empty());
assert!(loaded_page.is_loaded);
}
#[test]
fn test_corrupted_file_recovery() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let path = temp_dir.path();
// Beschädigtes, unvollständiges JSON manuell schreiben
let corrupted_file = path.join("page_99.json");
let truncated_json =
br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben");
// Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken
let load_res = load_page(path, 99);
assert!(
load_res.is_err(),
"Laden beschädigter Daten muss fehlschlagen"
);
match load_res.unwrap_err() {
StorageError::CorruptedPage(id, msg) => {
assert_eq!(id, 99);
assert!(!msg.is_empty());
}
other => panic!("Erwartete CorruptedPage-Fehler, erhielt: {other:?}"),
}
}
#[test]
fn test_debounce_timing() {
use crate::autosave::tests::MockClock;
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let notebook_dir = temp_dir.path().to_path_buf();
let clock = Arc::new(MockClock::new(Instant::now()));
let debounce_duration = Duration::from_millis(150);
let max_wait = Duration::from_secs(10);
let store = Arc::new(crate::autosave::DiskPageStore);
let mut controller = AutosaveController::with_options(
notebook_dir.clone(),
debounce_duration,
max_wait,
store,
Arc::clone(&clock) as Arc<dyn crate::autosave::Clock>,
);
let manifest = NotebookManifest::new("test_nb", "Test Notizbuch");
let mut page = Page::new(1, PageBackground::Grid5mm);
// 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms mit MockClock, keine Wartezeit)
for i in 0..10 {
let sample = StylusSample {
position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32),
pressure: 0.5,
timestamp: i as f64 * 0.01,
};
let stroke = Stroke::new(
i + 1,
vec![sample],
vec![InkVertex::new(10.0, 20.0, [0.0, 0.0, 0.0, 1.0])],
[0.0, 0.0, 0.0, 1.0],
);
page.add_stroke(stroke);
controller.mark_page_dirty(&page, &manifest);
clock.advance(Duration::from_millis(5));
controller.update();
}
// Während der schnellen Änderungen darf noch KEIN Schreibvorgang erfolgt sein!
assert_eq!(
controller.total_page_writes(),
0,
"Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben"
);
// Jetzt drehen wir die Mock-Uhr um 180 ms vor (> 150 ms Debounce)
clock.advance(Duration::from_millis(180));
controller.update();
// Flush abwarten
let flush_res = controller.flush();
assert!(flush_res.is_ok(), "Flush muss erfolgreich sein");
// Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
assert_eq!(
controller.total_page_writes(),
1,
"10 schnelle Änderungen müssen in genau 1 gebündelten Schreibvorgang münden"
);
// Verifiziere, dass die Datei existiert und alle 10 Striche enthält
let loaded = load_page(&notebook_dir, 1).expect("Seite muss lesbar sein");
assert_eq!(loaded.strokes.len(), 10);
let shutdown_res = controller.shutdown();
assert!(shutdown_res.is_ok());
}
#[test]
fn test_notebook_management() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let base_dir = temp_dir.path();
// 1. Erstes Notizbuch anlegen
let (nb1, mut m1, dir1) = create_new_notebook(base_dir, "Mathe Vorlesung")
.expect("Erstellen von Notizbuch 1 muss klappen");
assert_eq!(m1.title, "Mathe Vorlesung");
assert_eq!(nb1.pages.len(), 1);
assert!(dir1.exists());
// 2. Zweites Notizbuch anlegen
let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Skizzen & Entwürfe")
.expect("Erstellen von Notizbuch 2 muss klappen");
assert_eq!(m2.title, "Skizzen & Entwürfe");
assert!(dir2.exists());
// 3. Auflisten
let summaries = list_notebooks(base_dir);
assert_eq!(summaries.len(), 2);
assert!(summaries
.iter()
.any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
assert!(summaries
.iter()
.any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
// 4. Umbenennen
rename_notebook(&dir1, &mut m1, "Höhere Mathematik").expect("Umbenennen muss klappen");
assert_eq!(m1.title, "Höhere Mathematik");
let updated_summaries = list_notebooks(base_dir);
assert!(updated_summaries
.iter()
.any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
// 5. Löschen
delete_notebook(&dir2, base_dir).expect("Löschen muss klappen");
assert!(!dir2.exists());
let final_summaries = list_notebooks(base_dir);
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<String> = 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-<ts>, 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;
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();
// I/O-Fehler simulieren, der auch als root auftritt (CI-Runner laufen oft als root,
// dort greift ein chmod 000 nicht): page_1.json ist ein Verzeichnis -> EISDIR beim Lesen
let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#;
let page_path = path.join("page_1.json");
fs::create_dir(&page_path).unwrap();
let marker = page_path.join("original");
fs::write(&marker, original_content).unwrap();
let (nb, mut m) =
load_notebook(path).expect("load_notebook should succeed with fallback page");
let dir_entries: Vec<String> = 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!(
page_path.is_dir(),
"Original page path must not be replaced"
);
assert_eq!(
fs::read(&marker).unwrap(),
original_content,
"Original 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<String> = 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-")));
}
#[test]
fn test_fixture_legacy_v0_loads_and_migrates_with_backup() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let path = temp_dir.path();
let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_manifest = root_dir.join("tests/fixtures/legacy_v0_manifest.json");
let fixture_page = root_dir.join("tests/fixtures/legacy_v0_page_1.json");
fs::copy(&fixture_manifest, path.join("manifest.json")).unwrap();
fs::copy(&fixture_page, path.join("page_1.json")).unwrap();
let (nb, mut m) = load_notebook(path).expect("Legacy v0 notebook must load");
assert_eq!(m.format_version, 0, "Legacy manifest has format_version 0");
assert_eq!(nb.pages.len(), 1);
assert_eq!(nb.pages[0].background, PageBackground::DotGrid);
assert_eq!(nb.pages[0].strokes.len(), 1);
// Saving must upgrade to format_version 1 and create .v0.bak files
save_notebook(path, &nb, &mut m).expect("Save must succeed");
assert!(
path.join("manifest.json.v0.bak").exists(),
"manifest.json.v0.bak must be created before upgrading legacy format"
);
assert!(
path.join("page_1.json.v0.bak").exists(),
"page_1.json.v0.bak must be created before upgrading legacy format"
);
// Reload to verify format_version is now CURRENT_FORMAT_VERSION (2)
let (_nb_upgraded, m_upgraded) = load_notebook(path).expect("Upgraded notebook must load");
assert_eq!(m_upgraded.format_version, CURRENT_FORMAT_VERSION);
let p1_upgraded = load_page(path, 1).expect("Upgraded page must load");
assert_eq!(p1_upgraded.format_version, CURRENT_FORMAT_VERSION);
}
#[test]
fn test_fixture_unknown_fields_load_successfully() {
let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_page = root_dir.join("tests/fixtures/unknown_fields_page_1.json");
let temp_dir = tempfile::tempdir().expect("tempdir");
let path = temp_dir.path();
fs::copy(&fixture_page, path.join("page_1.json")).unwrap();
let page_data = load_page(path, 1).expect("Unknown fields must be tolerated");
assert_eq!(page_data.background, PageBackground::Lined);
assert_eq!(page_data.format_version, 1);
}
#[test]
fn test_fixture_future_version_is_rejected() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let path = temp_dir.path();
let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_manifest = root_dir.join("tests/fixtures/future_v99_manifest.json");
let fixture_page = root_dir.join("tests/fixtures/future_v99_page_1.json");
let dest_manifest = path.join("manifest.json");
let dest_page = path.join("page_1.json");
fs::copy(&fixture_manifest, &dest_manifest).unwrap();
fs::copy(&fixture_page, &dest_page).unwrap();
let orig_manifest_content = fs::read(&dest_manifest).unwrap();
let orig_page_content = fs::read(&dest_page).unwrap();
// Loading must fail with UnsupportedVersion
let load_res = load_notebook(path);
assert!(load_res.is_err(), "Future version must be rejected");
match load_res.unwrap_err() {
StorageError::UnsupportedVersion { found, supported } => {
assert_eq!(found, 99);
assert_eq!(supported, CURRENT_FORMAT_VERSION);
}
other => panic!("Expected UnsupportedVersion, got: {other:?}"),
}
// Files on disk must remain completely untouched
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"
);
}
#[test]
fn test_round_trip_fidelity() {
use crate::geometry::RecognizedShape;
use crate::spline::{finalize_stroke_with_config, SplineConfig};
let mut page = Page::new(1, PageBackground::Blank);
// 1. Dünner Stift (0.3 mm = 3.0 Einheiten)
let samples1 = vec![
StylusSample {
position: glam::Vec2::new(100.0, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(150.0, 100.0),
pressure: 0.6,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(200.0, 150.0),
pressure: 0.7,
timestamp: 0.2,
},
];
let cfg_thin = SplineConfig {
min_radius: 0.75,
max_radius: 2.25,
samples_per_segment: 16,
};
let stroke_thin =
finalize_stroke_with_config(1, &samples1, [0.0, 0.0, 0.0, 1.0], &cfg_thin);
page.add_stroke(stroke_thin);
// 2. Breiter Stift (1.5 mm = 15.0 Einheiten)
let samples2 = vec![
StylusSample {
position: glam::Vec2::new(200.0, 200.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 200.0),
pressure: 0.5,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(300.0, 250.0),
pressure: 0.5,
timestamp: 0.2,
},
];
let cfg_thick = SplineConfig {
min_radius: 3.75,
max_radius: 11.25,
samples_per_segment: 16,
};
let stroke_thick =
finalize_stroke_with_config(2, &samples2, [0.1, 0.2, 0.8, 1.0], &cfg_thick);
page.add_stroke(stroke_thick);
// 3. Textmarker (6.0 mm = 60.0 Einheiten)
let samples3 = vec![
StylusSample {
position: glam::Vec2::new(100.0, 300.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(200.0, 300.0),
pressure: 0.5,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(300.0, 300.0),
pressure: 0.5,
timestamp: 0.2,
},
];
let cfg_highlighter = SplineConfig {
min_radius: 15.0,
max_radius: 45.0,
samples_per_segment: 16,
};
let stroke_highlighter =
finalize_stroke_with_config(3, &samples3, [1.0, 1.0, 0.0, 0.4], &cfg_highlighter);
page.add_stroke(stroke_highlighter);
// 4. Erkannte Form (Kreis mit Breite 10.0 Einheiten)
let circle = RecognizedShape::Circle {
center: glam::Vec2::new(500.0, 500.0),
radius: 50.0,
};
let circle_stroke = crate::geometry::shape_to_stroke(circle, 4, [0.0, 0.5, 0.0, 1.0], 10.0);
page.add_stroke(circle_stroke);
// Serialisieren & Deserialisieren
let page_data = PageData::from(&page);
let restored_page = page_data.to_page();
assert_eq!(page.strokes.len(), restored_page.strokes.len());
for (i, (orig, restored)) in page
.strokes
.iter()
.zip(restored_page.strokes.iter())
.enumerate()
{
assert_eq!(
orig.vertices.len(),
restored.vertices.len(),
"Strich {i} Vertices-Anzahl weicht ab: orig {}, restored {}",
orig.vertices.len(),
restored.vertices.len()
);
for (v_idx, (v1, v2)) in orig
.vertices
.iter()
.zip(restored.vertices.iter())
.enumerate()
{
assert!(
(v1.position[0] - v2.position[0]).abs() < 1e-3
&& (v1.position[1] - v2.position[1]).abs() < 1e-3,
"Strich {i} Vertex {v_idx} Position weicht ab: orig {:?}, restored {:?}",
v1.position,
v2.position
);
}
}
}
#[test]
fn test_compact_json_size_reduction() {
use crate::spline::{finalize_stroke_with_config, SplineConfig};
let mut page = Page::new(1, PageBackground::Blank);
let samples: Vec<StylusSample> = (0..500)
.map(|i| StylusSample {
position: glam::Vec2::new(i as f32, (i * 2) as f32),
pressure: 0.5,
timestamp: i as f64 * 0.01,
})
.collect();
let stroke = finalize_stroke_with_config(
1,
&samples,
[0.0, 0.0, 0.0, 1.0],
&SplineConfig::default(),
);
page.add_stroke(stroke);
let page_data = PageData::from(&page);
let pretty_bytes = serde_json::to_vec_pretty(&page_data).unwrap();
let compact_bytes = serde_json::to_vec(&page_data).unwrap();
println!(
"\n[Dateigrößen-Vergleich] Pretty: {} Bytes | Kompakt: {} Bytes | Ersparnis: {:.1}%",
pretty_bytes.len(),
compact_bytes.len(),
(1.0 - compact_bytes.len() as f64 / pretty_bytes.len() as f64) * 100.0
);
assert!(
compact_bytes.len() < pretty_bytes.len(),
"Kompaktes JSON muss kleiner sein"
);
assert!(
compact_bytes.len() * 2 <= pretty_bytes.len(),
"Kompaktes JSON spart mind. 50% Whitespace"
);
}
/// Hilfsfunktion: Seite mit einem einzelnen Strich.
fn page_with_one_stroke(page_id: u64, background: PageBackground) -> Page {
let mut page = Page::new(page_id, background);
let samples = vec![
StylusSample {
position: glam::Vec2::new(100.0, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(200.0, 150.0),
pressure: 0.5,
timestamp: 0.05,
},
];
let vertices = vec![
InkVertex::new(100.0, 100.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(200.0, 150.0, [0.0, 0.0, 0.0, 1.0]),
];
page.add_stroke(Stroke::new(1, samples, vertices, [0.0, 0.0, 0.0, 1.0]));
page
}
#[test]
fn test_keyboard_new_page_is_recorded_in_manifest() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
notebook.next_page();
let idx = notebook.current_page_index;
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
autosave.mark_dirty(&notebook, &mut manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
let (mut reloaded, reloaded_manifest) = load_notebook(&dir).unwrap();
assert_eq!(reloaded_manifest.page_ids(), vec![1, 2]);
assert_eq!(
reloaded.pages.len(),
2,
"Neue Seite muss einen Neustart überleben"
);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].strokes.len(), 1);
}
#[test]
fn test_load_all_pages_for_export() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Export").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err());
load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
}
}