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
31 changes: 29 additions & 2 deletions src/integrations/cursor-local-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
const DEFAULT_UPDATE_HOST = "https://api2.cursor.sh";

const UPDATE_MANIFEST_TIMEOUT_MS = 4_000;
const UPDATE_MANIFEST_MAX_BYTES = 64 * 1024;
const UPDATE_MANIFEST_MAX_VERSION_LENGTH = 256;
const UPDATE_MANIFEST_MAX_URL_LENGTH = 4 * 1024;
const CACHE_TTL_OK_MS = 30 * 60_000;
const CACHE_TTL_FAILED_MS = 5 * 60_000;

Expand Down Expand Up @@ -63,7 +66,29 @@ export function realCursorLocalHintDeps(): CursorLocalHintDeps {
fetchJson: async (url, timeoutMs) => {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
if (!response.body) throw new Error("Missing response body");
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > UPDATE_MANIFEST_MAX_BYTES) {
// Best-effort discard: awaiting cancel() could hang on a stalled source
// even though AbortSignal.timeout already bounds the request itself.
void reader.cancel().catch(() => {});
throw new Error("Update manifest exceeds byte limit");
}
chunks.push(value);
}
const body = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body));
},
};
}
Expand All @@ -86,14 +111,16 @@ export function platformForHost(os: string, arch: string): CursorLocalPlatform |
function parseManifest(raw: unknown): { version: string; url: string } | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as CursorLocalManifest;
if (typeof record.url !== "string" || !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) {
if (typeof record.url !== "string" || record.url.length > UPDATE_MANIFEST_MAX_URL_LENGTH
|| !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) {
return null;
}
// Windows and Linux manifests carry `version`/`productVersion`; the Darwin ones return only
// `{ url, name }`, with the version in `name`.
const candidate = typeof record.version === "string" ? record.version
: typeof record.productVersion === "string" ? record.productVersion
: typeof record.name === "string" ? record.name : "";
if (candidate.length > UPDATE_MANIFEST_MAX_VERSION_LENGTH) return null;
const version = candidate.trim();
if (version === "") return null;
// The Linux channel advertises the AppImage's zsync delta metadata (what the in-app updater
Expand Down
3 changes: 2 additions & 1 deletion structure/clients/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ Cursor's `cursor-local` update channel advertises for the host platform and arch
`x64` and `arm64` on Windows, macOS and Linux map; any other host resolves to `unsupported-platform`
with no request)
(`<updateHost>/updates/api/update/<platform>/cursor-local/0.0.0/manual-check/stable`, 4 s timeout).
Only a `https://downloads.cursor.com/local-mode/` URL with a version is accepted (a Linux
The decoded manifest is capped at 64 KiB before JSON parsing. Only a bounded
`https://downloads.cursor.com/local-mode/` URL with a bounded version is accepted (a Linux
`.AppImage.zsync` delta-metadata URL is mapped to its sibling `.AppImage`); anything else
resolves to `available: false` with reason `unreachable` or `unusable-response`, and nothing is
requested when Private Inference is already installed or no regular install exists. The module never
Expand Down
85 changes: 85 additions & 0 deletions tests/providers/cursor/cursor-local-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,91 @@ describe("buildCursorLocalInstallerHint", () => {
expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" });
});

test("oversized manifest fields are rejected before they can be cached", async () => {
for (const manifest of [
{ version: "v".repeat(257), url: REPORTED_INSTALLER },
{ version: "3.21.18", url: `https://downloads.cursor.com/local-mode/${"x".repeat(4096)}` },
]) {
resetCursorLocalInstallerCacheForTests();
const hint = await buildCursorLocalInstallerHint(
{ regularInstalled: true, privateInferenceInstalled: false },
depsWith(manifest),
);
expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" });
}
});

test("real deps stop reading a decoded response above the manifest byte limit", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
version: "v".repeat(70 * 1024),
url: REPORTED_INSTALLER,
})))) as typeof fetch;
try {
const hint = await buildCursorLocalInstallerHint(
{ regularInstalled: true, privateInferenceInstalled: false },
{ ...realCursorLocalHintDeps(), platform: "win32", arch: "x64" },
);
expect(hint).toEqual({ available: false, url: null, version: null, reason: "unreachable" });
} finally {
globalThis.fetch = originalFetch;
}
});

test("oversize rejection does not await a never-settling stream cancellation", async () => {
const originalFetch = globalThis.fetch;
let cancelled = 0;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(32 * 1024));
controller.enqueue(new Uint8Array(32 * 1024 + 1));
},
cancel() {
cancelled += 1;
return new Promise<void>(() => {});
},
});
globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const outcome = await Promise.race([
realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000).then(
() => ({ kind: "resolved" }),
error => ({ kind: "rejected", message: String(error.message) }),
),
new Promise(resolve => { timer = setTimeout(() => resolve({ kind: "timeout" }), 1_000); }),
]);
expect(outcome).toEqual({ kind: "rejected", message: "Update manifest exceeds byte limit" });
expect(cancelled).toBe(1);
} finally {
clearTimeout(timer);
globalThis.fetch = originalFetch;
}
});

test.each([1_024, 64 * 1024])("valid multichunk manifest at %i bytes is decoded", async size => {
const originalFetch = globalThis.fetch;
const manifest = { version: "3.21.18", url: REPORTED_INSTALLER, padding: "" };
const encoder = new TextEncoder();
manifest.padding = "x".repeat(size - encoder.encode(JSON.stringify(manifest)).byteLength);
const encoded = encoder.encode(JSON.stringify(manifest));
expect(encoded.byteLength).toBe(size);
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (let offset = 0; offset < encoded.byteLength; offset += 512) {
controller.enqueue(encoded.slice(offset, offset + 512));
}
controller.close();
},
});
globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch;
try {
expect(await realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000)).toEqual(manifest);
} finally {
globalThis.fetch = originalFetch;
}
});

test("no regular install resolves nothing without any network call", async () => {
const hint = await buildCursorLocalInstallerHint(
{ regularInstalled: false, privateInferenceInstalled: false },
Expand Down
Loading