ReviewOS

craft-native/craft

saveFile resolves with a path it may never have written

#175
Closed glennmichael123 opened this 11 days ago ·
11 days ago

CraftBridge.saveFile writes only when the data URL has exactly one comma, and resolves unconditionally:

if (data.startsWith("data:")) {
    val parts = data.split(",")
    if (parts.size == 2) {
        val bytes = Base64.decode(parts[1], Base64.DEFAULT)
        file.writeBytes(bytes)
    }
    // no else
} else {
    file.writeText(data)
}

activity.runOnUiThread {
    webView.evaluateJavascript(
        "window._craftSaveResolve && window._craftSaveResolve(${jsQuote(file.absolutePath)})", null)
}

String.split(",") splits on every comma, so parts.size == 2 means "exactly one comma in the whole string". Anything else falls through the if with no else, nothing is written, and the page is handed file.absolutePath as though it had been.

What reaches it

  • data:text/plain,a,b — a legal data URL whose payload contains a comma. Three parts, no write.
  • data:image/png;base64 — a caller who built the URL wrong. One part, no write.
  • data: — one part, no write.

Base64 output has no commas, so the common case is safe. It is the plain-text data URLs and the malformed ones that fail silently.

Why it costs something

The reply is a path. A page that saves a file and then tells the user where it is, or hands the path to openPDF, or reads it back, gets a path to a file that does not exist — and the failure surfaces later, somewhere else, as a missing file rather than as a failed save.

_craftSaveReject is already assigned by the injected JS, so there is somewhere honest to send this.

Suggested fix

Give the if an else that rejects:

if (parts.size == 2) {
    file.writeBytes(Base64.decode(parts[1], Base64.DEFAULT))
} else {
    activity.runOnUiThread {
        webView.evaluateJavascript(
            "window._craftSaveReject && window._craftSaveReject(${jsQuote("Malformed data URL")})", null)
    }
    return
}

Splitting with a limit — data.split(",", limit = 2) — would instead make data:text/plain,a,b write a,b as base64, which throws and rejects. Either is honest; the first says what happened.

Zig ports it as-is

bridge_android_files.zig reproduces the branch, with the skipped case named Plan.nothing rather than left as an absent else, and a test that says what it does. It is the same behaviour in both languages until this is decided.

Related: #127, #148, #154, #157, #159 — replies that are sent but do not mean what they appear to.

11 days ago

Implemented and pushed in 273bfa0 (fix(android): reject malformed save data URLs). Android saveFile now rejects malformed data URLs before resolving or writing any path; the Zig planning/dispatch path mirrors the same behavior, and regression coverage was added. Verified locally with Android generator tests/build, zig build test:api, the full zig build test suite, Zig formatting, pickier, and git diff checks. GitHub Actions are green for CI, SBOM, Binary Size, and Benchmark on the exact commit.

Sign in to comment on this issue.