From d297844207d8515e076cf75352d5b92d7dbf12f3 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Mon, 27 Jul 2026 21:56:27 +0200 Subject: [PATCH] config: make alias sources Store authoritative --- docs/administration.md | 13 +- docs/architecture.md | 8 +- docs/directory-layout.md | 2 +- docs/setup-wizard.md | 4 +- src/apps/admin/admin.c | 8 +- src/apps/config/tests/test_configuration.py | 2 +- src/apps/config/tests/test_storage.py | 33 ++ src/apps/manager/manager.c | 345 +++++++++++++++++- .../python/bongo/configuration/storage.py | 23 +- 9 files changed, 419 insertions(+), 19 deletions(-) diff --git a/docs/administration.md b/docs/administration.md index 0abb586..c04483d 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -59,8 +59,17 @@ way. Aliases and virtual domains are split into suffixless JSON fragments below `/etc/bongo/aliases.d`. Every fragment may contain `aliases`, `domains`, or -both. The required `default_config` supplies global aliases; a full source -address overrides one domain only: +both. Their complete source set is stored operatively in the internal +`alias-fragments` Store document; the directory is its editable mirror. +Alias changes made by an administrative command update the Store and mirror +as one transaction. At manager startup, only a strictly newer file or +directory timestamp is treated as an emergency edit. An equally dated or +newer Store source refreshes the directory, including additions and +deletions. `bongo-admin config sync` explicitly selects a validated direct +file edit even within the same timestamp second. + +The required `default_config` supplies global aliases; a full source address +overrides one domain only: ```json { diff --git a/docs/architecture.md b/docs/architecture.md index ba635eb..c10f6ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,9 +92,11 @@ configured `bongo` service account before accepting either HTTP listener. Agent settings are operative suffixless JSON Store documents such as `smtp`, `imap`, `queue`, `web`, `authentication`, and `manager`. Their declarative counterparts live below `/etc/bongo/config.d`; alias fragments live below -`/etc/bongo/aliases.d`. The manager validates and imports the files only after -the Store starts, so agents always consume one coherent Store-facing model -and never fall back to a second runtime configuration source. +`/etc/bongo/aliases.d` and are represented operatively by one internal +`alias-fragments` Store source document. The manager reconciles the mirrors +only after the Store starts, compiles the authoritative alias source into its +per-domain delivery documents, and never lets an equally dated file override +the Store. `bongo-admin config` reads the Store and writes Store plus files as one rollback-capable operation. Use `bongo-admin config check` for a read-only diff --git a/docs/directory-layout.md b/docs/directory-layout.md index b0c4c04..c662f54 100644 --- a/docs/directory-layout.md +++ b/docs/directory-layout.md @@ -10,7 +10,7 @@ configuration under `/etc`, not `/usr/etc`. | --- | --- | | `/etc/bongo` | Administrator-managed configuration and protected key directories. | | `/etc/bongo/config.d` | Suffixless JSON mirrors for every operative Store configuration document. | -| `/etc/bongo/aliases.d` | Mergeable suffixless alias and virtual-domain JSON fragments. | +| `/etc/bongo/aliases.d` | Editable mirror of the Store-authoritative, mergeable suffixless alias and virtual-domain fragments. | | `/etc/bongo/ssl.d` | TLS certificates and private keys managed by setup or an ACME deploy hook. | | `/etc/bongo/dkim.d` | Per-domain DKIM keys and related material. | | `/etc/bongo/dmarc.d` | DMARC setup state and report-processing policy. | diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md index 5a00e1f..cd3cb4a 100644 --- a/docs/setup-wizard.md +++ b/docs/setup-wizard.md @@ -101,7 +101,9 @@ admin changes, and rolls back prior writes if a later write fails. `bongo-admin config sync` transactionally writes validated file changes and new template defaults to the Store, then asks the running manager to reload. Alias and virtual-domain fragments are kept separately in -`/etc/bongo/aliases.d` and are synchronized by the same manager operation. +`/etc/bongo/aliases.d`. Their operative `alias-fragments` Store source remains +authoritative; the same manager operation imports an explicitly newer or +forced emergency file edit and otherwise refreshes the fragment mirror. Configuration names such as `smtp`, `imap`, `antispam`, `antivirus`, `web`, and `authentication` have no `.json` suffix, although their contents are diff --git a/src/apps/admin/admin.c b/src/apps/admin/admin.c index ee0aa46..97af0c7 100644 --- a/src/apps/admin/admin.c +++ b/src/apps/admin/admin.c @@ -85,6 +85,8 @@ ValidConfigurationName(const char *name) if (!strcmp(name, *cursor)) return 1; } + if (!strcmp(name, "alias-fragments")) + return 1; if (!strncmp(name, "aliases/", 8)) { const unsigned char *alias = (const unsigned char *)name + 8; size_t length = strlen((const char *)alias); @@ -372,6 +374,7 @@ AccessConfiguration(const char *name, int replace) char *original = NULL; size_t length = 0; uint64_t modified = 0; + int mirror; int conn_started = 0; int msg_started = 0; int result = 1; @@ -381,6 +384,7 @@ AccessConfiguration(const char *name, int replace) name ? name : "(null)"); return 2; } + mirror = !strchr(name, '/') && strcmp(name, "alias-fragments"); if (replace && ReadConfigurationInput(&content, &length) != 0) return 1; if (replace && (BongoJsonParseString(content, &root) != BONGO_JSON_OK || @@ -402,7 +406,7 @@ AccessConfiguration(const char *name, int replace) goto done; } if (replace) { - if (!strchr(name, '/') && + if (mirror && (!NMAPReadConfigFile(name, &original) || !original)) { fprintf(stderr, "bongo-admin: cannot snapshot configuration %s\n", name); @@ -413,7 +417,7 @@ AccessConfiguration(const char *name, int replace) name); goto done; } - if (!strchr(name, '/') && + if (mirror && (!NMAPReadConfigFileModified(name, &modified) || !WriteConfigurationMirror(name, content, length, modified))) { if (!original || diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index 4869275..ea7ad66 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -75,7 +75,7 @@ class ConfigurationModelTest(unittest.TestCase): source = (SOURCE / "src" / "apps" / "manager" / "manager.c").read_text( encoding="utf-8") match = re.search( - r"SyncLocalConfiguration\(\).*?" + r"SyncLocalConfiguration\(BOOL forceLocal\).*?" r"static const char \*const names\[\] = \{(.*?)NULL", source, re.DOTALL) self.assertIsNotNone(match) diff --git a/src/apps/config/tests/test_storage.py b/src/apps/config/tests/test_storage.py index 59465be..52365f4 100644 --- a/src/apps/config/tests/test_storage.py +++ b/src/apps/config/tests/test_storage.py @@ -59,6 +59,8 @@ class FakeStore(ConfigurationStore): } self.domain_aliases = {} self.username_mappings = {} + self.alias_fragments = {} + self.fail_alias_fragments = False self.fail_name = None def read_store_document(self, name): @@ -84,6 +86,19 @@ class FakeStore(ConfigurationStore): return value, json_bytes(value) return self.read_store_document(name) + def _helper(self, operation, name, content=None): + if name != "alias-fragments": + return super()._helper(operation, name, content) + if operation == "__config-replace": + if self.fail_alias_fragments: + self.fail_alias_fragments = False + raise ConfigurationError("injected alias Store failure") + self.alias_fragments = json.loads(content) + return b"" + if operation == "__config-read": + return json_bytes(self.alias_fragments) + raise ConfigurationError("unsupported fake alias helper operation") + class FakeSocket: def __init__(self, response): @@ -450,6 +465,9 @@ class ConfigurationStorageTest(unittest.TestCase): aliases = json.loads( (self.paths.aliases_dir / "account-renames").read_text()) self.assertEqual(aliases["aliases"]["old-user"], "new-user") + self.assertEqual( + self.store.alias_fragments["account-renames"]["aliases"] + ["old-user"], "new-user") self.store.finish_retained_alias("101", True) self.assertFalse( (self.paths.lock.parent / "bongo-alias-rename-101").exists()) @@ -465,6 +483,21 @@ class ConfigurationStorageTest(unittest.TestCase): self.store.prepare_retained_alias("old-user", "new-user", "102") self.store.finish_retained_alias("102", False) self.assertFalse((self.paths.aliases_dir / "account-renames").exists()) + self.assertNotIn("account-renames", self.store.alias_fragments) + + def test_retained_alias_store_failure_restores_file_and_transaction(self): + self.paths.aliases_dir.mkdir(parents=True) + (self.paths.aliases_dir / "default_config").write_bytes(json_bytes({ + "aliases": {"postmaster": "admin"}, + })) + self.store.fail_alias_fragments = True + with self.assertRaisesRegex(ConfigurationError, + "injected alias Store failure"): + self.store.prepare_retained_alias( + "old-user", "new-user", "105") + self.assertFalse((self.paths.aliases_dir / "account-renames").exists()) + self.assertFalse( + (self.paths.lock.parent / "bongo-alias-rename-105").exists()) def test_retained_alias_prepare_rejects_conflicting_mapping(self): self.paths.aliases_dir.mkdir(parents=True) diff --git a/src/apps/manager/manager.c b/src/apps/manager/manager.c index 3b536bb..faded35 100644 --- a/src/apps/manager/manager.c +++ b/src/apps/manager/manager.c @@ -801,7 +801,8 @@ done: } static BOOL -SyncConfigurationDocument(const char *storeName, const char *path) +SyncConfigurationDocument(const char *storeName, const char *path, + BOOL forceLocal) { char *local = NULL; char *stored = NULL; @@ -834,7 +835,7 @@ SyncConfigurationDocument(const char *storeName, const char *path) result = SetConfigurationFileTime(path, &details, storeModified); goto done; } - if ((uint64_t)details.st_mtime > storeModified) { + if (forceLocal || (uint64_t)details.st_mtime > storeModified) { if (!SyncConfigurationContent(storeName, path, local) || !NMAPReadConfigFileModified(storeName, &storeModified)) goto done; @@ -878,6 +879,267 @@ ValidAliasConfigurationName(const char *name) return TRUE; } +static BOOL +ReadAliasSourceDirectory(BongoJsonNode **rootOut, char **contentOut, + uint64_t *modifiedOut) +{ + DIR *directory = NULL; + struct dirent *entry; + struct stat directoryDetails; + BongoJsonObject *fragments = NULL; + BongoJsonNode *aggregate = NULL; + char *content = NULL; + uint64_t modified; + size_t total = 0; + BOOL foundDefault = FALSE; + BOOL result = FALSE; + + if (lstat(XPL_DEFAULT_ALIASES_DIR, &directoryDetails) != 0 || + !S_ISDIR(directoryDetails.st_mode)) { + fprintf(stderr, _("bongo-manager: invalid alias directory %s\n"), + XPL_DEFAULT_ALIASES_DIR); + goto done; + } + modified = (uint64_t)directoryDetails.st_mtime; + fragments = BongoJsonObjectNew(); + if (!fragments) + goto done; + directory = opendir(XPL_DEFAULT_ALIASES_DIR); + if (!directory) + goto done; + while ((entry = readdir(directory)) != NULL) { + BongoJsonNode *fragment = NULL; + struct stat details; + char path[XPL_MAX_PATH + 1]; + char *fragmentContent = NULL; + size_t fragmentLength; + + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) + continue; + if (!ValidAliasConfigurationName(entry->d_name) || + snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR, + entry->d_name) >= (int)sizeof(path) || + !ReadConfigurationFile(path, &fragmentContent, &details) || + BongoJsonParseString(fragmentContent, &fragment) != BONGO_JSON_OK || + !fragment || fragment->type != BONGO_JSON_OBJECT) { + fprintf(stderr, + _("bongo-manager: invalid alias source fragment %s/%s\n"), + XPL_DEFAULT_ALIASES_DIR, entry->d_name); + if (fragment) + BongoJsonNodeFree(fragment); + if (fragmentContent) + MemFree(fragmentContent); + goto done; + } + fragmentLength = strlen(fragmentContent); + if (fragmentLength > MAX_LOCAL_CONFIG_SIZE - total) { + fprintf(stderr, + _("bongo-manager: alias source exceeds %u bytes\n"), + MAX_LOCAL_CONFIG_SIZE); + BongoJsonNodeFree(fragment); + MemFree(fragmentContent); + goto done; + } + total += fragmentLength; + MemFree(fragmentContent); + if ((uint64_t)details.st_mtime > modified) + modified = (uint64_t)details.st_mtime; + if (!strcmp(entry->d_name, "default_config")) + foundDefault = TRUE; + if (BongoJsonObjectPut(fragments, entry->d_name, fragment) != + BONGO_JSON_OK) { + BongoJsonNodeFree(fragment); + goto done; + } + } + if (!foundDefault) { + fprintf(stderr, _("bongo-manager: missing %s/default_config\n"), + XPL_DEFAULT_ALIASES_DIR); + goto done; + } + aggregate = BongoJsonNodeNewObject(fragments); + if (!aggregate) + goto done; + fragments = NULL; + content = BongoJsonNodeToString(aggregate); + if (!content) + goto done; + *rootOut = aggregate; + *contentOut = content; + *modifiedOut = modified; + aggregate = NULL; + content = NULL; + result = TRUE; + +done: + if (directory) + closedir(directory); + if (aggregate) + BongoJsonNodeFree(aggregate); + else if (fragments) + BongoJsonObjectFree(fragments); + if (content) + MemFree(content); + return result; +} + +static BOOL +SetAliasSourceTimes(BongoJsonNode *root, uint64_t modified) +{ + BongoJsonObjectIter iterator; + BongoJsonObject *fragments; + struct stat details; + char path[XPL_MAX_PATH + 1]; + + if (!root || root->type != BONGO_JSON_OBJECT) + return FALSE; + fragments = BongoJsonNodeAsObject(root); + for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key; + BongoJsonObjectIterNext(fragments, &iterator)) { + if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR, + iterator.key) >= (int)sizeof(path) || + lstat(path, &details) != 0 || !S_ISREG(details.st_mode) || + !SetConfigurationFileTime(path, &details, modified)) + return FALSE; + } + if (lstat(XPL_DEFAULT_ALIASES_DIR, &details) != 0 || + !S_ISDIR(details.st_mode) || + !SetConfigurationFileTime(XPL_DEFAULT_ALIASES_DIR, &details, modified)) + return FALSE; + return TRUE; +} + +static BOOL +ValidateAliasSourceRoot(BongoJsonNode *root) +{ + BongoJsonObjectIter iterator; + BongoJsonObject *fragments; + BOOL foundDefault = FALSE; + + if (!root || root->type != BONGO_JSON_OBJECT) + return FALSE; + fragments = BongoJsonNodeAsObject(root); + for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key; + BongoJsonObjectIterNext(fragments, &iterator)) { + if (!ValidAliasConfigurationName(iterator.key) || !iterator.value || + iterator.value->type != BONGO_JSON_OBJECT) + return FALSE; + if (!strcmp(iterator.key, "default_config")) + foundDefault = TRUE; + } + return foundDefault; +} + +static BOOL +WriteAliasSourceDirectory(BongoJsonNode *root, uint64_t modified) +{ + DIR *directory = NULL; + struct dirent *entry; + BongoJsonObjectIter iterator; + BongoJsonObject *fragments; + struct stat directoryDetails; + uid_t effectiveUser = geteuid(); + int savedError = 0; + BOOL result = FALSE; + + if (!ValidateAliasSourceRoot(root) || + lstat(XPL_DEFAULT_ALIASES_DIR, &directoryDetails) != 0 || + !S_ISDIR(directoryDetails.st_mode)) + return FALSE; + fragments = BongoJsonNodeAsObject(root); + directory = opendir(XPL_DEFAULT_ALIASES_DIR); + if (!directory) + return FALSE; + while ((entry = readdir(directory)) != NULL) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) + continue; + if (!ValidAliasConfigurationName(entry->d_name)) { + fprintf(stderr, _("bongo-manager: unsupported alias file %s/%s\n"), + XPL_DEFAULT_ALIASES_DIR, entry->d_name); + goto done; + } + } + closedir(directory); + directory = NULL; + if (effectiveUser != 0 && XplSetEffectiveUserId(0) != 0) + goto done; + for (BongoJsonObjectIterFirst(fragments, &iterator); iterator.key; + BongoJsonObjectIterNext(fragments, &iterator)) { + struct stat details = directoryDetails; + char path[XPL_MAX_PATH + 1]; + char *content; + + if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR, + iterator.key) >= (int)sizeof(path)) + goto done; + if (lstat(path, &details) != 0) { + if (errno != ENOENT) + goto done; + details = directoryDetails; + details.st_mode = S_IFREG | 0644; + } else if (!S_ISREG(details.st_mode)) { + errno = EINVAL; + goto done; + } + content = BongoJsonNodeToString(iterator.value); + if (!content) + goto done; + if (!WriteConfigurationFile(path, content, &details, modified)) { + MemFree(content); + goto done; + } + MemFree(content); + } + directory = opendir(XPL_DEFAULT_ALIASES_DIR); + if (!directory) + goto done; + while ((entry = readdir(directory)) != NULL) { + BongoJsonNode *unused = NULL; + char path[XPL_MAX_PATH + 1]; + + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) + continue; + if (BongoJsonObjectGet(fragments, entry->d_name, &unused) == + BONGO_JSON_OK) + continue; + if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_ALIASES_DIR, + entry->d_name) >= (int)sizeof(path) || + unlink(path) != 0) + goto done; + } + closedir(directory); + directory = NULL; + if (!SetAliasSourceTimes(root, modified)) + goto done; + { + int descriptor = open(XPL_DEFAULT_ALIASES_DIR, + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + + if (descriptor < 0 || fsync(descriptor) != 0) { + if (descriptor >= 0) + close(descriptor); + goto done; + } + close(descriptor); + } + result = TRUE; + +done: + savedError = errno; + if (directory) + closedir(directory); + if (effectiveUser != 0 && XplSetEffectiveUserId(effectiveUser) != 0) { + savedError = errno; + result = FALSE; + } + errno = savedError; + if (!result) + fprintf(stderr, + _("bongo-manager: cannot refresh alias source from Store: %s\n"), + strerror(errno)); + return result; +} + typedef struct { const char *name; BongoJsonObject *aliases; @@ -1691,7 +1953,76 @@ done: } static BOOL -SyncLocalConfiguration() +SyncAliasSourceConfiguration(BOOL forceLocal) +{ + BongoJsonNode *localRoot = NULL; + BongoJsonNode *storedRoot = NULL; + char *local = NULL; + char *stored = NULL; + char *canonicalStored = NULL; + uint64_t localModified = 0; + uint64_t storeModified = 0; + BOOL result = FALSE; + + if (!ReadAliasSourceDirectory(&localRoot, &local, &localModified)) + goto done; + if (!NMAPReadConfigFile("alias-fragments", &stored) || !stored) { + if (!SyncAliasConfiguration() || + !NMAPReplaceConfigFile("alias-fragments", local, strlen(local)) || + !NMAPReadConfigFileModified("alias-fragments", &storeModified) || + !SetAliasSourceTimes(localRoot, storeModified)) + goto done; + printf(_("bongo-manager: initialized authoritative alias fragments\n")); + result = TRUE; + goto done; + } + if (BongoJsonParseString(stored, &storedRoot) != BONGO_JSON_OK || + !ValidateAliasSourceRoot(storedRoot) || + !(canonicalStored = BongoJsonNodeToString(storedRoot)) || + !NMAPReadConfigFileModified("alias-fragments", &storeModified)) { + fprintf(stderr, + _("bongo-manager: cannot read authoritative alias fragments\n")); + goto done; + } + if (BongoJsonNodeEqual(localRoot, storedRoot)) { + if (!SetAliasSourceTimes(localRoot, storeModified) || + !SyncAliasConfiguration()) + goto done; + result = TRUE; + goto done; + } + if (forceLocal || localModified > storeModified) { + if (!SyncAliasConfiguration() || + !NMAPReplaceConfigFile("alias-fragments", local, strlen(local)) || + !NMAPReadConfigFileModified("alias-fragments", &storeModified) || + !SetAliasSourceTimes(localRoot, storeModified)) + goto done; + printf(_("bongo-manager: applied emergency alias file changes to Store\n")); + result = TRUE; + } else { + if (!WriteAliasSourceDirectory(storedRoot, storeModified) || + !SyncAliasConfiguration()) + goto done; + printf(_("bongo-manager: refreshed alias files from the Store\n")); + result = TRUE; + } + +done: + if (localRoot) + BongoJsonNodeFree(localRoot); + if (storedRoot) + BongoJsonNodeFree(storedRoot); + if (local) + MemFree(local); + if (stored) + MemFree(stored); + if (canonicalStored) + MemFree(canonicalStored); + return result; +} + +static BOOL +SyncLocalConfiguration(BOOL forceLocal) { static const char *const names[] = { "global", "queue", "smtp", "imap", "pop3", "sieve", "collector", @@ -1705,10 +2036,10 @@ SyncLocalConfiguration() if (snprintf(path, sizeof(path), "%s/%s", XPL_DEFAULT_CONFIG_DIR, *name) >= (int)sizeof(path) || - !SyncConfigurationDocument(*name, path)) + !SyncConfigurationDocument(*name, path, forceLocal)) return FALSE; } - return SyncAliasConfiguration(); + return SyncAliasSourceConfiguration(forceLocal); } static void @@ -2216,7 +2547,7 @@ main(int argc, char **argv) goto err_handler; } StartStore(); - if (!SyncLocalConfiguration()) { + if (!SyncLocalConfiguration(FALSE)) { printf(_("bongo-manager: Couldn't apply local configuration\n")); goto err_handler; } @@ -2244,7 +2575,7 @@ main(int argc, char **argv) } if (ReloadNow) { - if (!SyncLocalConfiguration()) { + if (!SyncLocalConfiguration(TRUE)) { fprintf(stderr, _("bongo-manager: configuration synchronization failed\n")); ReloadNow = FALSE; diff --git a/src/libs/python/bongo/configuration/storage.py b/src/libs/python/bongo/configuration/storage.py index bc817a8..35cf1ef 100644 --- a/src/libs/python/bongo/configuration/storage.py +++ b/src/libs/python/bongo/configuration/storage.py @@ -376,6 +376,13 @@ class ConfigurationStore: merged_domains[source_key] = (destination, fragment) return aliases + def _write_alias_fragments_to_store( + self, fragments: Mapping[str, Mapping[str, Any]]) -> None: + self._helper( + "__config-replace", "alias-fragments", + json_bytes({name: dict(value) + for name, value in fragments.items()})) + def dmarc_report_users(self, hosted_domains: Iterable[str]) -> dict[str, str]: """Resolve each hosted domain's DMARC reporting alias for UI tasks.""" domains = [domain_to_ascii(str(domain)) for domain in hosted_domains] @@ -618,7 +625,12 @@ class ConfigurationStore: transaction, json_bytes(record), _FileSnapshot(False, b"", 0o600, os.geteuid(), os.getegid())) + self._write_alias_fragments_to_store(self.load_aliases()) except BaseException: + try: + transaction.unlink() + except FileNotFoundError: + pass if changed: _restore(target, before) raise @@ -654,10 +666,17 @@ class ConfigurationStore: raise ConfigurationError( "invalid alias rename rollback data") from error current = _snapshot(target) - if not current.exists or current.content != expected: + before_matches = ( + current.exists == before.exists and + (not current.exists or current.content == before.content) + ) + if not before_matches and ( + not current.exists or current.content != expected): raise ConfigurationError( "account rename aliases changed during the transaction") - _restore(target, before) + if not before_matches: + _restore(target, before) + self._write_alias_fragments_to_store(self.load_aliases()) transaction.unlink() _fsync_directory(transaction.parent) finally: