From ab1c561d6c300ed8abf877d63d2dd7c2f29d2540 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Mon, 20 Jul 2026 13:37:02 +0200 Subject: [PATCH] Robustheit: App-Settings-Lesepfad, Pool-Round-Trip, Archiv per create_new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - write_app_setting: nur eine fehlende Datei ergibt ein frisches Objekt; unlesbares/kaputtes JSON bricht ab, statt die übrigen App-Settings zu verwerfen. - struct Pool reicht unbekannte Keys per serde(flatten) durch; Umbenennen schreibt den gelesenen Bestand zurück. - Archiv-Dokumente entstehen über create_new (Kollisionsgarantie vom Dateisystem statt exists()-Vorabblick — TOCTOU beim Maschinen-Sync). --- src-tauri/src/domain/archive.rs | 63 +++++++++++++++++++++----------- src-tauri/src/domain/pool.rs | 21 ++++++----- src-tauri/src/domain/settings.rs | 11 +++++- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index e7e725c..1be3b8b 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -108,8 +108,13 @@ pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> { remove_archive_permission(&paths, project, &dir) } -/// Gegenstück zu add_archive_permission. -fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { +/// Gegenstück zu add_archive_permission; auch der Lösch-Dialog (Stufe „nur +/// Integration") nimmt darüber die Archiv-Rechte zurück. +pub(crate) fn remove_archive_permission( + paths: &Paths, + project: &str, + dir: &str, +) -> Result<(), String> { let sp = settings_path(&project_dir(paths, project)?); if !sp.is_file() { return Ok(()); @@ -175,29 +180,42 @@ pub(crate) fn archive_panel_content( .as_secs(); let (stamp, iso) = utc_stamp(secs); let title = first_line(&text); - let path = free_path(&dir, &stamp, &slugify(&title)); + let (path, mut file) = create_unique(&dir, &stamp, &slugify(&title))?; // Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID. let name = crate::domain::project::display_name_in(&Paths::real(), project)?; let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end()); - fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; + std::io::Write::write_all(&mut file, doc.as_bytes()) + .map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } -/// Freier Dateiname für das Archiv-Dokument. +/// Archiv-Dokument kollisionsfrei anlegen. /// /// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer -/// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write` -/// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv, -/// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen. -fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf { - let first = dir.join(format!("{stamp}-{slug}.md")); - if !first.exists() { - return first; +/// Minute mit derselben Titelzeile ergäbe denselben Namen — bei Kollision +/// wird `-2`, `-3`, … angehängt. Die Garantie kommt vom Dateisystem +/// (`create_new`), nicht von einem `exists()`-Vorabblick: Beim Archiv-Sync +/// über zwei Maschinen wäre der Vorabblick ein TOCTOU-Fenster, und stiller +/// Datenverlust träfe ausgerechnet die dauerhafte Ablage. +fn create_unique( + dir: &std::path::Path, + stamp: &str, + slug: &str, +) -> Result<(PathBuf, fs::File), String> { + for n in 1.. { + let name = if n == 1 { + format!("{stamp}-{slug}.md") + } else { + format!("{stamp}-{slug}-{n}.md") + }; + let path = dir.join(name); + match fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => return Ok((path, file)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("{}: {e}", path.display())), + } } - (2..) - .map(|n| dir.join(format!("{stamp}-{slug}-{n}.md"))) - .find(|p| !p.exists()) - .expect("unendlicher Zahlenraum") + unreachable!() } /// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root). @@ -377,21 +395,22 @@ mod tests { } /// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste - /// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung. + /// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung, und + /// die Datei entsteht bereits beim Anlegen (create_new), nicht erst beim + /// Schreiben. #[test] fn gleicher_stempel_und_titel_kollidiert_nicht() { let dir = crate::domain::testutil::tmp_paths().home.join("archiv"); fs::create_dir_all(&dir).unwrap(); - let erste = free_path(&dir, "2026-07-19_2118", "notiz"); + let (erste, mut f1) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap(); assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md"); - fs::write(&erste, "alt").unwrap(); + std::io::Write::write_all(&mut f1, b"alt").unwrap(); - let zweite = free_path(&dir, "2026-07-19_2118", "notiz"); + let (zweite, _f2) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap(); assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md"); - fs::write(&zweite, "neu").unwrap(); assert_eq!(fs::read_to_string(&erste).unwrap(), "alt"); - let dritte = free_path(&dir, "2026-07-19_2118", "notiz"); + let (dritte, _f3) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap(); assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md"); } diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index c6ebf2a..0a0cda8 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -22,6 +22,10 @@ pub(crate) struct Pool { pub(crate) name: String, #[serde(rename = "credentialType")] pub(crate) credential_type: String, + /// Unbekannte Keys unverändert durchreichen — beim Umbenennen wird die + /// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig). + #[serde(flatten)] + pub(crate) rest: serde_json::Map, } #[derive(Serialize)] @@ -136,13 +140,9 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result { Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) } -fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> { +fn write_pool_json(dir: &PathBuf, pool: &Pool) -> Result<(), String> { fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let pool = Pool { - name: name.to_string(), - credential_type: credential_type.to_string(), - }; - let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?; + let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?; crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n")) } @@ -381,7 +381,7 @@ pub(crate) fn create_apikey_pool_in( &dir, serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }), )?; - write_pool_json(&dir, name, "apikey")?; + write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), rest: Default::default() })?; if pool_sync_dir(paths).is_some() { link_pool_runtime_in(paths, &id)?; } @@ -395,7 +395,7 @@ pub(crate) fn create_apikey_pool_in( pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result { let dir = check_new_pool(paths, name)?; init_pool_config(&dir, serde_json::json!({}))?; - write_pool_json(&dir, name, "oauth")?; + write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "oauth".into(), rest: Default::default() })?; let id = dir.file_name().unwrap().to_string_lossy().into_owned(); if pool_sync_dir(paths).is_some() { link_pool_runtime_in(paths, &id)?; @@ -407,11 +407,12 @@ pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result Result<(), String> { check_name(name)?; - let current = read_pool(paths, pool)?; + let mut current = read_pool(paths, pool)?; if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) { return Err(format!("Pool existiert bereits: {name}")); } - write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type) + current.name = name.to_string(); + write_pool_json(&paths.pool_dir(pool), ¤t) } /// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den diff --git a/src-tauri/src/domain/settings.rs b/src-tauri/src/domain/settings.rs index 6eb10af..4a5face 100644 --- a/src-tauri/src/domain/settings.rs +++ b/src-tauri/src/domain/settings.rs @@ -62,13 +62,22 @@ pub(crate) fn spellcheck_lang(paths: &Paths) -> String { } /// Einen Schlüssel setzen; übrige App-settings bleiben erhalten. +/// +/// Nur eine *fehlende* Datei rechtfertigt ein frisches Objekt. Unlesbar oder +/// kaputtes JSON bricht ab — sonst würde ein einziges Verstellen der +/// Schriftgröße claudeCommand, poolSyncDir & Co. endgültig verwerfen +/// (read_app_settings wirft für die Lese-Getter alles in denselben None). fn write_app_setting( paths: &Paths, key: &str, value: serde_json::Value, ) -> Result<(), String> { let path = paths.config_dir().join(APP_SETTINGS_FILE); - let mut v = read_app_settings(paths).unwrap_or_else(|| serde_json::json!({})); + let mut v: serde_json::Value = match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; v[key] = value; fs::create_dir_all(paths.config_dir()) .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;