Skip to content

id(web): image upload stores by filename with no collision handling — a second paste named 'image.png' silently un-names the first #1549

Closed

Description

@dezren39

Problem

POST /api/upload stores every image under its multipart filename as a blob tag, with no collision handling — so a second upload that arrives under the same name silently re-points that name to the new blob, and the first image disappears from every name-based surface. Uploaded names collide in ordinary use, because they are not unique:

  • Paste/drop File names are caller/browser-assigned, not unique per image (a clipboard bitmap is image.png). The web upload path sends the File as-is (uploadImageFile, web/src/image-upload.ts:76: formData.append("file", file) — no filename argument), so the multipart filename the server reads is exactly file.name.
  • The server's own fallback for a nameless upload is paste-{unix_seconds}.{ext} (src/web/routes.rs:1491-1499) — one-second granularity, so two nameless uploads in the same second also collide.
  • The client's dedicated pasted-image name, generatePasteFilename (web/src/image-upload.ts:57-60, `paste-${Date.now()}.${ext}`), is never sent to the server at all — it is only the alt-text fallback at image-upload.ts:217. It has the same shape of problem anyway: millisecond-only entropy.

Evidence (verified this run, main a120b9a9)

Laptop Bun 1.4.2 probe (real image-upload.ts, sha256 d0ab2a43 identical both sides; probe + log in the goal's hidden_files/probe-2026-10-27-laneA-uploadname/):

  • P1 fixed-clock: call1=paste-1730000000000.png call2=paste-1730000000000.png identical=true
  • P1 tight-loop: 200 calls -> 1 distinct name(s)
  • P2 paste#1: bytes=3 -> server sees filename="image.png" and P2 paste#2: bytes=4 -> server sees filename="image.png" — two different images present the same filename on the wire, which is the name upload_handler keys on.

Server chain, by code read (src/web/routes.rs):

  • :1491-1499 — filename = field.file_name() when non-empty (reconciled with the content type at :621), else paste-{secs}.{ext}.
  • :1553 — state.store.tags().set(tag, hash) with Tag::from(filename): a bare overwrite of any existing tag of that name. No existence check, no uniquifying, no archiving.
  • :1562/:1575/:1588 — the created (set_if_absent), modified and content-type metadata tags are also keyed by that same filename, so the second upload's metadata merges into the first image's name record.
  • images_list_handler (:1613+) lists images by tag, and get_file_name (:1015+) resolves a hash's name by tag — once the tag is re-pointed, the first image is gone from the image browser/list and from name lookup, even though its blob and any /blob/{hash} embeds keep working (which is why this stays silent).

Contrast with the house pattern: FileOps::rename/copy archive whatever they replace (src/fileops.rs:15-16, archive_existing at :153 — <name>.archive.<unix-ts>). Upload is the outlier: it is the one name-writing path that destroys the previous occupant without a trace. A document save overwriting its own name is intended semantics; an image upload is the creation of a new object, so the replace semantics do not transfer.

Why it matters

Pasting two screenshots into documents is a core editor flow. Today the second paste quietly un-names the first image: it vanishes from the image browser, name-based lookups return the newer image, and the shared metadata record mixes both uploads. Nothing errors anywhere, so the loss is only noticed when someone goes looking for the earlier image by name.

Acceptance criteria

  • Two uploads that arrive under the same filename both remain listed by images_list_handler and resolvable by name afterwards — e.g. the later upload is uniquified (suffix/counter/hash-derived) or the replaced name is archived first, per the fileops pattern; triage picks and pins one behaviour.
  • The nameless-upload fallback name cannot repeat for two uploads in the same second (and, if the client paste name is ever sent, generatePasteFilename gets the same guarantee).
  • Regression coverage in the existing upload router tests (upload_router_tests, src/web/routes.rs:2333+): two same-name uploads with different bytes → both images still listed/resolvable, first upload's name record not silently merged with the second's.
  • No change to document save semantics (a save still updates its own name's tag) and no change to the /blob/{hash} URL shape.

Notes

Activity

  1. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Oct 28, 2026
  3. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane A, slug new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a, run 2026-10-27 20:06 CDT
    • Found via: filed this run
    • Task: Triage the upload naming collision: pin uniquify-vs-archive for same-name uploads, then implement + regression-test in the upload router tests.
    • Expectation: effort/s — done looks like two same-name uploads both remaining listed/name-resolvable, with the pinned collision behaviour covered by a router test.
    • Links: queue packet packet-1549.json (repo-health-code-pilot, triage)
  4. added
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    and removed on Oct 28, 2026
  5. 46 remaining items

  6. added
    status/parkedDeliberately paused with a recorded resume condition
    on Oct 28, 2026
  7. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    Parked: fix implemented and PR opened — #1696 (branch queue/1824-1549-upload-uniquify, commit 4d423fb). Stored-name uniquifier per the triage ruling: intended name kept when free or already naming the same blob (idempotent re-upload); stem-N.ext with smallest free N when a different blob owns the name; the paste-{secs} fallback passes through the same uniquifier. Laptop tests: cargo test --features web --lib web::routes::upload_router_tests — 12 passed, 0 failed, including 2 new #1549 regression tests. CI at park time: 13 pass / 18 pending / 0 fail. Resume condition: PR #1696 checks settle — merge if green per standing criteria, then complete queue task 1824.

  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Oct 28, 2026
  9. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  10. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

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

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions