Author SHA1 Message Date
sneak 685f1d584c Test script/build-bin's archive choice, hash check and refusal
check / check (push) Successful in 3m51s
The test runs the script with stub uname, ldd, curl, unzip and nix-shell
first on PATH, so it needs no network and no bun. The download and the
compile stay untested.

Model: opus-5-5
2026-11-11 22:52:58 +00:00
sneak 674fdeb482 make build-bin: on Linux, build with bun's pinned release build (closes #240)
nix's bun loads through a glibc in the nix store, and bun build --compile
copied that loader into bin/quak, so nix-collect-garbage could break the
cron backup. The build moves into script/build-bin. On Linux it downloads
bun 1.3.13's release archive for the machine from GitHub: x86-64
(baseline) or arm64, glibc or musl. It checks the archive's SHA-256
before running it, and compiles with that bun, so the binary loads
through the system loader. macOS still runs bun through nix-shell.

Model: opus-5-5
2026-11-11 22:40:51 +00:00
9 changed files with 80 additions and 376 deletions

No files matched your search

+12 -29
View File
@@ -156,9 +156,10 @@ requests. The "Library reference" below covers both.
`quak backup` never prompts, so cron can run it. `make install` builds quak as `quak backup` never prompts, so cron can run it. `make install` builds quak as
one binary and copies it to `~/bin/quak`. It uses bun, a JavaScript runtime, to one binary and copies it to `~/bin/quak`. It uses bun, a JavaScript runtime, to
bundle the built `dist/` into one executable. Besides what Getting Started bundle the built `dist/` into one executable. Besides what Getting Started
needs, `make install` needs `make`. It downloads bun from GitHub with `curl` and needs, `make install` needs `make`. On Linux it downloads bun from GitHub with
unpacks it with `unzip`. It checks the download against a SHA-256 hash in the `curl` and unpacks it with `unzip`. It checks the download against a SHA-256
repo. `make install` works on Linux and macOS, on x86-64 and arm64. hash in the repo. On macOS it runs bun through `nix-shell -p bun`, so it needs
[nix](https://nixos.org/download).
On Linux systems that use the musl C library, such as Alpine, `make install` On Linux systems that use the musl C library, such as Alpine, `make install`
downloads bun's musl build. On Alpine, both that bun and `~/bin/quak` need the downloads bun's musl build. On Alpine, both that bun and `~/bin/quak` need the
@@ -229,7 +230,7 @@ below belong to quak itself, and go before or after the command's name.
### The commands ### The commands
``` ```
quak login [--server <url>] quak login
quak whoami quak whoami
quak logout quak logout
quak collections [--json] quak collections [--json]
@@ -246,26 +247,13 @@ Every command but `login` and `logout` needs a saved session. Every command but
`login`, `whoami` and `logout` opens the library. How each one gets current data `login`, `whoami` and `logout` opens the library. How each one gets current data
from the server is described under "How a command gets current data". from the server is described under "How a command gets current data".
#### `quak login [--server <url>]` #### `quak login`
Logs in to an account and saves the session. It reads the email from Logs in to an account and saves the session. It reads the email from
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not `QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
set, it asks on the terminal. The password shows as `*` characters while it is set, it asks on the terminal. The password shows as `*` characters while it is
typed. typed.
quak logs in to Ente's hosted service unless you name a self-hosted Ente server.
Name it with `--server`, or with `QUAK_SERVER` when `--server` is not given.
Give the address of the server's API, for example
`quak login --server https://ente.example.com`. The address is an http or https
URL. It may include a path, but no query or fragment. quak refuses any other
address before it asks for anything. It logs
`Invalid server address "<url>": it must be an http or https URL with no query or fragment`,
and the exit status is 1.
The session file keeps the server's address. Every later command, cron runs
included, uses that server without further setting. To change server, log in
again.
When the account has a second factor, quak asks for the code. It asks When the account has a second factor, quak asks for the code. It asks
`TOTP code: ` for a time-based code from an authenticator app. An account whose `TOTP code: ` for a time-based code from an authenticator app. An account whose
only second factor is a passkey cannot log in with quak. When the account has only second factor is a passkey cannot log in with quak. When the account has
@@ -610,11 +598,9 @@ Linux that is `$XDG_DATA_HOME/quak/session.json`, or
mode `0600`, in a directory it creates with mode `0700`. mode `0600`, in a directory it creates with mode `0700`.
The file holds the account's email, its user ID, the session token and the The file holds the account's email, its user ID, the session token and the
account's three keys, in base64 and unencrypted. After a login to a self-hosted account's three keys, in base64 and unencrypted. Treat it as you would treat the
server, it also holds the server's address as `apiOrigin`. Treat it as you would password. A missing file means "not logged in". A file that cannot be read back
treat the password. A missing file means "not logged in". A file that cannot be is reported as corrupt, with the reason. Run `quak logout`, then `quak login`.
read back is reported as corrupt, with the reason. Run `quak logout`, then
`quak login`.
A command that needs a session exits 3 when the file is missing or corrupt. It A command that needs a session exits 3 when the file is missing or corrupt. It
also exits 3 when the server no longer accepts the session, and prints also exits 3 when the server no longer accepts the session, and prints
@@ -1492,11 +1478,6 @@ write wherever it likes. `Client.fromJSON(snapshot, apiOptions?)` restores a
working client from that snapshot without logging in again. It checks every working client from that snapshot without logging in again. It checks every
field and each key's length first, and throws an error naming the bad field. field and each key's length first, and throws an error naming the bad field.
A client whose `apiOptions` named an `apiOrigin` puts it in its snapshot. The
restored client then uses that server, even when the `apiOptions` given to
`Client.fromJSON` name another. A snapshot without `apiOrigin` uses the
`apiOrigin` of those `apiOptions`, or the default server.
`Client.fromJSON` works only once libsodium, the cryptography library quak uses, `Client.fromJSON` works only once libsodium, the cryptography library quak uses,
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
that restores a session in a new process first runs `await sodium.ready`, with that restores a session in a new process first runs `await sodium.ready`, with
@@ -1956,9 +1937,11 @@ These apply to people and to coding agents alike.
- container images by digest; - container images by digest;
- the nvm archive `script/bootstrap` downloads, and the Python packages - the nvm archive `script/bootstrap` downloads, and the Python packages
`bin/clip-keyword-vectors` installs, by SHA-256 hash; `bin/clip-keyword-vectors` installs, by SHA-256 hash;
- the bun builds `script/build-bin` downloads, by SHA-256 hash; - bun's glibc and musl builds, which `script/build-bin` downloads on Linux,
by SHA-256 hash;
- Node and yarn, when `script/bootstrap` installs them, by version. - Node and yarn, when `script/bootstrap` installs them, by version.
- Not pinned: - Not pinned:
- bun on macOS, which `script/build-bin` takes from `nix-shell -p bun`;
- `make` and `git`, which `script/bootstrap` installs from the system - `make` and `git`, which `script/bootstrap` installs from the system
package manager when they are missing, as every `Dockerfile` stage does; package manager when they are missing, as every `Dockerfile` stage does;
- `bash` and `curl`, which `script/bootstrap` installs the same way when it - `bash` and `curl`, which `script/bootstrap` installs the same way when it
+2 -11
View File
@@ -74,11 +74,7 @@ const context = (): CliContext => ({
sessionDir: paths.data, sessionDir: paths.data,
cacheDir: program.opts<ProgramOptions>().cacheDir, cacheDir: program.opts<ProgramOptions>().cacheDir,
loadSession: (path) => loadSession(path, logRequests(log())), loadSession: (path) => loadSession(path, logRequests(log())),
login: (opts) => login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }),
Client.login({
...opts,
apiOptions: { ...opts.apiOptions, ...logRequests(log()) },
}),
prompt: (message) => input({ message }), prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }), promptSecret: (message) => password({ message, mask: true }),
}); });
@@ -89,12 +85,7 @@ const run = (command: Promise<number>): Promise<void> =>
program program
.command("login") .command("login")
.description("Log in to an Ente account and save the session") .description("Log in to an Ente account and save the session")
.option( .action(() => run(loginCommand(context())));
"--server <url>",
"Address of a self-hosted Ente server's API, saved with the session " +
"(default: QUAK_SERVER, then Ente's hosted service)",
)
.action((opts: { server?: string }) => run(loginCommand(context(), opts)));
program program
.command("whoami") .command("whoami")
+12 -24
View File
@@ -3,13 +3,12 @@
# executable, bin/quak, which reports the version script/build stamped. Our own # executable, bin/quak, which reports the version script/build stamped. Our own
# extension to scripts-to-rule-them-all. # extension to scripts-to-rule-them-all.
# #
# bun compiles the binary from a copy of its own executable, so the binary # bun compiles the binary from a copy of its own executable. On Linux, nix's
# loads the same libraries as the bun that built it. nix's bun loads libraries # bun loads through a C library in the nix store, and so does a binary compiled
# from the nix store: on Linux its C library, on macOS its ICU. A binary # from it. nix-collect-garbage can delete that library, and the binary then no
# compiled from it loads them too. nix-collect-garbage can delete them, and the # longer starts. On Linux this script therefore compiles with bun's own release
# binary then no longer starts. This script therefore compiles with bun's own # build, which loads through the system's C library. It downloads that build
# release build, which loads only the system's libraries. It downloads that # and checks it against the hash below before running it.
# build and checks it against the hash below before running it.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -23,8 +22,6 @@ BUN_LINUX_X64_BASELINE_SHA256="9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf
BUN_LINUX_X64_MUSL_BASELINE_SHA256="88ca7c7ad235b498f549eea2f770f434e9f0f5e9ba95168a2d3a1f235184c394" BUN_LINUX_X64_MUSL_BASELINE_SHA256="88ca7c7ad235b498f549eea2f770f434e9f0f5e9ba95168a2d3a1f235184c394"
BUN_LINUX_AARCH64_SHA256="70bae41b3908b0a120e1e58c5c8af30e74afae3b8d11b0d3fdd8e787ddfb4b22" BUN_LINUX_AARCH64_SHA256="70bae41b3908b0a120e1e58c5c8af30e74afae3b8d11b0d3fdd8e787ddfb4b22"
BUN_LINUX_AARCH64_MUSL_SHA256="5385e978107ce4934298d8d6afe9bfbb898683f6cc23e6753a0da60bc60c5b81" BUN_LINUX_AARCH64_MUSL_SHA256="5385e978107ce4934298d8d6afe9bfbb898683f6cc23e6753a0da60bc60c5b81"
BUN_DARWIN_X64_BASELINE_SHA256="a98ba6a480f22fda9b343626b906a4e26aa53618bf85d2bc5928ecf2ba45f0ed"
BUN_DARWIN_AARCH64_SHA256="5467e3f65dba526b9fea98f0cce04efafc0c63e169733ec27b876a3ad32da190"
# compile_with_downloaded_bun <archive-name> <sha256> # compile_with_downloaded_bun <archive-name> <sha256>
compile_with_downloaded_bun() { compile_with_downloaded_bun() {
@@ -32,12 +29,7 @@ compile_with_downloaded_bun() {
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/$1.zip" \ curl -fsSL -o "$tmp/$1.zip" \
"https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$1.zip" "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$1.zip"
# macOS always has shasum, but not every macOS has sha256sum. echo "$2 $tmp/$1.zip" | sha256sum -c -
if command -v sha256sum >/dev/null 2>&1; then
echo "$2 $tmp/$1.zip" | sha256sum -c -
else
echo "$2 $tmp/$1.zip" | shasum -a 256 -c -
fi
unzip -q "$tmp/$1.zip" -d "$tmp" unzip -q "$tmp/$1.zip" -d "$tmp"
"$tmp/$1/bun" build dist/bin/quak.js --compile --outfile bin/quak "$tmp/$1/bun" build dist/bin/quak.js --compile --outfile bin/quak
} }
@@ -67,17 +59,13 @@ main() {
compile_with_downloaded_bun bun-linux-aarch64-musl \ compile_with_downloaded_bun bun-linux-aarch64-musl \
"$BUN_LINUX_AARCH64_MUSL_SHA256" "$BUN_LINUX_AARCH64_MUSL_SHA256"
;; ;;
Darwin-x86_64) Linux-*)
compile_with_downloaded_bun bun-darwin-x64-baseline \ echo "build-bin: bun has no release build for Linux on $(uname -m)" >&2
"$BUN_DARWIN_X64_BASELINE_SHA256" exit 1
;;
Darwin-arm64)
compile_with_downloaded_bun bun-darwin-aarch64 \
"$BUN_DARWIN_AARCH64_SHA256"
;; ;;
*) *)
echo "build-bin: bun has no release build for $(uname -s) on $(uname -m)" >&2 nix-shell -p bun --run \
exit 1 "bun build dist/bin/quak.js --compile --outfile bin/quak"
;; ;;
esac esac
} }
+1 -26
View File
@@ -155,31 +155,8 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
onProgress: logRefresh(ctx.log), onProgress: logRefresh(ctx.log),
}); });
// Logs in to the server `--server` or `QUAK_SERVER` names, the flag first, or export const loginCommand = async (ctx: CliContext): Promise<number> => {
// to Ente's hosted service when neither is set. The saved session keeps the
// server, so every later command uses it.
// Whether `text` can be an Ente server's address: an http or https URL, which
// may have a path, and no query or fragment, which request URLs could not
// carry.
const isServerAddress = (text: string): boolean =>
URL.canParse(text) &&
["http:", "https:"].includes(new URL(text).protocol) &&
!/[?#]/.test(text);
export const loginCommand = async (
ctx: CliContext,
opts: { server?: string },
): Promise<number> => {
await init(); await init();
const server = opts.server ?? process.env.QUAK_SERVER;
if (server !== undefined && !isServerAddress(server)) {
ctx.log(
"fatal",
`Invalid server address "${server}": it must be an http or ` +
`https URL with no query or fragment`,
);
return 1;
}
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email")); const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
const password = const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
@@ -191,8 +168,6 @@ export const loginCommand = async (
password, password,
totp: async () => ctx.prompt("TOTP code: "), totp: async () => ctx.prompt("TOTP code: "),
emailOTP: async () => ctx.prompt("Email verification code: "), emailOTP: async () => ctx.prompt("Email verification code: "),
apiOptions:
server === undefined ? undefined : { apiOrigin: server },
}); });
saveSession(ctx.sessionDir, client.toJSON()); saveSession(ctx.sessionDir, client.toJSON());
+1 -23
View File
@@ -40,9 +40,6 @@ export interface ClientSnapshot {
masterKey: string; masterKey: string;
secretKey: string; secretKey: string;
publicKey: string; publicKey: string;
// The `apiOrigin` the client was given, so a restored client talks to
// the server that issued its token. Absent means the default server.
apiOrigin?: string;
} }
// The result of a resumable enumeration. Live decrypted records and deleted // The result of a resumable enumeration. Live decrypted records and deleted
@@ -76,7 +73,6 @@ export class Client {
private readonly masterKey: Uint8Array; private readonly masterKey: Uint8Array;
private readonly secretKey: Uint8Array; private readonly secretKey: Uint8Array;
private readonly publicKey: Uint8Array; private readonly publicKey: Uint8Array;
private readonly apiOrigin: string | undefined;
private loggedOut = false; private loggedOut = false;
private constructor( private constructor(
@@ -86,7 +82,6 @@ export class Client {
masterKey: Uint8Array, masterKey: Uint8Array,
secretKey: Uint8Array, secretKey: Uint8Array,
publicKey: Uint8Array, publicKey: Uint8Array,
apiOrigin: string | undefined,
) { ) {
this.api = api; this.api = api;
this.email = email; this.email = email;
@@ -94,7 +89,6 @@ export class Client {
this.masterKey = masterKey; this.masterKey = masterKey;
this.secretKey = secretKey; this.secretKey = secretKey;
this.publicKey = publicKey; this.publicKey = publicKey;
this.apiOrigin = apiOrigin;
} }
static async login(opts: LoginOptions): Promise<Client> { static async login(opts: LoginOptions): Promise<Client> {
@@ -136,15 +130,12 @@ export class Client {
unwrapped.masterKey, unwrapped.masterKey,
unwrapped.secretKey, unwrapped.secretKey,
unwrapped.publicKey, unwrapped.publicKey,
opts.apiOptions?.apiOrigin,
); );
} }
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes // Restore a client from a `toJSON()` snapshot. The snapshot usually comes
// straight from `JSON.parse` of a file on disk, so every field is checked // straight from `JSON.parse` of a file on disk, so every field is checked
// before use; a bad one throws an error naming it. The snapshot's // before use; a bad one throws an error naming it. Needs `init()` first.
// `apiOrigin`, when it has one, is used instead of `apiOptions.apiOrigin`.
// Needs `init()` first.
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client { static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
const invalid = (field: string, problem: string): Error => const invalid = (field: string, problem: string): Error =>
new Error(`Invalid session data: ${field} ${problem}`); new Error(`Invalid session data: ${field} ${problem}`);
@@ -161,14 +152,6 @@ export class Client {
if (!Number.isInteger(s.userID)) { if (!Number.isInteger(s.userID)) {
throw invalid("userID", "must be an integer"); throw invalid("userID", "must be an integer");
} }
if (
s.apiOrigin !== undefined &&
(typeof s.apiOrigin !== "string" || s.apiOrigin === "")
) {
throw invalid("apiOrigin", "must be a non-empty string");
}
const apiOrigin =
(s.apiOrigin as string | undefined) ?? apiOptions?.apiOrigin;
const key = (field: string): Uint8Array => { const key = (field: string): Uint8Array => {
const value = s[field]; const value = s[field];
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -192,7 +175,6 @@ export class Client {
const api = new ApiClient({ const api = new ApiClient({
...apiOptions, ...apiOptions,
apiOrigin,
authToken: s.token as string, authToken: s.token as string,
}); });
return new Client( return new Client(
@@ -202,7 +184,6 @@ export class Client {
key("masterKey"), key("masterKey"),
key("secretKey"), key("secretKey"),
key("publicKey"), key("publicKey"),
apiOrigin,
); );
} }
@@ -241,9 +222,6 @@ export class Client {
masterKey: toBase64(this.masterKey), masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey), secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey), publicKey: toBase64(this.publicKey),
...(this.apiOrigin === undefined
? {}
: { apiOrigin: this.apiOrigin }),
}; };
} }
+2 -13
View File
@@ -2,9 +2,8 @@
* Tests for the retry options `bin/quak.ts` loads the saved session with * Tests for the retry options `bin/quak.ts` loads the saved session with
* (`quak backup` gets the unattended ones, every other command the default), * (`quak backup` gets the unattended ones, every other command the default),
* each with the callbacks that log its requests and retries, for `quak files` * each with the callbacks that log its requests and retries, for `quak files`
* taking its collection ID as an optional argument, for `quak get` and * taking its collection ID as an optional argument, and for `quak get` and
* `quak get-thumb` refusing `--collection`, and for `quak login` taking * `quak get-thumb` refusing `--collection`.
* `--server`.
* *
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session, * replaced by one that records the options it is given and reports no session,
@@ -118,13 +117,3 @@ describe("bin/quak.ts get and get-thumb", () => {
}, },
); );
}); });
// The command refuses an address that is not a URL before it asks for anything
// or sends any request.
describe("bin/quak.ts login", () => {
it("hands --server to the command", async () => {
expect(await quak("login", "--server", "ente.example.com")).toMatch(
/^Invalid server address "ente\.example\.com"/,
);
});
});
+4 -103
View File
@@ -328,7 +328,6 @@ describe("login", () => {
prompts = []; prompts = [];
vi.stubEnv("QUAK_EMAIL", "cli@example.com"); vi.stubEnv("QUAK_EMAIL", "cli@example.com");
vi.stubEnv("QUAK_PASSWORD", "hunter2"); vi.stubEnv("QUAK_PASSWORD", "hunter2");
vi.stubEnv("QUAK_SERVER", undefined);
}); });
afterEach(() => { afterEach(() => {
@@ -341,14 +340,12 @@ describe("login", () => {
calls.push(opts); calls.push(opts);
return loggedIn; return loggedIn;
}); });
expect(await loginCommand(ctx, {})).toBe(0); expect(await loginCommand(ctx)).toBe(0);
expect(prompts).toEqual([]); expect(prompts).toEqual([]);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0]!.email).toBe("cli@example.com"); expect(calls[0]!.email).toBe("cli@example.com");
expect(calls[0]!.password).toBe("hunter2"); expect(calls[0]!.password).toBe("hunter2");
// No server named: the client uses Ente's hosted service.
expect(calls[0]!.apiOptions).toBeUndefined();
const path = join(ctx.sessionDir, "session.json"); const path = join(ctx.sessionDir, "session.json");
expect(stderr.text).toBe( expect(stderr.text).toBe(
"Authenticating...\n" + "Authenticating...\n" +
@@ -359,7 +356,7 @@ describe("login", () => {
it("saves the session with mode 0600 in a directory with mode 0700", async () => { it("saves the session with mode 0600 in a directory with mode 0700", async () => {
const ctx = loginContext(async () => loggedIn); const ctx = loginContext(async () => loggedIn);
expect(await loginCommand(ctx, {})).toBe(0); expect(await loginCommand(ctx)).toBe(0);
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700); expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
const path = join(ctx.sessionDir, "session.json"); const path = join(ctx.sessionDir, "session.json");
@@ -373,7 +370,7 @@ describe("login", () => {
code = await opts.totp!(); code = await opts.totp!();
return loggedIn; return loggedIn;
}); });
expect(await loginCommand(ctx, {})).toBe(0); expect(await loginCommand(ctx)).toBe(0);
expect(prompts).toEqual(["TOTP code: "]); expect(prompts).toEqual(["TOTP code: "]);
expect(code).toBe("123456"); expect(code).toBe("123456");
@@ -383,68 +380,13 @@ describe("login", () => {
const ctx = loginContext(async () => { const ctx = loginContext(async () => {
throw new Error("HTTP 401 from server"); throw new Error("HTTP 401 from server");
}); });
expect(await loginCommand(ctx, {})).toBe(1); expect(await loginCommand(ctx)).toBe(1);
expect(stderr.text).toBe( expect(stderr.text).toBe(
"Authenticating...\nLogin failed: HTTP 401 from server\n", "Authenticating...\nLogin failed: HTTP 401 from server\n",
); );
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
}); });
it("logs in to the server --server names, ahead of QUAK_SERVER", async () => {
vi.stubEnv("QUAK_SERVER", "https://other.example.com");
const calls: LoginOptions[] = [];
const ctx = loginContext(async (opts) => {
calls.push(opts);
return loggedIn;
});
expect(
await loginCommand(ctx, { server: "https://ente.example.com" }),
).toBe(0);
expect(calls[0]!.apiOptions).toEqual({
apiOrigin: "https://ente.example.com",
});
});
it("logs in to the server QUAK_SERVER names without --server", async () => {
vi.stubEnv("QUAK_SERVER", "http://192.0.2.1:8080/ente");
const calls: LoginOptions[] = [];
const ctx = loginContext(async (opts) => {
calls.push(opts);
return loggedIn;
});
expect(await loginCommand(ctx, {})).toBe(0);
expect(calls[0]!.apiOptions).toEqual({
apiOrigin: "http://192.0.2.1:8080/ente",
});
});
it.each([
"",
"ente.example.com",
"ftp://ente.example.com",
"https://ente.example.com/?region=eu",
"https://ente.example.com/#top",
])(
"refuses the server address %j before asking for anything",
async (server) => {
const ctx = loginContext(async () => {
throw new Error("login must not be called");
});
expect(await loginCommand(ctx, { server })).toBe(1);
expect(prompts).toEqual([]);
expect(stderr.text).toBe(
`Invalid server address "${server}": it must be an http or ` +
`https URL with no query or fragment\n`,
);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(
false,
);
},
);
}); });
// These use a real client read from the session file, over a fake API that // These use a real client read from the session file, over a fake API that
@@ -1909,47 +1851,6 @@ describe("an account with many albums", () => {
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false); expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
}); });
// A self-hosted server serves downloads under its own address, at
// `/files/download/<id>`, so no request goes to Ente's hosted service.
it("login --server saves the server, and a later get sends every request to it", async () => {
vi.stubEnv("QUAK_EMAIL", "many@example.com");
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
vi.stubEnv("QUAK_SERVER", undefined);
const hosts = new Set<string>();
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
hosts.add(
new URL(input instanceof Request ? input.url : input).host,
);
return server(input, init);
}) as typeof globalThis.fetch;
const ctx: CliContext = {
...context(),
login: (opts) =>
Client.login({
...opts,
apiOptions: { ...opts.apiOptions, fetch },
}),
loadSession: (path) => loadSession(path, { fetch }),
};
const out = join(root, "photo.jpg");
try {
const opts = { server: "https://ente.example.com" };
expect(await loginCommand(ctx, opts)).toBe(0);
requests.length = 0;
expect(await getCommand(ctx, "200", { out })).toBe(0);
} finally {
vi.unstubAllEnvs();
}
const path = join(ctx.sessionDir, "session.json");
expect(JSON.parse(readFileSync(path, "utf-8")).apiOrigin).toBe(
"https://ente.example.com",
);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(requests.at(-1)).toBe("/files/download/200");
expect([...hosts]).toEqual(["ente.example.com"]);
});
// `files` first fills the cache, so `get` waits for a refresh. No album has // `files` first fills the cache, so `get` waits for a refresh. No album has
// changed since, so the refresh asks for no album's files. // changed since, so the refresh asks for no album's files.
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => { it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
-40
View File
@@ -47,20 +47,6 @@ describe("Client.toJSON", () => {
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot); expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
}); });
it("round-trips a snapshot that names a server", () => {
const snapshot = {
...validSnapshot(),
apiOrigin: "https://ente.example.com",
};
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
});
it("names the server apiOptions gave fromJSON", () => {
const apiOrigin = "https://ente.example.com";
const client = Client.fromJSON(validSnapshot(), { apiOrigin });
expect(client.toJSON().apiOrigin).toBe(apiOrigin);
});
it("throws instead of emitting a snapshot without a token", () => { it("throws instead of emitting a snapshot without a token", () => {
const client = Client.fromJSON(validSnapshot()); const client = Client.fromJSON(validSnapshot());
client.getApiClient().clearAuthToken(); client.getApiClient().clearAuthToken();
@@ -91,8 +77,6 @@ describe("Client.fromJSON", () => {
["publicKey", undefined], ["publicKey", undefined],
["publicKey", "not base64!"], ["publicKey", "not base64!"],
["publicKey", shortKey], ["publicKey", shortKey],
["apiOrigin", 7],
["apiOrigin", ""],
])("rejects %s = %j, naming the field", (field, value) => { ])("rejects %s = %j, naming the field", (field, value) => {
const snapshot: Record<string, unknown> = { ...validSnapshot() }; const snapshot: Record<string, unknown> = { ...validSnapshot() };
snapshot[field] = value; snapshot[field] = value;
@@ -101,30 +85,6 @@ describe("Client.fromJSON", () => {
); );
}); });
// The token belongs to the snapshot's server, so that server wins over
// the one in `apiOptions`.
it("sends requests to the snapshot's server", async () => {
const urls: string[] = [];
const client = Client.fromJSON(
{ ...validSnapshot(), apiOrigin: "https://ente.example.com/" },
{
apiOrigin: "https://other.example.com",
fetch: async (input) => {
urls.push(String(input));
return new Response(JSON.stringify({ collections: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
},
);
await client.listCollections();
expect(urls).toEqual([
"https://ente.example.com/collections/v2?sinceTime=0",
]);
});
it.each([null, "a string", 42])("rejects a non-object %j", (value) => { it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
expect(() => Client.fromJSON(value)).toThrow( expect(() => Client.fromJSON(value)).toThrow(
/^Invalid session data: not a JSON object/, /^Invalid session data: not a JSON object/,
+46 -107
View File
@@ -1,29 +1,30 @@
// `script/build-bin` compiles `bin/quak`, the single binary that the README's // `script/build-bin` compiles `bin/quak`, the single binary that the README's
// cron setup runs. bun compiles it from a copy of its own executable, so the // cron setup runs. bun compiles it from a copy of its own executable, so the
// binary loads the same libraries as the bun that built it. nix's bun loads // binary loads through the same C library as the bun that built it. nix's bun
// libraries from the nix store, which garbage collection can delete: on Linux // loads through a C library in the nix store, which garbage collection can
// its C library, on macOS its ICU. The script therefore downloads bun's own // delete. On Linux the script therefore downloads bun's own release build,
// release build, which loads only the system's libraries. // which loads through the system's C library.
// //
// What the script promises, and why each test matters: // What the script promises, and why each test matters:
// //
// - Each system, machine and C library that bun publishes a build for gets its // - On Linux, each machine and C library that bun publishes a build for gets
// own archive: Linux on x86-64 or arm64, with glibc or with musl, as on // its own archive: x86-64 or arm64, with glibc or with musl, as on Alpine. A
// Alpine, and macOS on Intel or Apple silicon. A bun built for another // bun built for another machine or C library does not start, and
// system, machine or C library does not start, and `make install` fails. // `make install` fails.
// - The archive's SHA-256 must match the one in the script before anything in // - The archive's SHA-256 must match the one in the script before anything in
// the archive runs. Without that check, a tampered download would build the // the archive runs. Without that check, a tampered download would build the
// binary that cron runs. // binary that cron runs.
// - Any other system or machine stops with an error, and never falls back to // - Linux on any other machine stops with an error. Building there with nix's
// nix's bun. Building with nix's bun would bring back the dependency on the // bun would bring back the dependency on the nix store.
// nix store. // - Other systems, such as macOS, compile with nix's bun as before, and
// download nothing.
// //
// Each test copies the script into a fresh directory, which the script then // Each test copies the script into a fresh directory, which the script then
// treats as the checkout, and runs it there. Stubs first on PATH stand in for // treats as the checkout, and runs it there. Stubs first on PATH stand in for
// `uname`, `ldd`, `curl`, `unzip`, `nix-shell` and `shasum`, so no test // `uname`, `ldd`, `curl`, `unzip` and `nix-shell`, so no test downloads
// downloads anything or runs a real bun. // anything or runs a real bun.
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { execFileSync, spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { import {
chmodSync, chmodSync,
copyFileSync, copyFileSync,
@@ -32,7 +33,6 @@ import {
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
rmSync, rmSync,
symlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -42,10 +42,9 @@ import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
// The stubs, keyed by the command each replaces. `uname` and `ldd` describe the // The stubs, keyed by the command each replaces. `uname` and `ldd` describe the
// system that runBuildBin passes in the environment. `curl`, `unzip`, // system that runBuildBin passes in the environment. `curl`, `unzip` and
// `nix-shell` and `shasum` write the arguments they were given, one per line, // `nix-shell` write the arguments they were given, one per line, to a file
// to a file named after the command in $STUB_LOG_DIR, which also shows that // named after the command in $STUB_LOG_DIR, which also shows that they ran.
// they ran.
const stubs: Record<string, string> = { const stubs: Record<string, string> = {
uname: ` uname: `
case "$1" in case "$1" in
@@ -83,11 +82,6 @@ const stubs: Record<string, string> = {
chmod +x "$bun"`, chmod +x "$bun"`,
"nix-shell": ` "nix-shell": `
printf '%s\\n' "$@" > "$STUB_LOG_DIR/nix-shell"`, printf '%s\\n' "$@" > "$STUB_LOG_DIR/nix-shell"`,
// The script checks the hash with `shasum` where there is no `sha256sum`.
// The stub fails, as `shasum` does when the hash does not match.
shasum: `
printf '%s\\n' "$@" > "$STUB_LOG_DIR/shasum"
exit 1`,
}; };
let checkout = ""; let checkout = "";
@@ -120,21 +114,13 @@ afterEach(() => {
}); });
// Runs the script with nothing in its environment but the stubs' settings and a // Runs the script with nothing in its environment but the stubs' settings and a
// PATH. Unless the caller passes one, the PATH finds the stubs first and then // PATH that finds the stubs first.
// the host's commands. const runBuildBin = (system: string, machine: string, libc = "glibc") =>
const runBuildBin = (
system: string,
machine: string,
{
libc = "glibc",
path = `${join(checkout, "stubs")}:${process.env.PATH}`,
} = {},
) =>
spawnSync(join(checkout, "script/build-bin"), { spawnSync(join(checkout, "script/build-bin"), {
cwd: checkout, cwd: checkout,
encoding: "utf-8", encoding: "utf-8",
env: { env: {
PATH: path, PATH: `${join(checkout, "stubs")}:${process.env.PATH}`,
STUB_UNAME_S: system, STUB_UNAME_S: system,
STUB_UNAME_M: machine, STUB_UNAME_M: machine,
STUB_LIBC: libc, STUB_LIBC: libc,
@@ -142,31 +128,6 @@ const runBuildBin = (
}, },
}); });
// The real commands that the script and the stubs run.
const hostCommands = [
"basename",
"chmod",
"dirname",
"grep",
"mkdir",
"mktemp",
"rm",
];
// A Mac has no `ldd`, and not every Mac has `sha256sum`. This makes a PATH that
// finds neither: the stubs other than `ldd`, then links to hostCommands.
const makeMacPath = (): string => {
rmSync(join(checkout, "stubs", "ldd"));
mkdirSync(join(checkout, "host-commands"));
for (const command of hostCommands) {
const target = execFileSync("sh", ["-c", `command -v ${command}`], {
encoding: "utf-8",
}).trim();
symlinkSync(target, join(checkout, "host-commands", command));
}
return `${join(checkout, "stubs")}:${join(checkout, "host-commands")}`;
};
const ran = (command: string): boolean => const ran = (command: string): boolean =>
existsSync(join(checkout, "log", command)); existsSync(join(checkout, "log", command));
@@ -178,64 +139,42 @@ const argumentsOf = (command: string): string[] =>
describe("script/build-bin", () => { describe("script/build-bin", () => {
// The stub curl writes a file that is not the archive, so the hash check // The stub curl writes a file that is not the archive, so the hash check
// must stop the script before unzip runs. A script without the check would // must stop the script before unzip runs. A script without the check would
// run unzip, and the stub bun it unpacks would write bin/quak. On Linux the // run unzip, and the stub bun it unpacks would write bin/quak.
// check must use `sha256sum`, since a stock Alpine has no `shasum`.
it.each([ it.each([
["Linux", "x86_64", "glibc", "bun-linux-x64-baseline.zip"], ["x86_64", "glibc", "bun-linux-x64-baseline.zip"],
["Linux", "x86_64", "musl", "bun-linux-x64-musl-baseline.zip"], ["x86_64", "musl", "bun-linux-x64-musl-baseline.zip"],
["Linux", "aarch64", "glibc", "bun-linux-aarch64.zip"], ["aarch64", "glibc", "bun-linux-aarch64.zip"],
["Linux", "aarch64", "musl", "bun-linux-aarch64-musl.zip"], ["aarch64", "musl", "bun-linux-aarch64-musl.zip"],
])( ])(
"on %s %s with %s, downloads %s and stops when its hash does not match", "on Linux %s with %s, downloads %s and stops when its hash does not match",
(system, machine, libc, archive) => { (machine, libc, archive) => {
const result = runBuildBin(system, machine, { libc }); const result = runBuildBin("Linux", machine, libc);
expect(argumentsOf("curl")).toContain( expect(argumentsOf("curl")).toContain(
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" + "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
archive, archive,
); );
expect(ran("shasum")).toBe(false);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
expect(ran("unzip")).toBe(false); expect(ran("unzip")).toBe(false);
expect(ran("nix-shell")).toBe(false);
expect(existsSync(join(checkout, "bin/quak"))).toBe(false); expect(existsSync(join(checkout, "bin/quak"))).toBe(false);
}, },
); );
// On the PATH from makeMacPath the script finds no `sha256sum` and checks it("stops on a Linux machine that bun publishes no build for", () => {
// the hash with the stub `shasum`. That stub fails, so the script must stop const result = runBuildBin("Linux", "riscv64");
// before unzip runs. expect(result.status).not.toBe(0);
// On Apple silicon `uname -m` says arm64, where bun's archive says aarch64. expect(ran("curl")).toBe(false);
it.each([ expect(ran("nix-shell")).toBe(false);
["Darwin", "x86_64", "bun-darwin-x64-baseline.zip"], });
["Darwin", "arm64", "bun-darwin-aarch64.zip"],
])(
"on %s %s, downloads %s and stops when shasum rejects its hash",
(system, machine, archive) => {
const result = runBuildBin(system, machine, {
path: makeMacPath(),
});
expect(argumentsOf("curl")).toContain(
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
archive,
);
expect(argumentsOf("shasum")).toEqual(["-a", "256", "-c", "-"]);
expect(result.status).not.toBe(0);
expect(ran("unzip")).toBe(false);
expect(ran("nix-shell")).toBe(false);
expect(existsSync(join(checkout, "bin/quak"))).toBe(false);
},
);
it.each([ it("compiles with nix's bun on macOS", () => {
["Linux", "riscv64"], const result = runBuildBin("Darwin", "arm64");
["FreeBSD", "amd64"], expect(result.status).toBe(0);
])( expect(ran("curl")).toBe(false);
"stops on %s %s, which bun publishes no build for", expect(argumentsOf("nix-shell")).toEqual([
(system, machine) => { "-p",
const result = runBuildBin(system, machine); "bun",
expect(result.status).not.toBe(0); "--run",
expect(ran("curl")).toBe(false); "bun build dist/bin/quak.js --compile --outfile bin/quak",
expect(ran("nix-shell")).toBe(false); ]);
}, });
);
}); });