Skip to content

Commit e752f47

Browse files
committed
fix(orchestrator): validate prepared archive paths before decrypt/copy
Validate the staged archive path against the manifest encryption mode in preparePlainBundleCommon before deriving the plain archive output path. This rejects inconsistent inputs where `EncryptionMode=age` does not have a `.age` archive suffix, or where a non-age archive still ends with `.age`. It also ensures decryption never runs with identical input and output paths by generating a unique output name when needed. Add regression coverage for both mode/suffix mismatch cases and the unique-path fallback for age archives.
1 parent 9fbaacd commit e752f47

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

internal/orchestrator/decrypt_prepare_common.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,56 @@ import (
1212

1313
type archiveDecryptFunc func(ctx context.Context, encryptedPath, outputPath, displayName string) error
1414

15+
func createUniquePreparedArchivePath(workDir, baseName string) (string, error) {
16+
pattern := strings.TrimSpace(baseName)
17+
if pattern == "" {
18+
pattern = "archive"
19+
}
20+
tempFile, err := restoreFS.CreateTemp(workDir, pattern+".decrypted-*")
21+
if err != nil {
22+
return "", fmt.Errorf("create archive output path: %w", err)
23+
}
24+
path := tempFile.Name()
25+
if err := tempFile.Close(); err != nil {
26+
return "", fmt.Errorf("close archive output path: %w", err)
27+
}
28+
return path, nil
29+
}
30+
31+
func resolvePreparedArchivePath(workDir, stagedArchivePath, currentEncryption string) (string, error) {
32+
archiveBase := filepath.Base(stagedArchivePath)
33+
if archiveBase == "." || archiveBase == string(filepath.Separator) || strings.TrimSpace(archiveBase) == "" {
34+
return "", fmt.Errorf("invalid staged archive path %s", stagedArchivePath)
35+
}
36+
37+
if currentEncryption == "age" {
38+
if !strings.HasSuffix(archiveBase, ".age") {
39+
return "", fmt.Errorf("encrypted archive %s is missing .age suffix", stagedArchivePath)
40+
}
41+
42+
plainArchiveName := strings.TrimSuffix(archiveBase, ".age")
43+
if strings.TrimSpace(plainArchiveName) == "" {
44+
return createUniquePreparedArchivePath(workDir, archiveBase)
45+
}
46+
47+
plainArchivePath := filepath.Join(workDir, plainArchiveName)
48+
if plainArchivePath == stagedArchivePath {
49+
return createUniquePreparedArchivePath(workDir, plainArchiveName)
50+
}
51+
return plainArchivePath, nil
52+
}
53+
54+
if strings.HasSuffix(archiveBase, ".age") {
55+
mode := currentEncryption
56+
if mode == "" {
57+
mode = "plain"
58+
}
59+
return "", fmt.Errorf("archive %s has .age suffix but encryption mode is %s", stagedArchivePath, mode)
60+
}
61+
62+
return filepath.Join(workDir, archiveBase), nil
63+
}
64+
1565
func preparePlainBundleCommon(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, decryptArchive archiveDecryptFunc) (bundle *preparedBundle, err error) {
1666
if cand == nil || cand.Manifest == nil {
1767
return nil, fmt.Errorf("invalid backup candidate")
@@ -80,8 +130,11 @@ func preparePlainBundleCommon(ctx context.Context, cand *decryptCandidate, versi
80130
currentEncryption := strings.ToLower(strings.TrimSpace(manifestCopy.EncryptionMode))
81131
logger.Info("Preparing archive %s for decryption (mode: %s)", manifestCopy.ArchivePath, statusFromManifest(&manifestCopy))
82132

83-
plainArchiveName := strings.TrimSuffix(filepath.Base(staged.ArchivePath), ".age")
84-
plainArchivePath := filepath.Join(workDir, plainArchiveName)
133+
plainArchivePath, err := resolvePreparedArchivePath(workDir, staged.ArchivePath, currentEncryption)
134+
if err != nil {
135+
cleanup()
136+
return nil, err
137+
}
85138

86139
if currentEncryption == "age" {
87140
if decryptArchive == nil {

internal/orchestrator/decrypt_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3006,6 +3006,130 @@ func TestPreparePlainBundleCommon_TrimmedAgeEncryptionTriggersDecrypt(t *testing
30063006
}
30073007
}
30083008

3009+
func TestPreparePlainBundleCommon_AgeModeRequiresAgeSuffix(t *testing.T) {
3010+
origFS := restoreFS
3011+
restoreFS = osFS{}
3012+
t.Cleanup(func() { restoreFS = origFS })
3013+
3014+
dir := t.TempDir()
3015+
workArchive := filepath.Join(dir, "backup.tar.xz")
3016+
if err := os.WriteFile(workArchive, []byte("ciphertext"), 0o600); err != nil {
3017+
t.Fatalf("write archive: %v", err)
3018+
}
3019+
manifestPath := filepath.Join(dir, "backup.metadata")
3020+
if err := os.WriteFile(manifestPath, []byte(`{"encryption_mode":"age"}`), 0o600); err != nil {
3021+
t.Fatalf("write manifest: %v", err)
3022+
}
3023+
checksumPath := filepath.Join(dir, "backup.sha256")
3024+
checksumLine := checksumLineForBytes(filepath.Base(workArchive), []byte("ciphertext"))
3025+
if err := os.WriteFile(checksumPath, []byte(checksumLine), 0o600); err != nil {
3026+
t.Fatalf("write checksum: %v", err)
3027+
}
3028+
3029+
cand := &decryptCandidate{
3030+
Manifest: &backup.Manifest{
3031+
ArchivePath: workArchive,
3032+
EncryptionMode: "age",
3033+
},
3034+
Source: sourceRaw,
3035+
RawArchivePath: workArchive,
3036+
RawMetadataPath: manifestPath,
3037+
RawChecksumPath: checksumPath,
3038+
DisplayBase: "backup.tar.xz",
3039+
}
3040+
3041+
logger := logging.New(types.LogLevelError, false)
3042+
logger.SetOutput(io.Discard)
3043+
3044+
decryptCalled := false
3045+
_, err := preparePlainBundleCommon(context.Background(), cand, "1.0.0", logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error {
3046+
decryptCalled = true
3047+
return nil
3048+
})
3049+
if err == nil {
3050+
t.Fatal("preparePlainBundleCommon error = nil; want missing .age suffix error")
3051+
}
3052+
if !strings.Contains(err.Error(), "missing .age suffix") {
3053+
t.Fatalf("preparePlainBundleCommon error = %v; want missing .age suffix error", err)
3054+
}
3055+
if decryptCalled {
3056+
t.Fatal("decrypt callback was called for archive without .age suffix")
3057+
}
3058+
}
3059+
3060+
func TestPreparePlainBundleCommon_NonAgeRejectsAgeSuffix(t *testing.T) {
3061+
origFS := restoreFS
3062+
restoreFS = osFS{}
3063+
t.Cleanup(func() { restoreFS = origFS })
3064+
3065+
dir := t.TempDir()
3066+
workArchive := filepath.Join(dir, "backup.tar.xz.age")
3067+
if err := os.WriteFile(workArchive, []byte("ciphertext"), 0o600); err != nil {
3068+
t.Fatalf("write archive: %v", err)
3069+
}
3070+
manifestPath := filepath.Join(dir, "backup.metadata")
3071+
if err := os.WriteFile(manifestPath, []byte(`{"encryption_mode":"none"}`), 0o600); err != nil {
3072+
t.Fatalf("write manifest: %v", err)
3073+
}
3074+
checksumPath := filepath.Join(dir, "backup.sha256")
3075+
checksumLine := checksumLineForBytes(filepath.Base(workArchive), []byte("ciphertext"))
3076+
if err := os.WriteFile(checksumPath, []byte(checksumLine), 0o600); err != nil {
3077+
t.Fatalf("write checksum: %v", err)
3078+
}
3079+
3080+
cand := &decryptCandidate{
3081+
Manifest: &backup.Manifest{
3082+
ArchivePath: workArchive,
3083+
EncryptionMode: "none",
3084+
},
3085+
Source: sourceRaw,
3086+
RawArchivePath: workArchive,
3087+
RawMetadataPath: manifestPath,
3088+
RawChecksumPath: checksumPath,
3089+
DisplayBase: "backup.tar.xz.age",
3090+
}
3091+
3092+
logger := logging.New(types.LogLevelError, false)
3093+
logger.SetOutput(io.Discard)
3094+
3095+
_, err := preparePlainBundleCommon(context.Background(), cand, "1.0.0", logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error {
3096+
t.Fatal("decrypt callback should not be called for non-age archive handling")
3097+
return nil
3098+
})
3099+
if err == nil {
3100+
t.Fatal("preparePlainBundleCommon error = nil; want .age suffix mismatch error")
3101+
}
3102+
if !strings.Contains(err.Error(), "has .age suffix but encryption mode is none") {
3103+
t.Fatalf("preparePlainBundleCommon error = %v; want .age suffix mismatch error", err)
3104+
}
3105+
}
3106+
3107+
func TestResolvePreparedArchivePath_AgeFallbackUsesUniqueOutput(t *testing.T) {
3108+
origFS := restoreFS
3109+
restoreFS = osFS{}
3110+
t.Cleanup(func() { restoreFS = origFS })
3111+
3112+
workDir := t.TempDir()
3113+
stagedArchivePath := filepath.Join(workDir, ".age")
3114+
3115+
got, err := resolvePreparedArchivePath(workDir, stagedArchivePath, "age")
3116+
if err != nil {
3117+
t.Fatalf("resolvePreparedArchivePath error: %v", err)
3118+
}
3119+
if got == stagedArchivePath {
3120+
t.Fatalf("resolvePreparedArchivePath() = %q; want unique output path", got)
3121+
}
3122+
if got == workDir {
3123+
t.Fatalf("resolvePreparedArchivePath() = %q; want file path inside workdir", got)
3124+
}
3125+
if filepath.Dir(got) != workDir {
3126+
t.Fatalf("resolvePreparedArchivePath() dir = %q; want %q", filepath.Dir(got), workDir)
3127+
}
3128+
if !strings.HasPrefix(filepath.Base(got), ".age.decrypted-") {
3129+
t.Fatalf("resolvePreparedArchivePath() base = %q; want .age.decrypted-*", filepath.Base(got))
3130+
}
3131+
}
3132+
30093133
// =====================================
30103134
// extractBundleToWorkdirWithLogger coverage tests
30113135
// =====================================

0 commit comments

Comments
 (0)