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