Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to Quoll are documented here.

## 0.1.60 — 2026-07-29

### Fixed

- Pasting images into a single document is now capped at a generous total size per editing session, guarding against runaway disk use from a misbehaving source. The limit is far above any normal paste workflow; if it is ever reached, further image pastes are declined with a one-time notice and resume when you reopen the document.

## 0.1.59 — 2026-07-29

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"gfm",
"MEO"
],
"version": "0.1.59",
"version": "0.1.60",
"publisher": "mtskf",
"type": "module",
"engines": {
Expand Down
84 changes: 84 additions & 0 deletions src/extension/image/image-write-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Per-session cumulative volume cap for the image-WRITE path.
//
// The per-message caps in image-write-service (the MAX_IMAGE_DATA_LENGTH string
// bound + the 10 MB decode limit) bound a SINGLE write; they do nothing against
// a compromised webview — the exact adversary image-write-wiring gates against —
// that loops `image-write` with distinct PNG-magic-prefixed random payloads.
// Each such payload content-addresses to a NEW <docFolder>/assets/ file, so the
// per-message caps are individually satisfied while the disk fills silently.
//
// Other webview→host channels are already count-bounded PER MESSAGE (e.g.
// MAX_LINT_DIAGNOSTICS caps diagnostics on each inbound lint message), but none
// keeps a running session-lifetime total — this budget is the FIRST such bound
// in the codebase, added because the image-write path is uniquely able to turn a
// message loop into unbounded disk growth. It is a generous cumulative byte
// ceiling scoped to the editor session (one budget per panel). Once crossed it
// rejects further writes and warns the user ONCE (a per-write toast would itself
// be a notification-flood vector). Real paste flows — even bulk multi-image
// paste — never approach it.

/** Cumulative image bytes a single editor session may write to ./assets/.
* 512 MiB ≈ 50 full-size (10 MB) images — orders of magnitude beyond any real
* paste session, yet a hard stop against an unbounded write loop. A session is
* one open panel; reopening the document starts a fresh budget. */
export const SESSION_IMAGE_WRITE_BUDGET_BYTES = 512 * 1024 * 1024;

/** User-facing warning shown ONCE when the session budget is first exceeded. */
export const SESSION_IMAGE_WRITE_BUDGET_TOAST =
"Quoll: image paste limit reached for this document — reopen it to insert more images.";

export interface SessionVolumeBudget {
/** Charge `byteLength` against the session budget. Returns true (and records
* the spend) when the write may proceed; false once the cumulative cap would
* be exceeded — in which case `onExceeded` fires exactly once across the
* session's lifetime, no matter how many further writes are attempted.
* Reserve BEFORE the async write so concurrent (fire-and-forget) writes can't
* each independently pass the check and overshoot the cap.
*
* There is deliberately NO release/refund counterpart: a charge is never
* reversed, even when the subsequent write fails. This is the fail-SAFE
* choice for a disk-fill cap — `workspace.fs.writeFile` gives no atomicity
* guarantee, so a failed write can still leave a partial content-addressed
* file on disk; refunding it would let an attacker loop failing writes and
* grow the disk without bound while every reservation is returned. Counting
* the attempt keeps total disk growth bounded by the budget. The only cost is
* that a session hitting many genuine FS failures may reach the cap early
* (reopen the document to reset) — negligible for real use against a 512 MiB
* ceiling. */
reserve(byteLength: number): boolean;
}

// Guard the numeric domain the running total assumes (non-negative integer).
// The sole production caller passes a `Uint8Array.length`, always safe — but a
// NaN would poison `spent` permanently (every `NaN > cap` is false, silently
// disabling the cap: the exact failure this module exists to prevent), and a
// negative would rewind it. Fail loud on the contract violation instead.
function assertByteLength(byteLength: number): void {
if (!Number.isInteger(byteLength) || byteLength < 0) {
throw new RangeError(
`SessionVolumeBudget: byteLength must be a non-negative integer, got ${byteLength}`
);
}
}

export function createSessionVolumeBudget(
budgetBytes: number,
onExceeded: () => void
): SessionVolumeBudget {
let spent = 0;
let warned = false;
return {
reserve(byteLength) {
assertByteLength(byteLength);
if (spent + byteLength > budgetBytes) {
if (!warned) {
warned = true;
onExceeded();
}
return false;
}
spent += byteLength;
return true;
},
};
}
39 changes: 37 additions & 2 deletions src/extension/image/image-write-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { MAX_IMAGE_DATA_LENGTH } from "../../shared/protocol.js";
import { decideImageWrite, type ImageRejectReason } from "./image-ingest.js";
import type { SessionVolumeBudget } from "./image-write-budget.js";

export function imageRejectToast(reason: ImageRejectReason): string {
switch (reason) {
Expand All @@ -29,6 +30,16 @@ export type ImageWriteDeps = {
showError: (message: string) => void;
/** Post the image-write-result; `null` path ⇒ ok:false. */
postResult: (requestId: string, relativePath: string | null) => void;
/** Session cumulative-volume budget (the DoS cap this service enforces).
* REQUIRED, not optional: a security-load-bearing dep must never default to
* "unbounded" — callers that genuinely want no cap pass an all-permitting
* budget explicitly, so "unbounded" is a visible decision, not a silent one.
* reserve() is charged AFTER validation (only bytes past every per-message
* gate) and BEFORE the write; the charge is never refunded, so total disk
* growth stays bounded by the budget even when a write fails and leaves a
* partial file (see SessionVolumeBudget.reserve). The budget owns its own
* one-time warning, so a budget rejection posts ok:false WITHOUT a showError. */
budget: SessionVolumeBudget;
};

/** Validate + write a base64 image and post the result. Never throws — every
Expand Down Expand Up @@ -64,14 +75,38 @@ export async function handleImageWrite(
deps.postResult(requestId, null);
return;
}
// Session cumulative-volume gate: reserve the validated byte count (only bytes
// past every per-message gate above) BEFORE the async write so concurrent
// fire-and-forget writes can't each overshoot the cap. The charge is not
// refunded on failure (see SessionVolumeBudget.reserve — a failed write can
// still leave a partial file, so counting the attempt keeps disk bounded). The
// budget surfaces its own one-time warning, so this rejection just clears the
// webview's pending entry.
//
// The charge brackets the WHOLE write (in the wiring, writeImage does an
// idempotent createDirectory then writeFile), so a pre-write setup failure is
// charged too even though it wrote no bytes. Accepted deliberately: reserving
// here keeps the budget-rejection path cleanly separate from I/O failures, and
// the over-charge is immaterial — createDirectory only fails on a broken FS,
// which blocks all pastes regardless of the cap (a persistent failure is not a
// budget problem), while a transient blip barely dents a 512 MiB ceiling.
if (!deps.budget.reserve(decision.bytes.length)) {
deps.postResult(requestId, null);
return;
}
// Scope the try to the write ALONE: a throw here means the write failed. The
// success-path post is deliberately OUTSIDE it so a postResult that throws is
// not misread as a write failure (wrong toast + double post).
let relativePath: string;
try {
const relativePath = await deps.writeImage(decision.filename, decision.bytes);
deps.postResult(requestId, relativePath);
relativePath = await deps.writeImage(decision.filename, decision.bytes);
} catch (err) {
console.error("[quoll] image write failed", err);
deps.showError(
"Quoll: failed to write the image file. See the extension host log for details."
);
deps.postResult(requestId, null);
return;
}
deps.postResult(requestId, relativePath);
}
16 changes: 16 additions & 0 deletions src/extension/image/image-write-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { Uri, workspace } from "vscode";

import type { HostToWebview } from "../../shared/protocol.js";
import { buildImageWriteResultMessage } from "../session/document-message.js";
import {
createSessionVolumeBudget,
SESSION_IMAGE_WRITE_BUDGET_BYTES,
SESSION_IMAGE_WRITE_BUDGET_TOAST,
} from "./image-write-budget.js";
import { handleImageWrite } from "./image-write-service.js";

export interface ImageWriteWiringDeps {
Expand All @@ -35,6 +40,10 @@ export interface ImageWriteWiringDeps {
* (harness.writeImageFileOverride) — read PER WRITE (it can be set after
* resolve). null routes to workspace.fs.writeFile. */
readonly writeFileOverride: () => ((uri: Uri, content: Uint8Array) => Thenable<void>) | null;
/** Session cumulative-volume ceiling in bytes. Defaults to
* SESSION_IMAGE_WRITE_BUDGET_BYTES; overridable only so tests can trip the cap
* without allocating half a gigabyte. */
readonly budgetBytes?: number;
}

export interface ImageWriteWiring {
Expand All @@ -44,12 +53,19 @@ export interface ImageWriteWiring {
}

export function createImageWriteWiring(deps: ImageWriteWiringDeps): ImageWriteWiring {
// One budget per wiring instance = one per panel = one per editor session. It
// owns its own one-time warning (a per-write toast would be a flood vector).
const budget = createSessionVolumeBudget(
deps.budgetBytes ?? SESSION_IMAGE_WRITE_BUDGET_BYTES,
() => deps.showError(SESSION_IMAGE_WRITE_BUDGET_TOAST)
);
return {
handle(requestId: string, data: string): void {
void handleImageWrite(
{
canWrite: deps.canWrite,
showError: deps.showError,
budget,
postResult: (id, relativePath) =>
deps.post(buildImageWriteResultMessage(id, relativePath)),
// writeImage creates <docFolder>/assets/ then writes — the explicit
Expand Down
53 changes: 53 additions & 0 deletions test/extension/image/image-write-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";

import { createSessionVolumeBudget } from "../../../src/extension/image/image-write-budget.js";

describe("createSessionVolumeBudget", () => {
it("allows writes up to (and including) the budget, then rejects", () => {
const onExceeded = vi.fn();
const budget = createSessionVolumeBudget(100, onExceeded);

expect(budget.reserve(60)).toBe(true); // spent 60
expect(budget.reserve(40)).toBe(true); // spent 100 — exactly at the cap
expect(budget.reserve(1)).toBe(false); // 101 > 100 — over budget
expect(onExceeded).toHaveBeenCalledOnce();
});

it("warns exactly once no matter how many over-budget writes follow", () => {
const onExceeded = vi.fn();
const budget = createSessionVolumeBudget(10, onExceeded);

expect(budget.reserve(8)).toBe(true); // spent 8
expect(budget.reserve(5)).toBe(false); // 13 > 10 — first over-budget, warns
expect(budget.reserve(5)).toBe(false); // still over — no second warning
expect(onExceeded).toHaveBeenCalledOnce();
});

it("does not charge the budget for a rejected write (a rejected spend cannot exhaust it)", () => {
const onExceeded = vi.fn();
const budget = createSessionVolumeBudget(10, onExceeded);

expect(budget.reserve(8)).toBe(true); // spent 8
expect(budget.reserve(5)).toBe(false); // 13 > 10 — rejected, NOT charged
expect(budget.reserve(2)).toBe(true); // 8 + 2 = 10 still fits
expect(budget.reserve(1)).toBe(false); // 11 > 10
});

it("never refunds a charge: a spend stays spent for the life of the session", () => {
// No release/refund counterpart exists — a charged write is permanent even
// if the write later fails, so total disk growth stays bounded by the cap.
const budget = createSessionVolumeBudget(10, vi.fn());
expect(budget.reserve(10)).toBe(true); // spent 10 — full
expect(budget.reserve(1)).toBe(false); // still full; the charge did not lapse
expect("release" in budget).toBe(false); // no refund API to reopen the cap
});

it("rejects a NaN or negative byteLength instead of poisoning the running total", () => {
const budget = createSessionVolumeBudget(10, vi.fn());
expect(() => budget.reserve(Number.NaN)).toThrow(RangeError);
expect(() => budget.reserve(-1)).toThrow(RangeError);
expect(() => budget.reserve(1.5)).toThrow(RangeError);
// The cap is still intact after the rejected inputs (spent untouched).
expect(budget.reserve(10)).toBe(true);
});
});
Loading