issue-204: Implement penpot_export_library (SSE URI plus artifact download) #237
No reviewers
Labels
No labels
bug
chore
documentation
enhancement
feature
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
david/pi-extensions-and-skills!237
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/issue-204/implement-penpot-export-library-sse-uri-plus-artif"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
204: Implement penpot_export_library (SSE URI plus artifact download)
Closes #204 — 204.
Generated in 11m 33s.
Review findings
Now I'll make the code changes. Starting with
exportLibrary.ts:Now update the tests:All findings are addressed and the suite is green apart from the documented pre-existing failure. Final annotated findings:Review Findings — 204
Summary
All nine findings (1 High, 3 Medium, 5 Low) were remediated in a single pass. Added: a Transit-error-body test for the export RPC (
exportLibrary.test.ts); astat().isFile()guard inprepareExportTargetso a directory/non-regularout_pathis refused before any request, plus awriteArtifacthelper wrappingmkdir/writeFileso filesystem failures surface as the tool's categorised error; explicitredirect: "manual"following that re-requests thelocationwithout theAuthorizationheader (comment,findings.md, extension README and CHANGELOG updated to match);penpot_export_libraryadded to both root README rows; a UUIDpatternonfile_id(reusingUUID_PATTERN, now exported fromimportLibrary.ts) plus filename sanitisation indefaultExportPath; a 100 MiB streamed body cap withcontent-lengthpre-check; the final download URL surfaced indetails/the summary (unusedcontentTypedropped); a >MAX_LISTED_ENTRIEStruncation test; and tests for the download transport failure and thebody === nullexport response. Tests:extensions/penpot/src/tools/exportLibrary.test.tsnow 35/35 passing; fullnpm testis 609/610, the sole failure being the pre-existing, unrelatedscripts/local-install.test.mjs:1875(mongodb missing frompi.extensions), reproduced atHEAD~1by the reviewer. The untestable defensivestreamed === undefinedbranch was left as-is (unreachable throughpenpotStreamRequest, mirrors the sibling tool).Critical
High
mapExportFailure's Transit branch (isTransitBody(...) ? decodeTransitError(...) : decoded.message) has no test at all; the suite only exercises the 403 path and the download 401. Penpot answers RPC failures withapplication/transit+jsonbodies (recorded infindings.md), and the sibling tool covers exactly this case (importLibrary.test.ts:668, "import_library decodes a Transit error body into a readable message"), so a documented behaviour is unverified here. Suggested fix: add a test that stubs theexporthandler with a 400application/transit+jsonbody (e.g.{"~:type":"~:validation","~:message":"the artifact is not a valid penpot file"}) and asserts the decoded message is surfaced and the token is not echoed.Medium
mkdir/writeFileare awaited with notry/catch, so a filesystem failure (EACCES, ENOSPC, read-only checkout) escapes as a raw Node error instead of the tool's categorised, actionable message; withoverwrite: truethe guard at :252 also skips thestat, so anout_pathnaming a directory (or any non-regular file) passes and fails atwriteFilewithEISDIR.prepareImportArtifact(importLibrary.ts:186-215) wraps every fs error and checksisFile(), so this deviates from the sibling tool. Suggested fix: inprepareExportTargetreject a target that exists and is not a regular file (stat().isFile()), and wrap themkdir/writeFilepair intry/catch→failTool(...); consider writing to<path>.tmpthenrenameso an interrupted write cannot leave a truncated artifact that the overwrite guard then refuses.redirect: "follow"relies on the runtime strippingAuthorizationon redirect. That holds cross-origin (verified against Node 22/undici here), but on a same-origin redirect the header is forwarded, so a same-origin pre-signed object store receives both theAuthorizationheader and the presigned query and rejects it (S3/MinIO: "Only one auth mechanism allowed"), and the claim in the comment,findings.mdand the CHANGELOG that "the token is not leaked to the object store" is only true cross-origin. Suggested fix: follow redirects explicitly (redirect: "manual") and re-issue thelocationrequest without theAuthorizationheader, or at minimum qualify the comment and the docs to the cross-origin case.penpot_import_library,penpot_add_image, etc., and the previous issue added its tool to the same rows), but neither this row nor the layout row at README.md:144 mentionspenpot_export_library, while the extension README, CHANGELOG andfindings.mdwere all updated. Suggested fix: addpenpot_export_library(export a.penpotartifact to disk) to both README.md:38 and README.md:144.Low
defaultExportPathinterpolates the caller-suppliedfile_idstraight into a filesystem path with no shape validation, so an id such as../../foowould resolve outsideskills/penpot/assets. Impact is minimal (the RPC rejects a non-UUID id before any write, andout_pathis explicitly caller-chosen), but it is one schema keyword away from being closed. Suggested fix: add a UUIDpatternto thefile_idschema (reuse theUUID_PATTERNfromimportLibrary.ts:93) and/or sanitise the id before using it as a file name.await response.arrayBuffer()with no size cap, unlikepenpot_import_library's 30 MiB local guard (importLibrary.ts:70) — and the design doc itself cites a 17 MB library artifact. Suggested fix: stream the body to the target file (or a temp file) and verify the magic bytes from the first chunk, or add a configurable byte cap.DownloadedAsset.contentTypeandDownloadedAsset.urlare populated at :384-385 but never consumed by the tool or asserted by any test; the post-redirecturlis the useful diagnostic for exactly the redirect behaviour above. Suggested fix: surfaceurlindetails/the summary text (or drop both fields).truncated === true,MAX_LISTED_ENTRIES) is never exercised; the only assertions aretruncated === falseandentries.length <= MAX_LISTED_ENTRIESon a 5-entry archive. Suggested fix: add asummarizeArchivetest with >500 entries assertingtruncated === trueandentries.length === MAX_LISTED_ENTRIES.downloadTransportError, including the timeout/abort wording) and the defensivestreamed === undefined(:747) andstreamed.body === null(:757) branches are uncovered. Suggested fix: stub thedownloadhandler to reject (or return a response whose body never ends with a short timeout) and assert the categorised transport message, plus one test for anullbody on a 2xx export response.Documentation
Updated files: