zerobyte/packages/core/src/restic/commands/ls.ts
2026-06-05 18:13:01 +02:00

172 lines
4.1 KiB
TypeScript

import { z } from "zod";
import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas";
import { logger, safeSpawn } from "../../node";
import { createResticError, isResticError } from "../error";
import type { ResticDeps } from "../types";
import { Data, Effect } from "effect";
import { toMessage } from "../../utils";
class ResticLsCommandError extends Data.TaggedError("ResticLsCommandError")<{
cause: unknown;
message: string;
}> {}
const lsNodeSchema = z.object({
name: z.string(),
type: z.string(),
path: z.string(),
uid: z.number().optional(),
gid: z.number().optional(),
size: z.number().optional(),
mode: z.number().optional(),
mtime: z.string().optional(),
atime: z.string().optional(),
ctime: z.string().optional(),
struct_type: z.literal("node"),
});
const lsSnapshotInfoSchema = z.object({
time: z.string(),
parent: z.string().optional(),
tree: z.string(),
paths: z.array(z.string()),
hostname: z.string(),
username: z.string().optional(),
id: z.string(),
short_id: z.string(),
struct_type: z.literal("snapshot"),
message_type: z.literal("snapshot"),
});
type LsNode = z.infer<typeof lsNodeSchema>;
type LsSnapshotInfo = z.infer<typeof lsSnapshotInfoSchema>;
type ResticLsResult = {
snapshot: LsSnapshotInfo | null;
nodes: LsNode[];
pagination: {
offset: number;
limit: number;
total: number;
hasMore: boolean;
};
};
export const ls = (
config: RepositoryConfig,
snapshotId: string,
path: string | undefined,
options: { organizationId: string; offset?: number; limit?: number; signal?: AbortSignal },
deps: ResticDeps,
) => {
return Effect.tryPromise({
try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const args: string[] = ["--repo", repoUrl, "ls", "--long"];
addCommonArgs(args, env, config);
args.push("--", snapshotId);
if (path) {
args.push(path);
}
let snapshot: LsSnapshotInfo | null = null;
const nodes: LsNode[] = [];
let totalNodes = 0;
let isFirstLine = true;
let hasMore = false;
const offset = Math.max(options?.offset ?? 0, 0);
const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500);
logger.debug(`Running restic ls with args: ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,
env,
signal: options.signal,
onStdout: (line) => {
const trimmedLine = line.trim();
if (!trimmedLine) {
return;
}
try {
const data = JSON.parse(trimmedLine);
if (isFirstLine) {
isFirstLine = false;
const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
if (snapshotValidation.success) {
snapshot = snapshotValidation.data;
}
return;
}
const nodeValidation = lsNodeSchema.safeParse(data);
if (!nodeValidation.success) {
logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
return;
}
if (totalNodes >= offset && totalNodes < offset + limit) {
nodes.push(nodeValidation.data);
}
totalNodes++;
if (totalNodes >= offset + limit + 1) {
hasMore = true;
}
} catch {
// Ignore JSON parse errors for non-JSON lines
}
},
});
await cleanupTemporaryKeys(env, deps);
if (options.signal?.aborted) {
logger.warn("Restic ls was aborted by signal.");
throw new Error("Operation aborted");
}
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.error}`);
throw createResticError(res.exitCode, res.stderr || res.error);
}
if (totalNodes > offset + limit) {
hasMore = true;
}
return {
snapshot,
nodes,
pagination: {
offset,
limit,
total: totalNodes,
hasMore,
},
} as ResticLsResult;
},
catch: (error) => {
if (isResticError(error)) {
return error;
}
return new ResticLsCommandError({
cause: error,
message: toMessage(error),
});
},
});
};