Robustheit: App-Settings-Lesepfad, Pool-Round-Trip, Archiv per create_new

- 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).
This commit is contained in:
marcus hinz
2026-07-20 13:37:02 +02:00
parent 446f52ca6d
commit ab1c561d6c
3 changed files with 62 additions and 33 deletions
+41 -22
View File
@@ -108,8 +108,13 @@ pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> {
remove_archive_permission(&paths, project, &dir) remove_archive_permission(&paths, project, &dir)
} }
/// Gegenstück zu add_archive_permission. /// Gegenstück zu add_archive_permission; auch der Lösch-Dialog (Stufe „nur
fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { /// 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)?); let sp = settings_path(&project_dir(paths, project)?);
if !sp.is_file() { if !sp.is_file() {
return Ok(()); return Ok(());
@@ -175,29 +180,42 @@ pub(crate) fn archive_panel_content(
.as_secs(); .as_secs();
let (stamp, iso) = utc_stamp(secs); let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text); 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. // Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID.
let name = crate::domain::project::display_name_in(&Paths::real(), project)?; let name = crate::domain::project::display_name_in(&Paths::real(), project)?;
let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end()); 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) Ok(path)
} }
/// Freier Dateiname für das Archiv-Dokument. /// Archiv-Dokument kollisionsfrei anlegen.
/// ///
/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer /// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer
/// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write` /// Minute mit derselben Titelzeile ergäbe denselben Namen — bei Kollision
/// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv, /// wird `-2`, `-3`, … angehängt. Die Garantie kommt vom Dateisystem
/// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen. /// (`create_new`), nicht von einem `exists()`-Vorabblick: Beim Archiv-Sync
fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf { /// über zwei Maschinen wäre der Vorabblick ein TOCTOU-Fenster, und stiller
let first = dir.join(format!("{stamp}-{slug}.md")); /// Datenverlust träfe ausgerechnet die dauerhafte Ablage.
if !first.exists() { fn create_unique(
return first; 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"))) unreachable!()
.find(|p| !p.exists())
.expect("unendlicher Zahlenraum")
} }
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root). /// 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 /// 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] #[test]
fn gleicher_stempel_und_titel_kollidiert_nicht() { fn gleicher_stempel_und_titel_kollidiert_nicht() {
let dir = crate::domain::testutil::tmp_paths().home.join("archiv"); let dir = crate::domain::testutil::tmp_paths().home.join("archiv");
fs::create_dir_all(&dir).unwrap(); 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"); 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"); 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"); 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"); assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md");
} }
+11 -10
View File
@@ -22,6 +22,10 @@ pub(crate) struct Pool {
pub(crate) name: String, pub(crate) name: String,
#[serde(rename = "credentialType")] #[serde(rename = "credentialType")]
pub(crate) credential_type: String, 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<String, serde_json::Value>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -136,13 +140,9 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) 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()))?; fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let pool = Pool { let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?;
name: name.to_string(),
credential_type: credential_type.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")) crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n"))
} }
@@ -381,7 +381,7 @@ pub(crate) fn create_apikey_pool_in(
&dir, &dir,
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }), 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() { if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?; 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<String, String> { pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
let dir = check_new_pool(paths, name)?; let dir = check_new_pool(paths, name)?;
init_pool_config(&dir, serde_json::json!({}))?; 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(); let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() { if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?; link_pool_runtime_in(paths, &id)?;
@@ -407,11 +407,12 @@ pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String,
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert. /// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> { pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
check_name(name)?; 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) { if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
return Err(format!("Pool existiert bereits: {name}")); return Err(format!("Pool existiert bereits: {name}"));
} }
write_pool_json(&paths.pool_dir(pool), name, &current.credential_type) current.name = name.to_string();
write_pool_json(&paths.pool_dir(pool), &current)
} }
/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den /// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
+10 -1
View File
@@ -62,13 +62,22 @@ pub(crate) fn spellcheck_lang(paths: &Paths) -> String {
} }
/// Einen Schlüssel setzen; übrige App-settings bleiben erhalten. /// 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( fn write_app_setting(
paths: &Paths, paths: &Paths,
key: &str, key: &str,
value: serde_json::Value, value: serde_json::Value,
) -> Result<(), String> { ) -> Result<(), String> {
let path = paths.config_dir().join(APP_SETTINGS_FILE); 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; v[key] = value;
fs::create_dir_all(paths.config_dir()) fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;