fix(desktop): stop clipboard image uploads clobbering each other and mangling paths by Jocs · Pull Request #5512 · marktext/marktext · GitHub
Skip to content

fix(desktop): stop clipboard image uploads clobbering each other and mangling paths - #5512

Merged
Jocs merged 4 commits into
developfrom
fix/3360-picgo-upload-temp-path
Sep 22, 2026
Merged

Jocs merged 4 commits into
developfrom
fix/3360-picgo-upload-temp-path

Conversation

@Jocs

@Jocs Jocs commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

What

Two defects in the image-upload IPC handler (packages/desktop/src/main/ipc/uploader.ts), found while investigating #3360 / #2915 — neither of them is the bug those issues report.

1. The image path was interpolated into a shell command (the real one)

uploadByPicgo ran exec(`${cmd} u "${localPath}"`), so a shell re-parsed the path: double quotes were consumed as quoting syntax, $name was expanded, and `x` was command substitution. A file named a "quoted" $name `x`.png — all legal characters on macOS/Linux — reached picgo as a quoted .png, which does not exist, so the upload always failed. A name containing $(…) or backticks would have executed.

The path now goes through as its own argv entry via execFile, which spawns no shell: each array element becomes one argv entry byte-for-byte. uploadByCli in the same file already did this; picgo is now consistent with it.

Reachable by: dragging a local image whose filename contains $, " or a backtick into a document with an uploader configured.

2. Concurrent uploads shared one temp file (hardening, not a reachable bug today)

writeBinaryToTmp named the file path.join(tmpdir(), Date.now() + ext), so two uploads starting in the same millisecond collide: one sends the other's bytes, and whichever finishes first unlinks the file the second still needs.

This is not reachable through the UI today — muya has exactly three imageAction call sites (paste, paste-over-selection, drag-drop), all single-image and all awaited, and a multi-file drop takes only files.find(...), the first image. It becomes a data-corruption bug the moment multi-image paste or drop is added. Each upload now gets its own mkdtemp directory, removed on cleanup; the basename stays timestamped because uploaders derive the remote filename from it.

Windows is deliberately untouched

The Windows branch keeps the original command string byte-for-byte. resolvePicgoBinary only ever returns the bare names picgo / picgo.exe there, so quoting ${cmd} would buy nothing, and this is the one platform the new spec does not cover.

What that branch does and does not protect against is now recorded in the comment: " is illegal in a Windows filename, so the injection fixed above is not reachable there, but %VAR% still expands inside double quotes, so a name like %TEMP%.png is still mangled. Fixing that needs real .exe-vs-.cmd resolution and a Windows machine to verify on — out of scope here.

Verification

Against the real picgo binary — old form vs new form, same filename:

OLD  exec(`picgo u "${path}"`)
  [PicGo WARN]: /tmp/mt-ab/a quoted  .png does not exist.

NEW  execFile(picgo, ['u', path])
  [PicGo WARN]: /tmp/mt-ab/a "quoted" $name `x`.png does not exist.

packages/desktop/test/unit/specs/uploader-temp-file.spec.ts is new — the uploader handler had no tests at all. It drives the real registered IPC handler against a stand-in uploader executable placed on PATH that records the path it was handed, and covers: the temp file keeps the image extension, the real bytes are written, the temp file is removed afterwards, two same-millisecond uploads get distinct paths, and a path with shell metacharacters arrives unmangled. The last two fail on develop.

Platform Coverage
macOS local: unit spec + real-picgo A/B
Linux CI test (ubuntu-latest): ✓ uploader-temp-file.spec.ts (5 tests)
Windows none — the spec's stand-in uploader is a /bin/sh script and Windows resolves executables through PATHEXT, so the file is skipped there. The branch it would exercise is also the one left unchanged.

Note on #3360 / #2915

The root cause those issues identified — path.join(os.tmpdir(), +new Date()) being handed a number, and the temp file having no extension — was already fixed in v0.19.0 (#3269 9309888b, and 624e69cb which moved upload into the main process and appended path.extname(name)). #3360 has since been verified against a real build and closed; #2915 was closed as its duplicate. This PR fixes two further defects on the same path and closes neither.

🤖 Generated with Claude Code

Jocs and others added 3 commits September 22, 2026 20:55
Two images pasted within the same millisecond were written to the same
`path.join(tmpdir(), Date.now() + ext)`, so one upload sent the other's
bytes and whichever finished first unlinked the file the second still
needed. Write into a per-upload `mkdtemp` directory and remove that
directory on cleanup; the basename stays timestamped because uploaders
derive the remote filename from it.

Adds the first tests for the uploader IPC handler, driving the real
handler against a stand-in uploader executable.

Refs #3360, #2915

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`exec()` interpolated the image path into a command string, so a shell
re-interpreted it: a file named `a "quoted" $name ` + '`x`' + `.png`
reached picgo as `a quoted  .png`, which does not exist, so the upload
always failed — and a name containing `$(…)` or backticks would have run
as a command.

Verified against the real picgo binary: the old form makes picgo report
`/tmp/.../a quoted  .png does not exist.`, the new one reports the
filename verbatim.

Windows keeps the quoted shell form, because `picgo` there is a .cmd
shim that only a shell can start and a Windows filename cannot contain
a double quote.

Refs #3360, #2915

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stand-in uploader the spec puts on PATH is a /bin/sh script, and
Windows resolves executables through PATHEXT, so it would never be
found there. The behaviour under test is also the non-Windows branch of
uploadByPicgo. Desktop unit tests run on windows-latest in CI, so guard
the file with describe.skipIf, matching write-file-atomic.spec.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Build artifacts for PR #5512:

Run: https://github.com/marktext/marktext/actions/runs/35749399204

Artifact Size Link
marktext-windows-arm64 280.1 MB Download
marktext-macos-x64 298.5 MB Download
marktext-linux 640.4 MB Download
marktext-windows-x64 288.2 MB Download
marktext-macos-arm64 288.2 MB Download

The previous commit also quoted `${cmd}` in the Windows branch. That buys
nothing — resolvePicgoBinary only ever returns the bare names `picgo` or
`picgo.exe` on win32, never a path with spaces — while changing
behaviour on the one platform this PR cannot test: the new spec is
skipped there, so `test (windows-latest)` does not cover this line.

Restore the original command string and record in the comment what the
Windows branch does and does not protect against: `"` is illegal in a
Windows filename so the injection fixed on POSIX is not reachable, but
`%VAR%` still expands inside double quotes, so a name like `%TEMP%.png`
is still mangled. Fixing that needs .exe-vs-.cmd resolution and a
Windows machine to verify on, so it is out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit a21d2e8 into develop Sep 22, 2026
11 checks passed
@Jocs
Jocs deleted the fix/3360-picgo-upload-temp-path branch September 22, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant