Skip to content

[Audit Finding] Flush managed PDF writes with the platform durability barrier (macOS F_FULLFSYNC) #134

Description

@Fooftilly

Status: Fixed by #138.

Finding ID: EF-031
Last verified: master@664cb8ebecdd953de18c1b014e55172ffe1f4049
Source: weekday repository-wide default-branch audit, 2026-09-22

Security: This is a managed-PDF durability / crash-consistency finding, not a suspected vulnerability or sensitive security disclosure.

Severity

P2

Affected files / functions

  • backend/services/work_pdf_replace.py — atomic_replace_managed_pdf_bytes (content sync before os.replace), store_new_managed_pdf_bytes (content sync after exclusive create)
  • Contrast (already fixed): backend/pdf_linearize.py — uses fsync_file_path / fsync_directory from backend/fs_durability.py
  • Contract owner: backend/fs_durability.py — _sync_descriptor documents that plain os.fsync() is not a durability barrier on macOS

Observed problem

After #129 / EF-018, managed-PDF linearization flushes through fsync_file_path (macOS F_FULLFSYNC when available). The primary write paths that publish canonical managed PDF bytes were only partially upgraded:

  • Directory sync was moved to fsync_directory via fsync_managed_pdf_parent.
  • Content sync still calls os.fsync(fp.fileno()) in both atomic_replace_managed_pdf_bytes and store_new_managed_pdf_bytes.

So the bytes that become the durable Work PDF can be published under the weak macOS barrier, while the optional linearize step (when it runs and succeeds) is the only path that applies the stronger one.

Why this is real (not theoretical)

  1. backend/fs_durability.py states explicitly that os.fsync() on macOS returns after handing data to the drive cache without waiting for stable media; _sync_descriptor prefers F_FULLFSYNC for that reason.
  2. Tip 664cb8e still has two os.fsync(fp.fileno()) sites in work_pdf_replace.py and only imports fsync_directory from fs_durability — not fsync_file_path / an open-fd sync helper.
  3. Annotation materialization and uploads return success after these write helpers complete. Linearize is optional (PRKS_PDF_LINEARIZE=0, missing qpdf, or sync-failed): when it does not rewrite, the weak first write is the only content barrier. When it later succeeds, there is still a crash window after the weak publish and before linearize's durable replace.
  4. [Audit Finding] Make PDF linearization preserve managed-file durability guarantees #112 / fix: make PDF linearization preserve managed-file durability #129 fixed linearize only. [Audit Finding] Make restore journal and component renames power-loss durable #110 / PR fix: make the restore journal and component renames power-loss durable #133 cover restore journal and component renames, not managed PDF replace/upload content sync. No open issue covers this gap.

Realistic repro / failure path

  1. On macOS (supported local Python runtime), with linearization disabled or unavailable so it does not re-write the file.
  2. Save durable PDF annotations (materialize → atomic_replace_managed_pdf_bytes) or upload a new PDF (store_new_managed_pdf_bytes).
  3. Observe HTTP/API success while content was only os.fsync'd.
  4. Power loss shortly after the write returns.
  5. Observed risk: the managed basename can point at bytes that never reached stable storage, so reopen shows a truncated/corrupt/previous PDF despite a successful save/upload.

(Hard to stage a real drive-cache power loss in CI; regression should spy the sync primitive the same way tests/test_pdf_linearize_durability.py forces the _FULLFSYNC path.)

Expected behavior

Any path that publishes canonical managed PDF bytes must flush file contents with the same strongest platform barrier fs_durability already defines for linearize — before os.replace / before treating an exclusive create as durable — and must fail closed if that content sync raises. Optional linearize must not be the content durability barrier.

Fix direction

  • Sync open write fds through _sync_descriptor (export a small fsync_open_file / fsync_open_fd if needed; PR fix: make the restore journal and component renames power-loss durable #133 already generalizes this for restore) instead of bare os.fsync.
  • Keep raising on content-sync failure before replace/publish.
  • Optionally surface failed parent-dir sync like linearize’s ok-unsynced-dir rather than ignoring fsync_managed_pdf_parent’s bool.
  • Do not rely on maybe_linearize_pdf_in_place as the primary content barrier.

Suggested regression tests

  • With _FULLFSYNC forced (same pattern as tests/test_pdf_linearize_durability.py), assert atomic_replace_managed_pdf_bytes and store_new_managed_pdf_bytes invoke F_FULLFSYNC (not only plain os.fsync) before the file is treated as canonical.
  • Assert replace / successful store is abandoned when content sync raises.
  • Keep a non-macOS path asserting plain os.fsync still runs where F_FULLFSYNC is absent.

Evidence links


Reviewed and commented using Grok Bot.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions