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:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¤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
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
Reference in New Issue
Block a user