Compare commits

...
2 Commits
Author SHA1 Message Date
clawbot 1bd6d72876 quak backup --verify re-hashes stored originals and downloads again any that do not match (closes #168)
check / check (push) Failing after 51s
`--verify`, or `lib.backup({ verify: true })`, hashes each original already
at its save path as the download check does, streamed, a live photo as
`<imageHash>:<videoHash>`. A mismatch is logged, removed and put back in the
same run, and what is put back is hashed too, since a copy from the content
cache is not checked; one that still does not match, or a failed fetch, goes
into `failures.json`. A file with no recorded hash counts as unchecked. The
result, the summary and `--json` gain `verified`, `mismatched` and
`unchecked`.

Judgement call: a stored original that cannot be read for hashing is recorded as failed and left in place.
Judgement call: a copy put back that still does not match stays at its save path and is not counted as downloaded.
Judgement call: the summary prints the three counts only with `--verify`.

Model: opus-5-5
2026-10-06 18:32:32 +00:00
clawbot bd77422965 quak backup refuses to run while another backup of the same directory runs, exit 2 (closes #169)
check / check (push) Successful in 3m10s
lib.backup() takes a lock, backup.lock in its download directory, made
with proper-lockfile, before its refresh, and removes it when it ends. A
second backup of the directory fails at once with an error naming it.
quak backup takes the lock itself before it opens its library and passes
lockHeld to the backup, so a refused run sends no request; it prints the
error as one line and exits 2. A lock untouched for 10 seconds, left by a
run that could not remove it, is taken over.

Deviation: yarn.lock was regenerated by yarn add in the pinned node image.
Judgement call: a run failing at its refresh leaves the directory, empty.
Judgement call: a lock removed mid-run stops that run with an uncaught
error, the library's default.

Model: opus-5-5
2026-10-06 20:30:51 +02:00
12 changed files with 756 additions and 71 deletions
+54 -8
View File
@@ -524,10 +524,10 @@ stack trace. All three exit with status 3, which means the user must run
`quak login` again. `quak logout` is the exception: with no file it says there `quak login` again. `quak logout` is the exception: with no file it says there
is no session and exits 0, and it handles a corrupt file or a failed server call is no session and exits 0, and it handles a corrupt file or a failed server call
as described below. `quak backup` meets an expired session on the refresh that as described below. `quak backup` meets an expired session on the refresh that
starts every run, before it touches any file. A session that stops working starts every run, before it touches any file but its lock (see "Backup layout").
partway through a backup instead fails each remaining file into `failures.json`, A session that stops working partway through a backup instead fails each
so that run exits 1 and the next one stops at its refresh with status 3. No remaining file into `failures.json`, so that run exits 1 and the next one stops
command but `quak login` ever prompts. at its refresh with status 3. No command but `quak login` ever prompts.
`quak logout` ends the session on the server, so the token in `session.json` `quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the stops working even in a copy of the file, and then deletes the file. If the
@@ -549,7 +549,7 @@ quak collections [--json] list all collections
quak files --collection <id> [--json] list files in a collection quak files --collection <id> [--json] list files in a collection
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] full incremental backup quak backup <dir> [--json] [--verify] full incremental backup
quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON
quak helper list-missing-thumbnails [--json] find files with missing thumbnails quak helper list-missing-thumbnails [--json] find files with missing thumbnails
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails
@@ -582,6 +582,10 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
and `helper fix-missing-thumbnails` take `--json` for machine-readable output. and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace goes
into `failures.json` (see "Backup layout").
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is `mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
@@ -621,6 +625,7 @@ the smallest does not.
(symlink) (symlink)
<name>.json collection metadata + file list <name>.json collection metadata + file list
account.json the account's email and user ID account.json the account's email and user ID
backup.lock the lock a running backup holds (see below)
failures.json files that failed and have not yet succeeded failures.json files that failed and have not yet succeeded
``` ```
@@ -665,6 +670,23 @@ succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
`backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, and
removes the lock when it ends, whether it succeeds or fails; `quak backup` takes
it before it opens its library, so a refused run sends no request. The lock is a
directory that
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
and keeps touching while the backup runs. A second backup of the directory, from
another process or the same one, fails at once: `quak backup` prints
`quak: another backup of <dir> is running` and exits with status 2. A run
stopped with Ctrl-C or `kill` removes the lock as it exits. Only a run that
cannot, such as one killed with SIGKILL or cut off by a crash or power loss,
leaves it behind; once it has gone 10 seconds untouched, the next run takes it
over, so nobody has to remove it. The lock is outside the date folders and
`collections/`, so it is never taken for an original, and the removal of old
album directories never touches it.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two symlinks in one collection the same collections would get the same name, or two symlinks in one collection the same
@@ -697,6 +719,25 @@ if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so the only file content it fetches is the originals the originals precache off, so the only file content it fetches is the originals the
backup stores. backup stores.
With `--verify`, or `lib.backup({ verify: true })`, a run also hashes each
original already at its save path the way a download is checked (see "On-disk
cache layout" below): its bytes, read in chunks, or a live photo's image and
video, joined as `<imageHash>:<videoHash>`. An original that matches the content
hash its metadata records is left as it is. One that does not is logged on one
line naming the file, deleted (a live photo's image and video both), and put
back in the same run like a missing one: downloaded, or copied from the cache if
the cache holds it. What is put back is hashed too, because a copy from the
cache is not checked as a download is. If it still does not match, it stays at
its save path and the file goes into `failures.json`, as it does when the
download fails. A file whose metadata records no hash is left as it is and
counted as unchecked. A stored original that cannot be read is left as it is and
counts as failed. The summary and `--json` add the counts `verified`,
`mismatched` and `unchecked`, all of originals that were already stored; one
first downloaded in this run is in none of them. A mismatch that was put back
with matching bytes does not make the exit code non-zero. Without `--verify`
nothing is hashed, the summary is unchanged, and the three counts are 0 in
`--json`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
after a power cut. A downloaded original's temporary file is named after a power cut. A downloaded original's temporary file is named
@@ -912,7 +953,12 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere. a query vector the caller produced elsewhere.
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as - `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download
directory, and fails at once with an error whose `code` is `ELOCKED` while
another backup of it runs. A caller can instead take the lock itself, before
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns the function that releases it, and
the caller passes `lockHeld: true` to the backup. It waits for a refresh as
`fresh()` does, puts every in-scope original not already at its save path `fresh()` does, puts every in-scope original not already at its save path
there as `photo.download()` does (and, with `includeThumbnails`, fetches there as `photo.download()` does (and, with `includeThumbnails`, fetches
thumbnails) through the content cache, waits for an ML data fetch, and thumbnails) through the content cache, waits for an ML data fetch, and
@@ -922,8 +968,8 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
then counts it as present; one the cache already held is copied from there. then counts it as present; one the cache already held is copied from there.
`BackupOptions`: `downloadDirectory` (falls back to the library's), `BackupOptions`: `downloadDirectory` (falls back to the library's),
`includeOriginals` (default `true`), `includeThumbnails` (default `false`), `includeOriginals` (default `true`), `includeThumbnails` (default `false`),
`onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and `lockHeld`
writes. (default `false`). See Backup layout above for the tree it writes.
### Request pools ### Request pools
+20
View File
@@ -25,6 +25,26 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-06: `quak backup --verify` and `lib.backup({ verify: true })` hash
each original already at its save path as the download check does, streamed, a
live photo as `<imageHash>:<videoHash>` (issue 168). One that does not match
the content hash its metadata records is logged, removed (both files of a live
photo) and put back in the same run, downloaded or copied from the cache, and
what is put back is hashed too. One that still does not match, or whose
download fails, goes into `failures.json`. One with no recorded hash is left
alone. The result, `--json` and the summary gain `verified`, `mismatched` and
`unchecked`. Without `--verify` nothing is hashed.
- 2026-10-06: Two backups of the same directory never run at once (issue 169).
`lib.backup()` takes a lock, `backup.lock` in its download directory, made
with `proper-lockfile`, before its refresh, and removes it when it ends,
whether it succeeds or fails. A second backup of the directory, from another
process or the same one, fails at once with an error naming the directory.
`quak backup` takes the lock before it opens its library, so a refused run
sends no request; it prints the error as one line and exits 2. A lock that has
gone 10 seconds untouched, left by a run that could not remove it, is taken
over by the next run.
- 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into - 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into
the file's JSON as `imageMetadata`, what `backup-metadata --exif` records the file's JSON as `imageMetadata`, what `backup-metadata --exif` records
(issue 167): for a live photo from its image, for a video nothing, and `{}` (issue 167): for a live photo from its image, for a video nothing, and `{}`
+5 -1
View File
@@ -130,9 +130,13 @@ program
) )
.argument("<dir>", "Output directory") .argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary") .option("--json", "Print result as JSON instead of human-readable summary")
.option(
"--verify",
"Re-hash stored originals and download again any that do not match",
)
// A backup usually runs from cron with nobody watching, so every request // A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do. // it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean }) => .action((dir: string, opts: { json?: boolean; verify?: boolean }) =>
run( run(
backupCommand( backupCommand(
{ {
+3 -1
View File
@@ -36,6 +36,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "9.38.0", "@eslint/js": "9.38.0",
"@types/node": "22.18.13", "@types/node": "22.18.13",
"@types/proper-lockfile": "4.1.4",
"eslint": "9.38.0", "eslint": "9.38.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "5.9.3", "typescript": "5.9.3",
@@ -50,6 +51,7 @@
"fast-srp-hap": "2.0.4", "fast-srp-hap": "2.0.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"libsodium-wrappers-sumo": "0.8.4" "libsodium-wrappers-sumo": "0.8.4",
"proper-lockfile": "4.1.2"
} }
} }
+169 -18
View File
@@ -1,11 +1,13 @@
// The backup command, rebuilt on the library API (issue #51). // The backup command, rebuilt on the library API (issue #51).
// //
// `lib.backup()` waits for a completed refresh of the library (a failed one // `lib.backup()` takes the lock in `downloadDirectory` (unless its caller
// fails the backup before any file is touched), then, for every file in scope, // already holds it), and fails at once when another backup of it holds the
// puts its original at its save path under `downloadDirectory`, as // lock. It waits for a completed refresh of the library (a failed one fails the
// `Photo.download()` does, waits for an ML data fetch, and rebuilds the derived // backup before any file is touched), then, for every file in scope, puts its
// views (per-file sidecars, per-collection symlink trees, per-collection JSON) // original at its save path under `downloadDirectory`, as `Photo.download()`
// from the model. The on-disk layout: // does, waits for an ML data fetch, and rebuilds the derived views (per-file
// sidecars, per-collection symlink trees, per-collection JSON) from the model.
// The on-disk layout:
// //
// <downloadDirectory>/ // <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
@@ -17,6 +19,7 @@
// collections/<name>/<title> symlink to the original // collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// account.json the account's email and user ID // account.json the account's email and user ID
// backup.lock the lock, while a backup runs
// failures.json durable ledger of unresolved failures // failures.json durable ledger of unresolved failures
// //
// A live photo's original is its image and its video, each with its own // A live photo's original is its image and its video, each with its own
@@ -35,6 +38,11 @@
// why they could not be read), so that a run does not read every stored // why they could not be read), so that a run does not read every stored
// original again; a sidecar without them gets them read from the original. // original again; a sidecar without them gets them read from the original.
// //
// With `verify`, each original already at its save path is hashed as the
// download check hashes it, and one that does not match the content hash its
// metadata records is removed and fetched again in the same run. What is put
// back is hashed too, and recorded as failed if it still does not match.
//
// Resilience (issue #8): no per-file condition aborts the run. A failed // Resilience (issue #8): no per-file condition aborts the run. A failed
// download, a failed symlink, or ML data missing because the ML data fetch // download, a failed symlink, or ML data missing because the ML data fetch
// failed is caught, recorded in `failures.json` with a classification, a // failed is caught, recorded in `failures.json` with a classification, a
@@ -47,6 +55,7 @@
// code. // code.
import { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -60,7 +69,14 @@ import {
} from "node:fs"; } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { sanitizeFileName, withExtension } from "./filename.js"; import { sanitizeFileName, withExtension } from "./filename.js";
import { import {
@@ -88,7 +104,14 @@ export interface BackupOptions {
includeThumbnails?: boolean; includeThumbnails?: boolean;
// Restrict the backup to albums with these names; others are left untouched. // Restrict the backup to albums with these names; others are left untouched.
onlyAlbumNames?: string[]; onlyAlbumNames?: string[];
// Hash each original already at its save path, and fetch again any whose
// bytes do not match the content hash its metadata records. Default false.
verify?: boolean;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
// The caller already holds the lock in `downloadDirectory`, taken with
// `lockBackupDirectory`, and releases it itself, so the backup does not
// take it. `quak backup` takes it before it opens its library.
lockHeld?: boolean;
} }
export interface BackupError { export interface BackupError {
@@ -105,6 +128,13 @@ export interface BackupResult {
downloaded: number; downloaded: number;
// Originals already at their save path and left untouched. // Originals already at their save path and left untouched.
skipped: number; skipped: number;
// With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each removed and fetched again), and
// those whose metadata records no hash (left as they are). All three are
// zero without `verify`.
verified: number;
mismatched: number;
unchecked: number;
// Files with an unresolved failure after this run (the ledger size); the // Files with an unresolved failure after this run (the ledger size); the
// CLI exits non-zero while this is above zero. A file can be both // CLI exits non-zero while this is above zero. A file can be both
// downloaded and failed if its bytes landed but its symlink did not. // downloaded and failed if its bytes landed but its symlink did not.
@@ -361,6 +391,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
); );
}; };
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what // A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not // `extractImageMetadata` found in its original, or why the original could not
// be read. // be read.
@@ -450,21 +500,17 @@ const writeAlbumJSON = (
writeFileSync(path, JSON.stringify(album, null, 2)); writeFileSync(path, JSON.stringify(album, null, 2));
}; };
export const runBackup = async ( // The backup itself, which `runBackup` below runs while the lock is held.
const runLockedBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
downloadDirectory: string,
): Promise<BackupResult> => { ): Promise<BackupResult> => {
const downloadDirectory = opts.downloadDirectory;
if (!downloadDirectory) {
throw new Error(
"backup requires a downloadDirectory (pass one to backup() or " +
"open the library with one)",
);
}
const includeOriginals = opts.includeOriginals ?? true; const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false; const includeThumbnails = opts.includeThumbnails ?? false;
const log = opts.onProgress ?? (() => {}); const log = opts.onProgress ?? (() => {});
const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined; const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined;
const verify = opts.verify ?? false;
log("Refreshing library..."); log("Refreshing library...");
await lib.refresh(); await lib.refresh();
@@ -523,6 +569,9 @@ export const runBackup = async (
const storedThisRun = new Set<number>(); const storedThisRun = new Set<number>();
let downloaded = 0; let downloaded = 0;
let skipped = 0; let skipped = 0;
let verified = 0;
let mismatched = 0;
let unchecked = 0;
const recordFailure = ( const recordFailure = (
file: EnteFile, file: EnteFile,
@@ -554,10 +603,47 @@ export const runBackup = async (
// Phase 1: get the bytes. Put each pending original at its save path // Phase 1: get the bytes. Put each pending original at its save path
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. // the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash
// its metadata records is removed and fetched like a missing one. One
// that cannot be read is recorded as failed and left where it is.
if (includeOriginals) { if (includeOriginals) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) { let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false;
if (stored !== undefined && verify) {
try {
if (file.metadata.hash === undefined) {
unchecked++;
} else if (
(await storedHash(stored)) === file.metadata.hash
) {
verified++;
} else {
log(
`MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`,
);
mismatched++;
mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
}
} catch (err) {
log(
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
);
recordFailure(
file,
collectionName.get(file.collectionID) ?? "",
err,
);
continue;
}
}
if (stored !== undefined) {
skipped++; skipped++;
continue; continue;
} }
@@ -566,10 +652,24 @@ export const runBackup = async (
// A fetched original is written straight to its save path (a // A fetched original is written straight to its save path (a
// live photo beside it); only one that was already cached // live photo beside it); only one that was already cached
// elsewhere is copied. // elsewhere is copied.
await placeOriginal(downloadDirectory, file, (dest) => const placed = await placeOriginal(
lib.original(fileID, dest), downloadDirectory,
file,
(dest) => lib.original(fileID, dest),
); );
storedThisRun.add(fileID); storedThisRun.add(fileID);
// A copy from the cache is not checked as a download is and can
// hold the same bad bytes, so what is put back after a
// mismatch is hashed too. A bad copy stays where it is and the
// file fails.
if (
mismatch &&
(await storedHash(placed)) !== file.metadata.hash
) {
throw new Error(
"the original put back does not match its content hash either",
);
}
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
@@ -732,7 +832,58 @@ export const runBackup = async (
totalFiles: distinct.size, totalFiles: distinct.size,
downloaded, downloaded,
skipped, skipped,
verified,
mismatched,
unchecked,
failed: ledger.size, failed: ledger.size,
errors, errors,
}; };
}; };
// Only one backup of a directory runs at a time, in this process or another.
// Creates `downloadDirectory` if it is missing, takes the lock in it and
// returns the function that releases it; while another backup holds the lock,
// fails at once with an error whose `code` is `ELOCKED`. The lock is the
// directory `backup.lock`, whose modification time proper-lockfile keeps
// current while it is held. One it has not touched for 10 seconds was left by
// a run that could not remove it, and is taken over.
export const lockBackupDirectory = async (
downloadDirectory: string,
): Promise<() => Promise<void>> => {
mkdirSync(downloadDirectory, { recursive: true });
try {
return await lockfile.lock(downloadDirectory, {
lockfilePath: join(downloadDirectory, "backup.lock"),
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
throw Object.assign(
new Error(`another backup of ${downloadDirectory} is running`),
{ code: "ELOCKED" },
);
}
};
// Runs the backup holding the lock, which it takes and releases itself unless
// the caller already holds it (`opts.lockHeld`).
export const runBackup = async (
lib: BackupLibrary,
opts: BackupOptions,
): Promise<BackupResult> => {
const downloadDirectory = opts.downloadDirectory;
if (!downloadDirectory) {
throw new Error(
"backup requires a downloadDirectory (pass one to backup() or " +
"open the library with one)",
);
}
if (opts.lockHeld) {
return runLockedBackup(lib, opts, downloadDirectory);
}
const release = await lockBackupDirectory(downloadDirectory);
try {
return await runLockedBackup(lib, opts, downloadDirectory);
} finally {
await release();
}
};
+53 -31
View File
@@ -20,6 +20,7 @@ import {
type ClientSnapshot, type ClientSnapshot,
type LoginOptions, type LoginOptions,
} from "./client.js"; } from "./client.js";
import { lockBackupDirectory } from "./backup.js";
import { init } from "./crypto/index.js"; import { init } from "./crypto/index.js";
import { import {
defaultCacheDirectory, defaultCacheDirectory,
@@ -401,51 +402,72 @@ export const backupMetadataCommand = async (
export const backupCommand = async ( export const backupCommand = async (
ctx: CliContext, ctx: CliContext,
dir: string, dir: string,
opts: { json?: boolean }, opts: { json?: boolean; verify?: boolean },
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
ctx.stderr.write("Starting backup...\n"); ctx.stderr.write("Starting backup...\n");
// The precache is off: the backup fetches what it needs, and must not // The lock is taken before the library opens and starts its refresh, so a
// also fill the cache with every thumbnail and the recent originals. // run refused while another backup of `dir` runs sends no request.
const lib = await Library.open({ let release: () => Promise<void>;
client,
downloadDirectory: dir,
cacheDirectory: ctx.cacheDir,
precacheThumbnails: false,
precacheOriginals: false,
});
try { try {
const result = await lib.backup({ release = await lockBackupDirectory(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.stderr.write(`quak: ${(err as Error).message}\n`);
return 2;
}
try {
// The precache is off: the backup fetches what it needs, and must not
// also fill the cache with every thumbnail and the recent originals.
const lib = await Library.open({
client,
downloadDirectory: dir, downloadDirectory: dir,
onProgress: (msg) => { cacheDirectory: ctx.cacheDir,
if (!opts.json) ctx.stderr.write(msg + "\n"); precacheThumbnails: false,
}, precacheOriginals: false,
}); });
try {
const result = await lib.backup({
downloadDirectory: dir,
lockHeld: true,
verify: opts.verify,
onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
});
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else { } else {
ctx.stderr.write("\n--- Backup complete ---\n"); ctx.stderr.write("\n--- Backup complete ---\n");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`); ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`); ctx.stderr.write(` Skipped: ${result.skipped}\n`);
ctx.stderr.write(` Failed: ${result.failed}\n`); if (opts.verify) {
if (result.errors.length > 0) { ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.stderr.write("\nFailed files:\n"); ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
for (const e of result.errors) { ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
ctx.stderr.write( }
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, ctx.stderr.write(` Failed: ${result.failed}\n`);
); if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) {
ctx.stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
);
}
} }
} }
}
return result.failed > 0 ? 1 : 0; return result.failed > 0 ? 1 : 0;
} finally {
await lib.close();
}
} finally { } finally {
await lib.close(); await release();
} }
}; };
+1
View File
@@ -67,6 +67,7 @@ export {
type EnsureOptions, type EnsureOptions,
type EnsureResult, type EnsureResult,
type EnsureEvent, type EnsureEvent,
lockBackupDirectory,
runBackup, runBackup,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
+11 -7
View File
@@ -94,6 +94,7 @@ import type { Collection, EnteFile } from "../model/types.js";
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
export { export {
lockBackupDirectory,
runBackup, runBackup,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
@@ -566,13 +567,16 @@ export class Library {
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Waits for a completed refresh first, as `fresh()` // ledger (issue #51). Takes the lock in that directory first, unless
// does, joining one already running, and rejects before touching any file // `opts.lockHeld` says the caller holds it, failing at once while another
// when it fails. Then puts pending originals at their save paths as // backup of it runs (see `runBackup`). Waits for a
// `Photo.download()` does (and optional thumbnails) through the content // completed refresh, as `fresh()` does, joining one already running, and
// cache and pools, waits for an ML data fetch, and rebuilds the derived // rejects before touching any file but the lock when it fails. Then puts
// symlink/JSON views from the model, each file's JSON with its ML data, // pending originals at their save paths as `Photo.download()` does (and
// beside an `account.json` with the account's email and user ID. // optional thumbnails) through the content cache and pools, waits for an
// ML data fetch, and rebuilds the derived symlink/JSON views from the
// model, each file's JSON with its ML data, beside an `account.json` with
// the account's email and user ID.
// Throws before any network work when no content cache backs the // Throws before any network work when no content cache backs the
// originals it must fetch. // originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
+310 -1
View File
@@ -43,6 +43,7 @@ import {
readlinkSync, readlinkSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
@@ -61,6 +62,7 @@ import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { JPEG_WITH_EXIF } from "../exif-jpeg.js";
import { import {
asLivePhoto, asLivePhoto,
blake2b,
cdnSource, cdnSource,
IMAGE, IMAGE,
livePhotoHash, livePhotoHash,
@@ -830,7 +832,81 @@ describe("the refresh before a backup", () => {
); );
expect(source.originalCalls).toBe(0); expect(source.originalCalls).toBe(0);
expect(existsSync(outDir)).toBe(false); // The backup made the directory for its lock, and removed the lock.
expect(readdirSync(outDir)).toEqual([]);
await lib.close();
});
});
describe("the backup lock", () => {
const lockPath = (outDir: string): string => join(outDir, "backup.lock");
it("refuses a second backup of the directory while one runs", async () => {
await fillCache();
const client = new HeldClient();
const lib = await openLibrary(stubSource(), client);
const outDir = join(root, "backup");
// The first backup holds the lock once it starts its refresh, which
// `HeldClient` keeps from finishing.
let refreshing!: () => void;
const started = new Promise<void>((resolve) => {
refreshing = resolve;
});
const first = lib.backup({
downloadDirectory: outDir,
onProgress: (msg) => {
if (msg === "Refreshing library...") refreshing();
},
});
await started;
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
`another backup of ${outDir} is running`,
);
client.release();
expect((await first).failed).toBe(0);
await lib.close();
});
it("releases the lock after a backup succeeds", async () => {
const lib = await openLibrary(stubSource());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(existsSync(lockPath(outDir))).toBe(false);
// So the next backup of the directory runs.
expect((await lib.backup({ downloadDirectory: outDir })).skipped).toBe(
3,
);
await lib.close();
});
it("releases the lock after a backup fails", async () => {
const lib = await openLibrary(stubSource(), new FailingClient());
const outDir = join(root, "backup");
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
"HTTP 401 from server",
);
expect(existsSync(lockPath(outDir))).toBe(false);
await lib.close();
});
it("takes over a lock left by a run that was killed", async () => {
const outDir = join(root, "backup");
// A killed run's lock, last touched a minute ago.
mkdirSync(lockPath(outDir), { recursive: true });
const minuteAgo = new Date(Date.now() - 60_000);
utimesSync(lockPath(outDir), minuteAgo, minuteAgo);
const lib = await openLibrary(stubSource());
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.downloaded).toBe(3);
expect(existsSync(lockPath(outDir))).toBe(false);
await lib.close(); await lib.close();
}); });
}); });
@@ -1196,6 +1272,181 @@ describe("image metadata in each file's JSON", () => {
}); });
}); });
// With `verify`, each original already stored is hashed, and one that does not
// match the content hash its metadata records is downloaded again.
describe("backup with verify", () => {
// MockClient's files, each recording the hash of what `stubSource` writes
// for it, except diagram.png (200), which records none.
class HashedClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const page = await super.filesSince(args);
const files = page.files.map((f) =>
f.id === 200
? f
: {
...f,
metadata: {
...f.metadata,
hash: blake2b(Buffer.alloc(SIZE_BY_ID[f.id]!)),
},
},
);
return { ...page, files };
}
}
// A backup of the account in `root/backup`, the library that made it, and
// the source it fetched from.
const backedUp = async (): Promise<{
lib: Library;
source: StubSource;
outDir: string;
}> => {
const source = stubSource();
const lib = await openLibrary(source, new HashedClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
return { lib, source, outDir };
};
// What `stubSource` writes for beach.jpg (100), and other bytes of the
// same length.
const good = Buffer.alloc(SIZE_BY_ID[100]!);
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
const { lib, source, outDir } = await backedUp();
const calls = source.originalCalls;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
skipped: 3,
verified: 2,
mismatched: 0,
unchecked: 1,
failed: 0,
});
expect(source.originalCalls).toBe(calls);
await lib.close();
});
it("downloads again an original that does not match its hash", async () => {
const { lib, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
const log: string[] = [];
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
onProgress: (msg) => log.push(msg),
});
expect(result).toMatchObject({
downloaded: 1,
skipped: 2,
verified: 1,
mismatched: 1,
unchecked: 1,
failed: 0,
});
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
]);
await lib.close();
});
it("records a failed download in failures.json, with the original removed", async () => {
const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.failID = 100;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
mismatched: 1,
failed: 1,
});
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
await lib.close();
});
it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
const lib = await openLibrary(stubSource(), new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
mismatched: 1,
failed: 1,
});
expect(result.errors.map((e) => e.error)).toEqual([
"the original put back does not match its content hash either",
]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
await lib.close();
});
it("hashes nothing without verify", async () => {
const { lib, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({
downloaded: 0,
skipped: 3,
verified: 0,
mismatched: 0,
unchecked: 0,
failed: 0,
});
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
await lib.close();
});
// Root ignores file permissions, so this fails when run as root. The
// `test` phase of the `Dockerfile` runs as the `node` user.
it("records an original it cannot read as failed, and leaves it", async () => {
const { lib, outDir } = await backedUp();
const original = saved(outDir, "100.jpg");
chmodSync(original, 0o000);
const result = await lib
.backup({ downloadDirectory: outDir, verify: true })
.finally(() => chmodSync(original, 0o600));
expect(result).toMatchObject({ downloaded: 0, verified: 1, failed: 1 });
expect(result.errors.map((e) => e.fileID)).toEqual([100]);
expect(result.errors[0]!.error).toMatch(/EACCES/);
expect(readFileSync(original)).toEqual(good);
await lib.close();
});
});
// Every entry under collections/, one level of directories deep, with each // Every entry under collections/, one level of directories deep, with each
// symlink's target. // symlink's target.
const tree = (outDir: string): string[] => { const tree = (outDir: string): string[] => {
@@ -1691,6 +1942,64 @@ describe("backup of live photos", () => {
}, },
); );
it("verifies a live photo's image and video together, and downloads it again when one does not match", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
const good = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(good).toMatchObject({ skipped: 1, verified: 1, mismatched: 0 });
const video = saved(outDir, "500.mov");
writeFileSync(video, "another few seconds of video");
const bad = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(bad).toMatchObject({
downloaded: 1,
verified: 0,
mismatched: 1,
failed: 0,
});
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const bodies = new Map([[500, body]]);
const lib = await open([live], bodies);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
writeFileSync(saved(outDir, "500.mov"), "another few seconds of video");
bodies.delete(500);
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({ mismatched: 1, failed: 1 });
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
await lib.close();
});
it("serves a live photo the backup stored to a library reading the backup", async () => { it("serves a live photo the backup stored to a library reading the backup", async () => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"), file(500, 10, "IMG_0500.HEIC"),
+92 -3
View File
@@ -18,6 +18,7 @@ import {
readFileSync, readFileSync,
rmSync, rmSync,
statSync, statSync,
utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -52,13 +53,14 @@ import {
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
import type { ContentSource } from "../../src/library/content.js"; import { savePath, type ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
blake2b,
cdnSource, cdnSource,
IMAGE, IMAGE,
livePhotoHash, livePhotoHash,
@@ -703,6 +705,7 @@ describe("backup", () => {
" Failed: 0\n", " Failed: 0\n",
); );
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
expect(existsSync(join(dir, "backup.lock"))).toBe(false);
}); });
// The backup opens its library with the precache off: it fetches the // The backup opens its library with the precache off: it fetches the
@@ -742,6 +745,61 @@ describe("backup", () => {
expect(stderr.text).toBe("Starting backup...\n"); expect(stderr.text).toBe("Starting backup...\n");
}); });
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
// Each file records the hash of the original the fake writes for it.
const client = {
...fakeClient(),
filesSince: async (args: { collectionID: number }) => ({
files: (FILES[args.collectionID] ?? []).map((f) => ({
...f,
metadata: {
...f.metadata,
hash: blake2b(Buffer.alloc(7, f.id & 0xff)),
},
})),
deleted: [],
cursor: 1,
}),
} as unknown as Client;
const ctx = context(client);
const dir = join(root, "backup");
expect(await backupCommand(ctx, dir, {})).toBe(0);
writeFileSync(savePath(dir, FILES[1]![0]!), "corrupt");
expect(await backupCommand(ctx, dir, { verify: true })).toBe(0);
expect(stderr.text).toContain(
"MISMATCH original beach.jpg (100): its bytes do not match its content hash\n",
);
expect(stderr.text).toContain(
" Downloaded: 1\n" +
" Skipped: 2\n" +
" Verified: 2\n" +
" Mismatched: 1\n" +
" Unchecked: 0\n" +
" Failed: 0\n",
);
});
it("--verify --json adds the verified, mismatched and unchecked counts", async () => {
const dir = join(root, "backup");
expect(await backupCommand(context(), dir, {})).toBe(0);
const code = await backupCommand(context(), dir, {
verify: true,
json: true,
});
expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject({
skipped: 3,
verified: 0,
mismatched: 0,
unchecked: 3,
failed: 0,
});
});
it("exits 1 and lists each file when the ML data fetch fails", async () => { it("exits 1 and lists each file when the ML data fetch fails", async () => {
const client = { const client = {
...fakeClient(), ...fakeClient(),
@@ -792,7 +850,7 @@ describe("backup", () => {
expect(code).toBe(1); expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 503 from server\n"); expect(runText).toBe("quak: HTTP 503 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false); expect(readdirSync(dir)).toEqual([]);
}); });
// A real saved session, read back by `loadSession`, whose server answers // A real saved session, read back by `loadSession`, whose server answers
@@ -823,7 +881,38 @@ describe("backup", () => {
`quak: the saved session is no longer valid; run "quak login"\n`, `quak: the saved session is no longer valid; run "quak login"\n`,
); );
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false); expect(readdirSync(dir)).toEqual([]);
});
it("exits 2 with one line naming the directory, sending no request, while another backup of it runs", async () => {
const dir = join(root, "backup");
// The lock another backup holds. Its modification time is set an hour
// ahead, so it stays current however long this test takes.
const lock = join(dir, "backup.lock");
mkdirSync(lock, { recursive: true });
const hourAhead = new Date(Date.now() + 3_600_000);
utimesSync(lock, hourAhead, hourAhead);
// A client whose refresh never finishes, so a run that started one
// before it exits would never return.
let requests = 0;
const never = (): Promise<never> => {
requests++;
return new Promise(() => {});
};
const client = {
...fakeClient(),
collectionsSince: never,
filesSince: never,
} as unknown as Client;
expect(await backupCommand(context(client), dir, {})).toBe(2);
expect(requests).toBe(0);
expect(stderr.text).toBe(
`Starting backup...\nquak: another backup of ${dir} is running\n`,
);
expect(readdirSync(dir)).toEqual(["backup.lock"]);
// Opening the library would have made its cache directory.
expect(existsSync(join(root, "cache"))).toBe(false);
}); });
}); });
+2 -1
View File
@@ -30,7 +30,8 @@ export const livePhotoZip = (
}, },
): Uint8Array => zipSync(entries); ): Uint8Array => zipSync(entries);
const blake2b = (bytes: Uint8Array): string => // The content hash Ente's clients record for an original's bytes.
export const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64"); createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of // The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
+36
View File
@@ -535,6 +535,18 @@
dependencies: dependencies:
undici-types "~6.21.0" undici-types "~6.21.0"
"@types/proper-lockfile@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008"
integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==
dependencies:
"@types/retry" "*"
"@types/retry@*":
version "0.12.5"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e"
integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==
"@typescript-eslint/eslint-plugin@8.46.2": "@typescript-eslint/eslint-plugin@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
@@ -1140,6 +1152,11 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
graphemer@^1.4.0: graphemer@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
@@ -1414,6 +1431,15 @@ prettier@3.8.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
proper-lockfile@4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
dependencies:
graceful-fs "^4.2.4"
retry "^0.12.0"
signal-exit "^3.0.2"
punycode@^2.1.0: punycode@^2.1.0:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -1429,6 +1455,11 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
reusify@^1.0.4: reusify@^1.0.4:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
@@ -1502,6 +1533,11 @@ siginfo@^2.0.0:
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
signal-exit@^3.0.2:
version "3.0.7"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
signal-exit@^4.1.0: signal-exit@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"