fix(backup-config): throw if include patterns have un-supported characters

This commit is contained in:
Nicolas Meienberger 2026-06-01 19:15:28 +02:00
parent 00d1dac515
commit 62cdf5dcca
No known key found for this signature in database
7 changed files with 104 additions and 41 deletions

View file

@ -133,19 +133,32 @@ describe("backup path options", () => {
test("rejects unsupported characters in selected include paths", () => { test("rejects unsupported characters in selected include paths", () => {
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
for (const includePath of ["/Photos\0/etc/passwd", "/Photos\n/etc/passwd", "/Photos\r/etc/passwd"]) { expect(() => createOptions(createPathOptions({ includePaths: ["/Photos\0/etc/passwd"] }), volumePath)).toThrow(
expect(() => createOptions(createPathOptions({ includePaths: [includePath] }), volumePath)).toThrow( "Include path contains an unsupported path character",
"Include pattern contains an unsupported path character", );
); });
}
test("allows line breaks in selected include paths", () => {
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
const options = createOptions(
createPathOptions({ includePaths: ["/Photos\n2026", "/Photos\r2025"] }),
volumePath,
);
expect(options.includePaths).toEqual([
path.join(volumePath, "Photos\n2026"),
path.join(volumePath, "Photos\r2025"),
]);
}); });
test("rejects unsupported characters in include patterns", () => { test("rejects unsupported characters in include patterns", () => {
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
expect(() => for (const includePattern of ["/Photos\0/etc/passwd", "/Photos\n/etc/passwd", "/Photos\r/etc/passwd"]) {
createOptions(createPathOptions({ includePatterns: ["/Photos\n/etc/passwd"] }), volumePath), expect(() => createOptions(createPathOptions({ includePatterns: [includePattern] }), volumePath)).toThrow(
).toThrow("Include pattern contains an unsupported path character"); "Include pattern contains an unsupported path character",
);
}
}); });
test("anchors generated include patterns under the volume path", () => { test("anchors generated include patterns under the volume path", () => {

View file

@ -1,14 +1,16 @@
import path from "node:path"; import path from "node:path";
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol"; import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { hasUnsupportedPathCharacter } from "@zerobyte/core/utils"; import { hasPathListSeparator } from "@zerobyte/core/utils";
type BackupOptions = BackupRunPayload["options"]; type BackupOptions = BackupRunPayload["options"];
export const processPattern = (pattern: string, volumePath: string, relative = false) => { const validateIncludeEntry = (entry: string, name: string, format: "raw" | "text") => {
if (relative && hasUnsupportedPathCharacter(pattern)) { if (hasPathListSeparator(entry, format)) {
throw new Error(`Include pattern contains an unsupported path character: ${pattern}`); throw new Error(`${name} contains an unsupported path character: ${entry}`);
} }
};
export const processPattern = (pattern: string, volumePath: string, relative = false) => {
const isNegated = pattern.startsWith("!"); const isNegated = pattern.startsWith("!");
const p = isNegated ? pattern.slice(1) : pattern; const p = isNegated ? pattern.slice(1) : pattern;
@ -46,8 +48,16 @@ export const createBackupOptions = (
signal, signal,
exclude: params.options.excludePatterns?.map((p) => processPattern(p, volumePath)) ?? undefined, exclude: params.options.excludePatterns?.map((p) => processPattern(p, volumePath)) ?? undefined,
excludeIfPresent: params.options.excludeIfPresent ?? undefined, excludeIfPresent: params.options.excludeIfPresent ?? undefined,
includePaths: params.options.includePaths?.map((p) => processPattern(p, volumePath, true)) ?? undefined, includePaths:
includePatterns: params.options.includePatterns?.map((p) => processPattern(p, volumePath, true)) ?? undefined, params.options.includePaths?.map((p) => {
validateIncludeEntry(p, "Include path", "raw");
return processPattern(p, volumePath, true);
}) ?? undefined,
includePatterns:
params.options.includePatterns?.map((p) => {
validateIncludeEntry(p, "Include pattern", "text");
return processPattern(p, volumePath, true);
}) ?? undefined,
customResticParams: params.options.customResticParams ?? [], customResticParams: params.options.customResticParams ?? [],
compressionMode: params.options.compressionMode, compressionMode: params.options.compressionMode,
}); });

View file

@ -158,6 +158,33 @@ describe("backup command", () => {
expect(patternIncludeContent).toBe("/mnt/data/**/*.zip"); expect(patternIncludeContent).toBe("/mnt/data/**/*.zip");
}); });
test("writes raw include paths containing line breaks as single entries", async () => {
const lineBreakDir = "/mnt/data/photos\n2026";
const carriageReturnDir = "/mnt/data/photos\r2025";
let rawIncludeContent = "";
setup({
onSpawnCall: async (params) => {
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
if (rawIncludeIndex > -1) {
rawIncludeContent = await Bun.file(params.args[rawIncludeIndex + 1]!).text();
}
},
});
await runBackup(
config,
"/mnt/data",
{
organizationId: "org-1",
includePaths: [lineBreakDir, carriageReturnDir],
},
mockDeps,
);
expect(rawIncludeContent).toBe(`${lineBreakDir}\0${carriageReturnDir}\0`);
});
test("rejects unsupported characters before writing raw include files", async () => { test("rejects unsupported characters before writing raw include files", async () => {
const { getArgs } = setup(); const { getArgs } = setup();
@ -179,18 +206,24 @@ describe("backup command", () => {
test("rejects unsupported characters before writing include pattern files", async () => { test("rejects unsupported characters before writing include pattern files", async () => {
const { getArgs } = setup(); const { getArgs } = setup();
const error = await runBackupError( for (const includePattern of [
config, "/mnt/data/safe\0/etc/passwd",
"/mnt/data", "/mnt/data/safe\n/etc/passwd",
{ "/mnt/data/safe\r/etc/passwd",
organizationId: "org-1", ]) {
includePatterns: ["/mnt/data/safe\n/etc/passwd"], const error = await runBackupError(
}, config,
mockDeps, "/mnt/data",
); {
organizationId: "org-1",
includePatterns: [includePattern],
},
mockDeps,
);
expect(String(error.message)).toContain("includePatterns contains an unsupported path character"); expect(String(error.message)).toContain("includePatterns contains an unsupported path character");
expect(getArgs()).toEqual([]); expect(getArgs()).toEqual([]);
}
}); });
test("always includes DEFAULT_EXCLUDES as --exclude args", async () => { test("always includes DEFAULT_EXCLUDES as --exclude args", async () => {

View file

@ -13,16 +13,16 @@ import { validateCustomResticParams } from "../helpers/validate-custom-params";
import { createResticError, isResticError } from "../error"; import { createResticError, isResticError } from "../error";
import { logger, safeSpawn } from "../../node"; import { logger, safeSpawn } from "../../node";
import type { ResticDeps } from "../types"; import type { ResticDeps } from "../types";
import { hasUnsupportedPathCharacter, toMessage } from "../../utils"; import { hasPathListSeparator, toMessage } from "../../utils";
class ResticBackupCommandError extends Data.TaggedError("ResticBackupCommandError")<{ class ResticBackupCommandError extends Data.TaggedError("ResticBackupCommandError")<{
cause: unknown; cause: unknown;
message: string; message: string;
}> {} }> {}
const validatePatterns = (entries: string[], optionName: string) => { const validateEntries = (entries: string[], optionName: string, format: "raw" | "text") => {
for (const entry of entries) { for (const entry of entries) {
if (hasUnsupportedPathCharacter(entry)) { if (hasPathListSeparator(entry, format)) {
throw new Error(`${optionName} contains an unsupported path character: ${entry}`); throw new Error(`${optionName} contains an unsupported path character: ${entry}`);
} }
} }
@ -49,7 +49,6 @@ export const backup = (
return Effect.tryPromise({ return Effect.tryPromise({
try: async () => { try: async () => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"]; const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"];
@ -74,7 +73,7 @@ export const backup = (
(!options.includePatterns || options.includePatterns.length === 0); (!options.includePatterns || options.includePatterns.length === 0);
if (options.includePatterns?.length) { if (options.includePatterns?.length) {
validatePatterns(options.includePatterns, "includePatterns"); validateEntries(options.includePatterns, "includePatterns", "text");
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-")); const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
includeFile = path.join(tmp, "include.txt"); includeFile = path.join(tmp, "include.txt");
@ -85,7 +84,7 @@ export const backup = (
} }
if (options.includePaths?.length) { if (options.includePaths?.length) {
validatePatterns(options.includePaths, "includePaths"); validateEntries(options.includePaths, "includePaths", "raw");
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-raw-")); const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-raw-"));
rawIncludeFile = path.join(tmp, "include.raw"); rawIncludeFile = path.join(tmp, "include.raw");
@ -126,6 +125,7 @@ export const backup = (
} }
} }
const env = await buildEnv(config, options.organizationId, deps);
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
if (usesSourceArg) { if (usesSourceArg) {

View file

@ -1,7 +1,7 @@
import path from "node:path"; import path from "node:path";
import fc from "fast-check"; import fc from "fast-check";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { hasUnsupportedPathCharacter, isPathWithin, normalizeAbsolutePath } from "../path"; import { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "../path";
const safePathSegmentArb = fc const safePathSegmentArb = fc
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), { .array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), {
@ -94,11 +94,18 @@ describe("isPathWithin", () => {
}); });
}); });
describe("hasUnsupportedPathCharacter", () => { describe("path list character support", () => {
test("detects path characters that are not supported by include files", () => { test("allows line breaks in raw path lists", () => {
expect(hasUnsupportedPathCharacter("Photos")).toBe(false); expect(hasPathListSeparator("Photos", "raw")).toBe(false);
expect(hasUnsupportedPathCharacter("Photos\0Secrets")).toBe(true); expect(hasPathListSeparator("Photos\nSecrets", "raw")).toBe(false);
expect(hasUnsupportedPathCharacter("Photos\nSecrets")).toBe(true); expect(hasPathListSeparator("Photos\rSecrets", "raw")).toBe(false);
expect(hasUnsupportedPathCharacter("Photos\rSecrets")).toBe(true); expect(hasPathListSeparator("Photos\0Secrets", "raw")).toBe(true);
});
test("rejects line breaks in text path lists", () => {
expect(hasPathListSeparator("Photos", "text")).toBe(false);
expect(hasPathListSeparator("Photos\0Secrets", "text")).toBe(true);
expect(hasPathListSeparator("Photos\nSecrets", "text")).toBe(true);
expect(hasPathListSeparator("Photos\rSecrets", "text")).toBe(true);
}); });
}); });

View file

@ -1,4 +1,4 @@
export { safeJsonParse } from "./json.js"; export { safeJsonParse } from "./json.js";
export { toErrorDetails, toMessage } from "./errors.js"; export { toErrorDetails, toMessage } from "./errors.js";
export { hasUnsupportedPathCharacter, isPathWithin, normalizeAbsolutePath } from "./path.js"; export { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "./path.js";
export { findCommonAncestor } from "./common-ancestor.js"; export { findCommonAncestor } from "./common-ancestor.js";

View file

@ -58,5 +58,5 @@ export const isPathWithin = (base: string, target: string): boolean => {
); );
}; };
export const hasUnsupportedPathCharacter = (value: string) => export const hasPathListSeparator = (value: string, format: "raw" | "text") =>
value.includes("\u0000") || value.includes("\n") || value.includes("\r"); value.includes("\u0000") || (format === "text" && (value.includes("\n") || value.includes("\r")));