Repository navigation
fix: parse Content-Disposition instead of slicing it at the first '' - #196
Conversation
`parseFileData` derived `filename` with
disposition.substring(disposition.indexOf("''") + 2)
which is wrong for every header shape a Backlog attachment arrives with.
`indexOf` returns -1 when there is no RFC 5987 form — the common case, since a
plain `filename="…"` contains no `''` — so `substring(1)` returned the whole
header minus its first character. When the extended form was present the value
came back still percent-encoded, which is every attachment with a non-ASCII
name.
Parse the header instead, following RFC 6266: `filename*` takes precedence over
`filename`, its `<charset>'<language>'` prefix is dropped and the rest decoded;
a quoted `filename` is matched before the unquoted one, because a quoted value
may contain the `;` the unquoted form ends at. An absent header still yields
an empty string.
The one existing test used `filename*=UTF-8''test.png` — extended notation with
nothing to decode, the single input the old code happened to get right. The
added cases cover the shapes it did not; seven of the eight fail without this
change.
Fixes #195
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntain Self-review of the parser found one shape it still got wrong: a server that quotes the RFC 5987 value — `filename*="UTF-8''%E5%9B%B3.png"` — left the closing quote on the name. RFC 5987 does not permit a quoted ext-value, but it is sent, and one stray character in a filename is exactly the class of thing this change exists to remove. The other note is about what the fix now lets through. Decoding is the point of it, and `%2E%2E%2F` decoding to `../` means a caller that writes `filename` straight to a path is exposed to something the old mangling hid. Say so on the function rather than leave it for a caller to discover. Also covers the shapes probed while reviewing: whitespace inside and outside quotes, an escaped quote, and no space after the semicolon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Self-reviewed the parser by extracting it from A shape it still got wrong. Something the fix lets through that the bug hid. Decoding is the point of this change, and it means Shapes probed that behave correctly and are now covered by tests where useful: a similarly named parameter ( 54 tests pass; |
Every case here was written by hand from what RFC 6266 permits. None of them came from a Backlog response, and that turned out to matter: measured against a live space, Backlog always uses the extended notation — even for a pure ASCII name — and percent-encodes anything that needs it. So the shape this client meets is only ever `filename*=UTF-8''…`, and the `indexOf` returning `-1` half of the bug is not reachable through Backlog. The decoding half is, and it is worse than "non-ASCII names": a space is `%20` and a `;` is `%3B`, so `a b.csv` and `q1;summary.csv` were mangled too. Four of the five measured headers fail against the old implementation. Added as their own group, with the general RFC cases kept and labelled — they cost nothing and stop the client from depending on one server's habits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Correcting the framing in this PR and in #195, after measuring against a live Backlog space instead of reasoning from the spec. Every header in the original table was one I wrote by hand. None came from a Backlog response, and the difference matters. What Backlog actually sendsOne attachment each, headers read straight off the response:
Backlog always uses the extended notation — even for a pure ASCII name — and percent-encodes everything that needs it. What that changesThe The decoding half is live, and worse than I described it. I framed it as "every attachment with a non-ASCII name". A space is The severity is unchanged; the reason for it is narrower and more common than stated. What changed in the PR
The RFC-general cases are kept, in a second group labelled as shapes Backlog does not currently send — parsing them costs nothing and keeps the client from depending on one server's habits. The fix itself is unchanged and needs no revision — it is RFC 6266 parsing, so it handles what Backlog sends and what it does not. 59 tests, lint, format, |
mmktomato
left a comment
There was a problem hiding this comment.
@katayama8000
I added a comment 🙏
| * | ||
| * Returns an empty string when the header is absent or carries no filename. | ||
| * | ||
| * The result is whatever the server put in the header, now actually decoded — |
There was a problem hiding this comment.
This paragraph won't make sense after this task is over.
Also, the comments are long overall, so please take another look 🙏
There was a problem hiding this comment.
Both fair — fixed in 0514ecf.
You are right that the paragraph does not survive the merge. It said "before this parsed the header, %2E%2E%2F came back encoded" — which describes the change, not the code, and once merged there is no before for a reader to compare against. Removed; the point lives in the PR description where it belongs. What is left of it is one clause on the value being the server's, which is about the value rather than about this task.
On length: 24 comment lines down to 9, and the same trim in the tests. I kept only the notes that stop the code being "simplified" back into a bug —
- quoted matched before unquoted, because a quoted value may contain the
;that terminates an unquoted one - the quoted ext-value servers send against RFC 5987
— plus the RFC 6266 precedence rule, which is what decides the branch order. Everything else was restating the regexes.
59 tests, lint, format and tsc still pass.
The paragraph about `%2E%2E%2F` described this change rather than the code: "before this parsed the header" means nothing to someone reading the file afterwards, when there is no before. It belongs in the PR, and it is there. The rest was explaining what the regexes already say. Kept the two notes that stop the code being "simplified" into a bug — quoted matched before unquoted, because a quoted value may contain the `;` that terminates an unquoted one, and the quoted ext-value servers send against RFC 5987 — plus the RFC 6266 precedence rule that decides the branch order, and one clause on sanitising, which is about the value rather than about the change. 24 comment lines to 9, and the same trim in the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@mmktomato |
|
Should have said in the description why this is hand-written rather than I checked it properly: v3.0.0 has no dependencies, uses no Node built-ins, and parses all five measured Backlog headers correctly — including the decoding, which it exposes as Reasons I still left it out, for you to overrule:
None of these is decisive and the trade is yours, not mine — happy to switch if you would rather have the dependency than the code. |
Is this something for me to decide, or was that you AI agent asking you? |
|
@mmktomato |
"before this it was read off the response and thrown away" is the same mistake already corrected in #196: it describes the change, and after the merge there is no before for a reader to compare against. Replaced with the fact the tests are actually pinning — Backlog sends a bare media type, no charset parameter, so the value compares directly. That is worth knowing whenever it is read, not just while reviewing this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.20.1 ships both halves of what this tool was working around: `filename` is decoded from `Content-Disposition` (nulab/backlog-js#196) and the response's `Content-Type` reaches the caller (nulab/backlog-js#197). So `MIME_TYPES` goes — 22 extensions mapped by hand, returning `application/octet-stream` for anything not listed, while the server's own answer was on the response and discarded. `getContentType` goes with it, and `normalizeFilename` loses the header parsing that made up most of it. What it keeps is the part that was never about the upstream bug: the name is still the server's string, so strip a directory part or a control character, and fall back to the id when nothing usable is left. Sniffing stays. Backlog derives `Content-Type` from the extension too — an attachment saved as `.jpg` whose bytes are a PNG is served as `image/jpeg` — so the bytes still beat what the server says. The bump also needs `childDocumentIds` on the three document tools and the three `document*` fields on `get_notifications`: 0.20 added them to the entities, and `outputFields` is exhaustive by design. Verified against a live space, not only against mocks: 図面.png inlines as image/png, a PNG named .jpg inlines as image/png, data.csv comes back as resource.text, and blob.bin as an octet-stream blob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #195.
The bug
parseFileDataderivedFileData.filenamewith onesubstring:Two problems in that line:
indexOfreturns-1when there is no RFC 5987 form.substring(-1 + 2)issubstring(1)— the whole header with its first character removed. That is the common case: a plainfilename="…"contains no''.%E5%9B%B3….png.Not one header shape produced a usable name. It has been this way since b191514 (2016-08-30), shipped in every release from 0.9.0 on. Affects every file-returning method:
getIssueAttachment,getWikiAttachment,getPullRequestAttachment,downloadDocumentAttachment,getSharedFile,getSpaceIcon,getUserIcon,getProjectIcon,getTeamIcon.The fix
A
parseContentDispositionFilenamehelper that follows RFC 6266:filename*takes precedence overfilenamewhen a header carries both. Its<charset>'<language>'prefix is dropped and the remainder decoded; malformed percent-encoding falls back to the raw value rather than throwing.filenameis matched before the unquoted one, because a quoted value may contain the;that terminates an unquoted one, and the quoted-pair escapes are unwound.filenameis taken as written — RFC 6266 plain values are not percent-encoded, so%30stays two characters."", unchanged.Content-Dispositionattachment; filename="report.png"ttachment; filename="report.png"report.pngattachment; filename=report.pngttachment; filename=report.pngreport.pngattachment; filename*=UTF-8''%E5%9B%B3%E9%9D%A2.png%E5%9B%B3%E9%9D%A2.png図面.pngattachment; filename="fallback.png"; filename*=UTF-8''%E5%9B%B3.png%E5%9B%B3.png図.pngattachment; filename="quarter;summary.pdf"ttachment; filename="quarterquarter;summary.pdfattachment; filename="20%30report.pdf"ttachment; filename="20%30report.pdf"20%30report.pdfattachmentttachment""""""Why one test did not catch it
The existing coverage was a single case:
Extended notation with nothing to decode — the one input the old code happened to get right. It still passes, and is left as it was. The added
it.eachcovers the shapes it did not: seven of the eight new cases fail without this change (the eighth, an absent header, is behaviour this preserves).Compatibility
This changes what
filenamereturns, which is the point of the fix — but only from unusable values to correct ones. A caller doing nothing withfilenameis unaffected; a caller that repairs it downstream (see nulab/backlog-mcp-server#187, whosenormalizeFilenameexists solely for this) keeps working and can eventually drop the workaround.FileData's shape is unchanged. No public API is added or removed.Verified
npx oxlint,oxfmt --check,tsc --noEmit,npm run buildall clean, andvitest run— 49 tests — passes on Node 22.23.2, 24 and 26.8.1, matching the CI matrix.🤖 Generated with Claude Code