Skip to content

Synapse v2.1.15 — "a prefix is not a name"

Patch release closing two defects that delete data and one that leaks a credential, found by reading the backup and page-history retention paths during weekly maintenance. No schema migrations, no new endpoints, no API changes, no new configuration keys.

Like 2.1.8 through 2.1.14, none of the three came from a live failure report. Two of them are literally the same mistake in two different modules, which is what makes this week worth writing down:

A glob of the form <name>-* does not select "the files of <name>". The * also swallows the <suffix>- of every longer name that begins with <name>-.

Both places then deleted what they had over-matched. In one, a vault's retention pruned a sibling vault's database dumps and kept none of its own; in the other, saving one wiki page erased a differently-named page's revision history. The third defect is the familiar "code that states an intent the code does not keep": a module whose header promises the DSN is never logged, and one error path that logs it.

Fixed

One vault's backup retention deleted another vault's dumps — and kept none of its own

run_backup() writes synapse-<vault_id>-<UTC stamp>.dump and then calls _apply_retention() to keep the newest N. That function decided what belonged to a vault with a glob:

pattern = f"{_ARCHIVE_GLOB_PREFIX}{vault_id}-*{_ARCHIVE_SUFFIX}"
archives = sorted(backup_dir.glob(pattern), key=lambda p: p.name, reverse=True)
stale = archives[keep:]

For vault home that is synapse-home-*.dump — which also matches every archive belonging to a vault called home-lab, because * happily absorbs lab-20260101T000000Z. Vault ids that are --separated extensions of each other are not exotic; home / home-lab and work / work-notes are the names people actually choose.

Over-matching alone would merely be wasteful. What makes it destructive is the line immediately after, which sorts by name to decide which archives are newest. After the shared synapse-home- prefix, the next character is l for the sibling and a digit for the real archives — and l (0x6C) sorts above 2 (0x32). So with reverse=True all of home-lab's archives sort first and are kept as "the newest", while every one of home's own dumps falls past the keep window and is unlinked.

Reproduced before the fix, three archives each and keep=3:

before: synapse-home-2026010{1,2,3}…, synapse-home-lab-2026010{1,2,3}…
deleted: 3
after : synapse-home-lab-2026010{1,2,3}…          ← vault "home" has zero backups

Vault home asked to keep three and was left with none, while its retention counter reported a healthy prune. This is the disaster-recovery artifact — the thing run_backup exists to produce before Watchtower recreates the containers — so the failure mode is discovering there is no backup at the moment one is needed.

Archives are now enumerated with a vault-agnostic glob and filtered through an anchored matcher that also pins the fixed-width timestamp:

def _archive_name_re(vault_id: str) -> re.Pattern[str]:
    return re.compile(
        rf"^{re.escape(_ARCHIVE_GLOB_PREFIX)}{re.escape(vault_id)}-"
        rf"{_ARCHIVE_STAMP_PATTERN}{re.escape(_ARCHIVE_SUFFIX)}$"
    )

Three things follow. Only this vault's archives can be pruned. The name sort stays chronological, because every surviving name shares an identical prefix and differs only in the fixed-width stamp. And foreign filenames — a half-written …dump.tmp, a hand-renamed archive with no timestamp — are no longer candidates for deletion at all. Escaping the vault id also closes the latent case of an id containing glob metacharacters.

Overwriting one wiki page deleted another page's history

write_block_page() keeps a bounded on-disk history of each page's previous bytes under .synapse/page-history/<stem>-<n>.md, capped by ingest_page_history_max_per_page. The listing had the same prefix-glob flaw, compounded by an unanchored index regex:

_BACKUP_INDEX_RE = re.compile(r"-(\d+)\.md$")
...
for path in history_dir.glob(f"{stem}-*.md"):
    match = _BACKUP_INDEX_RE.search(path.name)

Neither half is an identity test. _sanitize_backup_stem() deliberately keeps - (it is inside the allowed character class [A-Za-z0-9._-]), so concepts/rag.md and concepts/rag-pipeline.md become wiki__concepts__rag and wiki__concepts__rag-pipeline — one a --separated prefix of the other. The glob for rag returns wiki__concepts__rag-pipeline-7.md, and an unanchored search reads 7 straight out of it.

The listing feeds two callers, and the harmful one is the trim that runs after every overwrite:

while len(backups) > cap:
    _idx, oldest = backups.pop(0)
    oldest.unlink(missing_ok=True)

It deletes the lowest index first. Because the list held another page's files, churning the shorter-named page deleted the longer-named page's history. The regression test drives this through the real write_block_page() path, and without the fix the sibling's history comes back as [] — both of its revisions gone, while the page that caused it kept all of its own. The other caller, next_idx = existing[-1][0] + 1, is the benign half: it takes the maximum, so it skips index numbers rather than overwriting.

The regex is now the authoritative gate, anchored on the exact escaped stem:

def _backup_index_re(stem: str) -> re.Pattern[str]:
    return re.compile(rf"^{re.escape(stem)}-(\d+)\.md$")

The glob stays narrow, so the OS still does the coarse filtering and the directory is not re-scanned in full; every name the regex accepts is a subset of what the glob returns.

Security

The Postgres password reached the backend log

app/ops/backup.py opens with an explicit promise:

Security: the Postgres password is passed via the PGPASSWORD subprocess env var (never on the command line, so it never appears in ps/logs); the DSN itself is never logged.

The command line honours that. The parse-failure path did not:

raise _DsnParseError(f"database_url is missing host or database name: {database_url!r}")

run_backup() catches that error, logs it at ERROR, and returns the same string in BackupSummary.error_message — which trigger_system_update() logs a second time when a pre-update backup fails. So a password configured in DATABASE_URL lands in plaintext in the application log, twice, in the one place the module had declared off-limits.

The root cause is a guard written to be helpful — here is the DSN I could not parse — about a value the module had classified as a secret everywhere else.

It needs no adversary. _DsnParseError fires when the DSN has no host or no database name, and a unix-socket DSN has no host by construction:

postgresql+asyncpg://synapse:SuperSecret123@/synapse

That is ordinary Postgres configuration, and the resulting log line is exactly what an operator would paste into a bug report or a support thread.

The message is now redacted, and the netloc is rebuilt from the parsed components rather than string-replaced, so a password containing @ or : cannot survive into the output:

database_url is missing host or database name: 'postgresql+asyncpg://synapse:***@/synapse'

A DSN with no password is returned unchanged — there is nothing in it to hide — and the message stays actionable: the operator can still see which DSN was rejected and why.

Changed

mypy strict restored after a SQLAlchemy minor landed underneath CI

mypy app began failing on main with two errors, in two files nobody had touched:

app/ingest/context.py:94: Argument 1 to "append" of "list" has incompatible type "str | None"
app/ops/lint/detectors.py:102: Need type annotation for "target_rows"

Nothing in the repository changed. CI was green on main on 2026-09-18 (the 2.1.14 merge), with mypy pinned to ==2.1.0 — still the installed version. What changed is underneath: backend/pyproject.toml asks for sqlalchemy[asyncio]>=2.0.0 with no upper bound, and CI installs with a bare pip install -e ., so the job now resolves SQLAlchemy 2.1, which narrowed the element typing of Row/Result. Both sites were always loosely typed; 2.0 simply inferred Any and said nothing.

Both fixes are type-only, with no runtime behaviour change. Page.title is Mapped[str | None] and the query already excludes NULLs with Page.title.is_not(None), which the checker cannot see, so the guard is now explicit and is a no-op at runtime; func.distinct() erases the column type, so target_rows carries the annotation the line below it already assumes.

This is included in a patch release because the typecheck stage blocks merge: left alone it makes every PR red for a cause outside the repository — which is also how a team learns to re-run a red job without reading it. The underlying problem is a process one, and is filed in docs/reference/ROADMAP-3.0-IDEAS.md rather than attempted here.

Also in this release

One new entry in the 3.0 backlog (docs/reference/ROADMAP-3.0-IDEAS.md): backend dependencies are unpinned, so a minor upstream can break CI without a commit — the mechanism behind the mypy failure above. The two lines are fixed; the mechanism that let them appear on a branch nobody touched is not, because closing it means a lockfile or constraints file plus a decision about who regenerates it and how a deliberate bump is told apart from drift. That decision interacts with the long-standing pypdf entry (41 distinct advisories, all fixed only in the 6.x line, behind a <6 pin) and should be taken together with it, not separately.

Upgrade notes

docker compose pull && docker compose up -d
  • No schema migration. No SQLAlchemy model changed, so docs/er/schema.mmd is unchanged and no Alembic revision ships with this release.
  • No API change. docs/api/openapi.json differs only in the version string.
  • No new configuration. DATABASE_URL, BACKUP_ROOT, BACKUP_RETENTION_COUNT and INGEST_PAGE_HISTORY_MAX_PER_PAGE keep their meanings and defaults; nothing new to set in .env.
  • Check your backup directory if you run more than one vault. The retention fix stops further damage but cannot restore what earlier runs deleted. If two of your vault ids are --separated extensions of each other (home / home-lab), list $BACKUP_ROOT/synapse-*.dump and confirm the shorter-named vault still has the number of archives you expect. If it does not, take a fresh dump — POST /ops/schedules/backup/run-now — before relying on the next scheduled one.
  • Page history may look shorter than the cap on some pages. Same reason: revisions deleted by an earlier run are gone. New revisions accumulate correctly from this release on, per page, up to ingest_page_history_max_per_page. No page content was ever at risk — the live files in wiki/ are untouched by this path, only the .synapse/page-history/ copies.
  • If you have ever seen a database_url is missing host or database name line in your logs, treat the password in it as disclosed and rotate it. The line is redacted from this release forward, but existing log files, shipped logs and docker logs buffers still hold the original.
  • No client-side state to clear. Existing OAuth grants, refresh tokens, API tokens, conversations and vault content are untouched.