Skip to content

Commit 50fa88b

Browse files
committed
Fix unsafe backup excludes
1 parent 20f9da4 commit 50fa88b

10 files changed

Lines changed: 972 additions & 183 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
Preserve unrelated files when backup excludes contain multiple path components. Single-component patterns remain recursive, while multi-component patterns are relative to the backup directory root. A single trailing `/` is treated only as a directory marker, so `dist` and `dist/` remain recursive while `alpha/beta/` remains root-relative; trailing `/**` preserves root anchoring, so replace `dist/**` with `dist` or `**/dist` when recursive matching is intended. An explicit leading `**/` takes precedence when the normalized target is one component, so `**/dist/**` is also recursive. Patterns are rejected with `INVALID_BACKUP_CONFIG` rather than silently altered when they are empty, match the whole backup, or are match-all/globstar-only forms (`''`, `*`, `***`, `*?`, `?*`, `**`, `**/`), absolute or traversing (`/abs`, `./x`, `../x`, or embedded `.`/`..` components), contain non-canonical repeated or empty separators, are surrounded by whitespace, contain control characters, start with explicit `... ` syntax, or use recursive multi-component globstars such as `**/a/b` or `a/**/b`. Match-all forms are rejected because mksquashfs 4.5 would archive only the root, allowing restore to silently remove the target directory's contents. Gitignored filenames that cannot be represented safely are retained in the backup. Use matching SDK and container image versions because this classification depends on preserving the original pattern spelling across the SDK-to-container boundary.

‎packages/sandbox-container/src/services/backup-service.ts‎

Lines changed: 71 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { Logger } from '@repo/shared';
22
import { logCanonicalEvent, shellEscape } from '@repo/shared';
33
import {
44
BACKUP_ALLOWED_PREFIXES,
5-
normalizeBackupExcludePattern
5+
expandBackupExcludePattern,
6+
getBackupExcludePatternError
67
} from '@repo/shared/backup';
78
import { ErrorCode, Operation } from '@repo/shared/errors';
89
import type { ServiceResult } from '../core/types';
@@ -14,6 +15,8 @@ export const BACKUP_WORK_DIR = '/var/backups';
1415
const BACKUP_MOUNTS_DIR = '/var/backups/mounts';
1516
const BACKUP_UPLOAD_TIMEOUT_MS = 1_800_000;
1617
const BACKUP_UPLOAD_MAX_ATTEMPTS = 3;
18+
const GIT_SKIP_SAMPLE_SIZE = 5;
19+
const GIT_SKIP_PATH_MAX_CHARS = 200;
1720
const BACKUP_ALLOWED_COMPRESSIONS = ['gzip', 'lz4', 'zstd'] as const;
1821
type BackupCompression = (typeof BACKUP_ALLOWED_COMPRESSIONS)[number];
1922
type BackupCreateCompressionOptions = {
@@ -78,6 +81,14 @@ function isBackupCompression(value: string): value is BackupCompression {
7881
return BACKUP_ALLOWED_COMPRESSIONS.includes(value as BackupCompression);
7982
}
8083

84+
function canRepresentGitPathAsMksquashfsPattern(path: string): boolean {
85+
// biome-ignore lint/suspicious/noControlCharactersInRegex: exclude files are line-delimited
86+
const hasControlCharacters = /[\u0000-\u001f\u007f]/.test(path);
87+
return (
88+
!hasControlCharacters && path.trim() === path && !path.startsWith('... ')
89+
);
90+
}
91+
8192
interface CreateArchiveResult {
8293
sizeBytes: number;
8394
archivePath: string;
@@ -223,29 +234,20 @@ export class BackupService {
223234
? await this.resolveGitignoreExcludePatterns(dir, sessionId, opLogger)
224235
: [];
225236

226-
const normalizedExcludes: string[] = [];
237+
const userExcludePatterns: string[] = [];
227238
for (const pattern of excludes) {
228-
const normalized = BackupService.normalizeMksquashfsPattern(pattern);
229-
if (normalized === null) {
230-
opLogger.warn(
231-
'Exclude pattern reduced to empty after globstar normalization; skipping',
232-
{ original: pattern }
233-
);
234-
continue;
235-
}
236-
if (normalized !== pattern) {
237-
opLogger.warn(
238-
'Exclude pattern contained ** (globstar) which mksquashfs does not support; normalized automatically',
239-
{ original: pattern, normalized }
240-
);
239+
const error = getBackupExcludePatternError(pattern);
240+
if (error !== undefined) {
241+
errorMessage = 'Invalid backup exclude pattern';
242+
return serviceError({
243+
message: `Invalid backup exclude pattern: ${error}`,
244+
code: ErrorCode.INVALID_BACKUP_CONFIG,
245+
details: { dir, archivePath }
246+
});
241247
}
242-
normalizedExcludes.push(normalized);
243-
}
244248

245-
const userExcludePatterns = normalizedExcludes.flatMap((pattern) => [
246-
pattern,
247-
`... ${pattern}`
248-
]);
249+
userExcludePatterns.push(...expandBackupExcludePattern(pattern));
250+
}
249251

250252
const excludePatterns = [
251253
...new Set([...gitignorePatterns, ...userExcludePatterns])
@@ -399,50 +401,68 @@ export class BackupService {
399401
}
400402

401403
// Scope the query to the backup directory so Git returns ignored paths
402-
// relative to the directory mksquashfs will archive.
403-
// Use core.quotePath=false to ensure special characters (spaces, unicode)
404-
// are output literally rather than quoted, so they match archive entries.
404+
// relative to the directory mksquashfs will archive. NUL delimiters retain
405+
// valid pathname characters, and base64 carries them through line framing.
405406
const ignoredFilesResult = await this.executeInternal(
406407
sessionId,
407-
`git -C ${shellEscape(dir)} -c core.quotePath=false ls-files --others -i --exclude-standard -- .`
408+
`set -o pipefail; git -C ${shellEscape(dir)} ls-files -z --others -i --exclude-standard -- . | base64 -w0`
408409
);
409410
if (!ignoredFilesResult.success || ignoredFilesResult.data.exitCode !== 0) {
410411
opLogger.warn('Failed to resolve gitignored backup paths', { dir });
411412
return [];
412413
}
413414

414-
const relativePaths = ignoredFilesResult.data.stdout
415-
.split('\n')
416-
.map((line) => line.trim().replace(/\/+$/, ''))
417-
.filter((line) => line.length > 0)
418-
.map(BackupService.escapeMksquashfsWildcardLiteral);
419-
420-
// Include both direct relative paths and sticky "... " patterns.
421-
// mksquashfs path matching differs depending on how the source directory
422-
// is represented in the archive, so emitting both forms ensures ignored
423-
// content is excluded whether entries appear at the archive root or below
424-
// the source directory basename.
425-
const excludePatterns = relativePaths.flatMap((path) => [
426-
path,
427-
`... ${path}`
428-
]);
429-
return [...new Set(excludePatterns)];
415+
const relativePaths = Buffer.from(
416+
ignoredFilesResult.data.stdout.trim(),
417+
'base64'
418+
)
419+
.toString('utf8')
420+
.split('\0')
421+
.filter((path) => path.length > 0);
422+
423+
// The mksquashfs exclude-file parser strips leading whitespace and gives a
424+
// leading "... " special meaning. Control characters and surrounding
425+
// whitespace are also unsupported by the backup exclude contract, so those
426+
// paths remain in the backup.
427+
const representablePaths = relativePaths.filter((path) =>
428+
canRepresentGitPathAsMksquashfsPattern(path)
429+
);
430+
const skippedPaths = relativePaths.filter(
431+
(path) => !canRepresentGitPathAsMksquashfsPattern(path)
432+
);
433+
if (skippedPaths.length > 0) {
434+
opLogger.warn(
435+
'Some gitignored paths cannot be represented safely; including them in the backup',
436+
{ count: skippedPaths.length }
437+
);
438+
const sample = skippedPaths
439+
.slice(0, GIT_SKIP_SAMPLE_SIZE)
440+
.map((path) =>
441+
JSON.stringify(path.slice(0, GIT_SKIP_PATH_MAX_CHARS)).replaceAll(
442+
'\u007f',
443+
'\\u007f'
444+
)
445+
);
446+
opLogger.debug('Sample of unrepresentable gitignored backup paths', {
447+
sample,
448+
omittedCount: skippedPaths.length - sample.length
449+
});
450+
}
451+
452+
// Git has already resolved ignore rules into concrete source-relative
453+
// paths. Direct patterns retain that anchoring, including for one-component
454+
// paths produced by root-anchored gitignore rules.
455+
return [
456+
...new Set(
457+
representablePaths.map(BackupService.escapeMksquashfsWildcardLiteral)
458+
)
459+
];
430460
}
431461

432462
private static escapeMksquashfsWildcardLiteral(path: string): string {
433463
return path.replace(/\\/g, '\\\\').replace(/([*?[\]])/g, '\\$1');
434464
}
435465

436-
/**
437-
* Normalize a user-provided exclude pattern for mksquashfs compatibility.
438-
* mksquashfs uses fnmatch-style wildcards which do not support ** (globstar).
439-
* The mksquashfs "... " prefix already provides recursive directory matching,
440-
* making leading ** redundant. Returns null if the pattern reduces to empty.
441-
*/
442-
static normalizeMksquashfsPattern(pattern: string): string | null {
443-
return normalizeBackupExcludePattern(pattern);
444-
}
445-
446466
private async uploadPart(
447467
archiveFile: ReturnType<typeof Bun.file>,
448468
part: BackupUploadPart

0 commit comments

Comments
 (0)