Skip to content

Synapse v2.1.16 — "a rename carries its own mode"

Patch release closing a defect that quietly makes the shared vault unreadable from the Obsidian side, a data-loss guard missing from one of two sibling maintenance ops, and the only dependency advisory that reaches the production bundle. No schema migrations, no new endpoints, no API changes, no new configuration keys.

Like 2.1.8 through 2.1.15, none of them came from a live failure report — all three were found by reading code during weekly maintenance. And like most of those weeks, the shape is a guard applied at one of the places that need it. What makes this week worth writing down is that the repository had already written the fix down and then not generalised it:

Four weeks ago, 2.1.12 added an os.chmod to the one vault write site it was converting, with a comment explaining exactly why the vault needs it. Six older sites write the same way. None of them got it.

The second fix is the same sentence about a different pair: ops/reconcile_folders documents and enforces a "never overwrite" guard, and its twin module — whose header says it mirrors that architecture — does not have one.

Fixed

Every file published into the vault was owner-only, and saving a page edit downgraded an existing one

Every crash-safe vault write in Synapse has the same two steps: write the bytes to a temp file in the destination's own directory, then rename it over the destination, so no reader — Obsidian, LiveSync, the watcher — ever observes a half-written file. The temp file comes from tempfile.mkstemp:

tmp_fd, tmp_name = tempfile.mkstemp(dir=str(abs_path.parent), suffix=".block_tmp")
os.write(tmp_fd, data)
os.close(tmp_fd)
Path(tmp_name).replace(abs_path)          # ← the destination's mode does not survive this

mkstemp creates its file 0600 — correct, it is a temp file. The mistake is in the last line. os.replace does not copy the destination's permissions onto the incoming file: it unlinks the destination inode and gives its name to the temp file, which keeps the mode it already had. So the consequence is worse than "new vault files are owner-only":

  • a newly generated page lands at 0600;
  • rewriting an existing 0644 page takes it down to 0600.

The second is the one a user meets. PUT /pages/{page_id}/content — the editor's save — goes through this path on every call, so editing a page through the UI silently changed its permissions.

Why that matters here and not in a generic app: the backend runs as uid 1000 inside its container, while vault/ is a bind mount shared with Obsidian/LiveSync on the host (CLAUDE.md §1 — the vault-within-vault model is the whole point of the product). A 0600 file is one that only the backend can read. Invariant I5 says wiki/ must remain a valid Obsidian vault; a vault whose pages Obsidian cannot open is not one. And the failure is completely silent: nothing logs it, no functional test notices (the backend reads its own files fine), and the symptom surfaces on the other side of the sync — which is why a user would file it against LiveSync, not against Synapse.

Six sites were affected:

Site What it writes
ingest/block_writer._atomic_write every page the ingest loop generates
ingest/orchestrator.reindex_wiki_page_body the in-place rewrite behind wikilink enrichment
PUT /pages/{page_id}/content the editor's save — downgrades a live page
POST /ingest/upload uploaded source files
POST /ingest/from-text pasted / save-to-wiki sources
POST /clip Chrome clipper captures

The sites that still use write_text()/write_bytes() — wiki/index.py, so index.md — were never affected: those create at the process umask, i.e. 0644. That is the tell. It is precisely the sites that were converted to mkstemp (for streaming, or for atomicity) that regressed, one at a time, each conversion silently importing the mode along with the crash-safety. POST /ingest/convert-marker was the most recent such conversion, in 2.1.12, and it is the only one that noticed:

# mkstemp() creates 0600; the vault is shared with Obsidian/LiveSync and was
# previously written by write_bytes() at the process umask, so restore 0644
# rather than handing the vault a file only this UID can read.
os.chmod(tmp_name, 0o644)

The comment is right, and it is the diagnosis for all seven sites. It was applied to one.

Root cause: a file's mode treated as a property of the destination path when it is a property of the inode being renamed into place.

The chmod and the rename are now a single operation in a single place — app/vault_io.py, with VAULT_FILE_MODE as the one named constant — and every site goes through it, including the marker site, which no longer carries its own copy:

def publish_tmp_file(tmp_name: str | Path, dst: Path) -> None:
    os.chmod(tmp_name, VAULT_FILE_MODE)
    os.replace(tmp_name, dst)

The chmod deliberately happens before the rename, so dst is never momentarily owner-only: the rename stays the single atomic step that publishes the bytes and the mode together.

The same helper fixes a second, narrower bug it inherited from the open-coded idiom. Each site closed the descriptor in the happy path and closed it again in its error path:

os.close(tmp_fd)
Path(tmp_name).replace(abs_path)     # if THIS raises…
except Exception:
    os.close(tmp_fd)                 # …the fd is already closed

A failure in the rename is the one failure that happens after the close, so the handler called os.close on a descriptor number the process may already have reissued to an unrelated file or socket — closing someone else's file. atomic_write_bytes tracks the close and does it exactly once.

backend/tests/test_vault_file_mode.py pins the invariant at all seven sites and at both primitives. All 11 cases fail without the fix, reading 0o600 where 0o644 is asserted, and so does the pre-existing convert-marker mode assertion — confirming the refactor kept its coverage.

Migrating a lint stub could overwrite the real page it was standing in for

ops/migrate_lint_query_stubs moves the legacy type=query stubs Wiki Lint used to write for missing wikilink targets out of wiki/queries/ and into entities/ or concepts/. The move had no destination-exists check.

The destination path is built from the stub's own stem, so it collides with a genuine page whenever the vault already holds the page the stub was a placeholder for. That is not an adversarial case — it is the ordinary end state:

  1. a page links [[Google Cloud]], which does not exist yet;
  2. Lint writes the placeholder wiki/queries/google-cloud.md, title: Google Cloud;
  3. later, ingest authors the real wiki/entities/google-cloud.md with the same title;
  4. wikilinks resolve by title, so the link quietly starts resolving to the real page — and nothing removes the stub, which keeps sitting in queries/ under the same slug.

Applying the migration then renamed the stub over the real page: the real body was destroyed, and two live rows ended up pointing at one file, the surviving row carrying a content_hash that no longer matched its own bytes.

The sibling module states the rule in its header and enforces it:

ops/reconcile_folders.py:
  - Collision safety: if the destination path already exists, skip + log (never overwrite).

migrate_lint_query_stubs's own header says it mirrors that architecture. It was the one of the two without the guard.

Root cause: a path derived from a slug treated as necessarily free.

The move now raises FileExistsError before any mutation, leaving both files intact so an operator can resolve the collision by hand. _run_inner's existing per-page handler catches it, counts the page as failed and continues — so one collision no longer costs the rest of the batch either.

Latent today. Nothing in the running app reaches this module: no router, no scheduler. It is a hand-run remediation tool (ADR-0067 D1 — 133 stubs on prod), so the defect has never fired. It is fixed here rather than filed in the 3.0 backlog because the day someone runs it is the day it would matter, and the guard is six lines.

Security

dompurify 3.4.13 → 3.4.16

GHSA-p98j-92pf-mc4p (DOM XSS). This is the only advisory that reaches the production bundle: npm audit --omit=dev goes from 1 to 0. dompurify is a direct dependency and the sanitizer that every rendered markdown string passes through (renderMarkdown.ts) — LLM chat output, wiki page bodies, and web-fetched research text.

A patch bump inside the existing ^3.4.13 range, so package.json is untouched and only package-lock.json moves.

Scoped honestly: the advisory needs IN_PLACE: true together with a node-removing afterSanitize hook, and renderMarkdown.ts uses neither — there is no DOMPurify.addHook anywhere in the codebase — so it is not reachable through Synapse's own usage. It is taken anyway because this is the one library whose entire job is to be the XSS boundary for model-generated and web-fetched HTML, and the bump costs nothing.

The 6 remaining advisories are dev/build-chain only and each requires a MAJOR bump of vite or vitest, which stays out of scope for a patch release. They are already recorded in docs/reference/ROADMAP-3.0-IDEAS.md.

Also in this release

Three new entries in the 3.0 backlog (docs/reference/ROADMAP-3.0-IDEAS.md):

  • No automatic check that a file published into the vault is readable by the vault's other readers. This release routes all seven write sites through one helper, but nothing prevents the eighth: the next hand-written mkstemp + os.replace reintroduces the same defect, silently, and 2.1.12 shows that this is what actually happens. The current defence is a test that enumerates the known sites, which grows only if whoever adds a site remembers to. Closing it means choosing between a static gate and a runtime check in check_obsidian — a decision, not a patch.
  • GraphCache loses the follow-up recompute when a bump lands during a background revalidate. _kick_background_revalidate() sets _in_flight synchronously and then create_tasks, so the task body's _pending = False — whose comment holds for tick(), which has no await in between — can erase a notify_bump() that arrived in that window. Low impact: it self-heals on the next GET /graph miss. The small diff comes with a hard test to write, because the state machine has three entry points and no existing test can schedule inside that window.
  • The update check has no negative cache: a GitHub 403 makes it retry on every request. _fetch_latest_version() promises an ~1h cache "to respect the unauthenticated rate limit", but only the success path writes the cache, so a rate-limit response is exactly when the cache stops existing. Fixing it needs two TTLs and a decision about what the UI shows while a negative cache is warm — a behaviour change on an existing flow.

The long-standing pypdf entry is unchanged and still growing: pip-audit now reports 49 distinct advisories against the pinned 5.9.0 — up from 41 at 2.1.15 — every one fixed only in the 6.x line, behind a <6 constraint. Still a major bump, still out of scope for a patch.

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. Nothing new to set in .env; no existing key changes meaning.
  • Existing vault files keep the permissions they already have. This release fixes the writes; it does not sweep the vault. Pages written or edited before upgrading are still 0600, and they will be corrected individually the next time each one is written — which for a page nobody edits is never. If your wiki/ is shared with Obsidian/LiveSync (the standard setup), fix the existing files once, from the host, as the user that owns the mount:
find /path/to/vault -type f -perm /u+r ! -perm -g+r -exec chmod 644 {} +

Then confirm Obsidian can open a page it previously could not. If your vault is accessed only by the Synapse container, nothing was ever broken for you and there is nothing to do. - If the Obsidian side has been failing to read recently-edited pages, this was the cause. No page content was ever lost or corrupted by this defect — only the permission bits changed; the bytes on disk were always written correctly. - No client-side state to clear. Existing OAuth grants, refresh tokens, API tokens, conversations and vault content are untouched.