Replace sharp with jpeg-js + exif-reader; add bun compile binary

sharp was the only native dependency preventing a single-file binary.
Replaced with:
  - jpeg-js (pure JS) for JPEG decode/resize/encode in thumbnail gen
  - exif-reader (pure JS) for EXIF tag parsing
  - Raw JPEG APP1 marker extraction for EXIF segment discovery
  - Raw XMP packet extraction from file bytes

make build-bin produces a ~59MB self-contained Mach-O binary via
bun build --compile (bun installed via nix-shell). Zero runtime
dependencies. Tested: login, whoami, collections, files all work
from the compiled binary.

bin/quak.ts: init() called once at program start before commander
parses, so libsodium is ready for all commands including those that
restore sessions from disk.

118 tests pass.
This commit is contained in:
2026-06-10 10:44:26 -07:00
parent 5e6069f574
commit 25d3c612cf
9 changed files with 209 additions and 279 deletions

View File

@@ -42,7 +42,7 @@ import {
deriveLoginSubkey,
encryptBlob,
} from "../../src/crypto/index.js";
import sharp from "sharp";
import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js";
import { runMetadataBackup } from "../../src/metadata-backup.js";
import type { KeyAttributes } from "../../src/auth/types.js";
@@ -290,11 +290,19 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
);
// Generate a real JPEG for EXIF extraction tests
const tinyJpeg = await sharp({
create: { width: 100, height: 80, channels: 3, background: "red" },
})
.jpeg({ quality: 80 })
.toBuffer();
const jw = 100;
const jh = 80;
const jpixels = new Uint8Array(jw * jh * 4);
for (let i = 0; i < jpixels.length; i += 4) {
jpixels[i] = 255;
jpixels[i + 1] = 0;
jpixels[i + 2] = 0;
jpixels[i + 3] = 255;
}
const tinyJpeg = jpegJs.encode(
{ data: jpixels, width: jw, height: jh },
80,
).data;
const filePush1 =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk1);
const encFileBody1 = sodium.crypto_secretstream_xchacha20poly1305_push(
@@ -606,11 +614,10 @@ describe("quak backup-metadata", () => {
),
);
// imageMetadata from sharp should be present
// imageMetadata from JPEG parsing should be present
expect(fileMeta.imageMetadata).toBeDefined();
expect(fileMeta.imageMetadata.format).toBe("jpeg");
expect(fileMeta.imageMetadata.width).toBe(100);
expect(fileMeta.imageMetadata.height).toBe(80);
expect(fileMeta.imageMetadata.channels).toBe(3);
});
});

View File

@@ -7,7 +7,7 @@
* verify that the detection and repair logic handles each case correctly.
*
* `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with sharp,
* downloads the original file, generates a JPEG thumbnail with jpeg-js,
* encrypts it with secretstream push, gets a presigned upload URL,
* uploads to S3, and registers the new thumbnail with the API. The
* test verifies each step actually happened and the uploaded data is
@@ -15,7 +15,7 @@
*/
import sodium from "libsodium-wrappers-sumo";
import sharp from "sharp";
import * as jpegJs from "jpeg-js";
import { beforeAll, describe, expect, it } from "vitest";
import {
init,
@@ -120,12 +120,20 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
updationTime: 1700000000000000,
};
// Generate a real tiny JPEG that sharp can process
const tinyJpeg = await sharp({
create: { width: 100, height: 80, channels: 3, background: "red" },
})
.jpeg({ quality: 80 })
.toBuffer();
// Generate a real tiny JPEG via jpeg-js
const w = 100;
const h = 80;
const pixels = new Uint8Array(w * h * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 255; // R
pixels[i + 1] = 0; // G
pixels[i + 2] = 0; // B
pixels[i + 3] = 255; // A
}
const tinyJpeg = jpegJs.encode(
{ data: pixels, width: w, height: h },
80,
).data;
const fileKeys: Record<number, Uint8Array> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
@@ -434,7 +442,7 @@ describe("fixMissingThumbnails", () => {
expect(decrypted[0]).toBe(0xff);
expect(decrypted[1]).toBe(0xd8);
expect(decrypted[2]).toBe(0xff);
// Verify sharp produced a reasonably sized thumbnail
// Verify jpeg-js produced a reasonably sized thumbnail
expect(decrypted.length).toBeGreaterThan(100);
expect(decrypted.length).toBeLessThan(50000);
});