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)
}
/// 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");
}
+11 -10
View File
@@ -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<String, serde_json::Value>,
}
#[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()))
}
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<String, String> {
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<String,
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> 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, &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
+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.
///
/// 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()))?;