Skip to content

Commit 478fba1

Browse files
mnenciaarmru
andauthored
feat(fileutils): remove directories matched by glob patterns in RemoveFiles (#218)
The RemoveFiles function used os.Remove for paths matched by glob patterns, which only works for files and empty directories. Extensions like vchord create non-empty directories matching pgsql_tmp* in PGDATA during recovery, and these were not being cleaned up. Use os.RemoveAll instead, which handles both files and non-empty directories correctly. Add path traversal protection to prevent patterns from resolving outside basePath, and ensure basePath itself is never removed. Closes #217 Ref: cloudnative-pg/cloudnative-pg#10398 Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Co-authored-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
1 parent 7f0ff85 commit 478fba1

2 files changed

Lines changed: 133 additions & 37 deletions

File tree

pkg/fileutils/fileutils.go

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -415,52 +415,71 @@ func GetDirectoryContent(dir string) (files []string, err error) {
415415
return files, err
416416
}
417417

418-
// RemoveFiles deletes the files and directories specified by the filePaths patterns
419-
// relative to the basePath. If a pattern ends with "/*", it implies that all the
420-
// contents of the directory (not the directory itself) matching the pattern should
421-
// be removed. If a pattern does not end with "/*", then the files matching the
422-
// pattern will be removed.
418+
// RemoveFiles deletes the files and directories specified by the filePaths
419+
// patterns relative to basePath. Patterns ending with "/*" remove the directory
420+
// contents but preserve the directory itself. Other patterns remove all matching
421+
// files and directories.
423422
//
424-
// Parameters:
425-
// - ctx: A context used for logging
426-
// - basePath: The root directory where the filePaths are applied.
427-
// - filePaths: List of relative paths or patterns to be removed.
428-
//
429-
// Returns:
430-
// - error: Any error encountered during the removal process, or nil if the operation was successful.
431-
//
432-
// Example:
433-
// basePath: "/path/to/directory"
434-
// filePaths: ["file1.txt", "subdir/*"]
435-
// This would remove "/path/to/directory/file1.txt" and the "path/to/directory/subdir" folder
423+
// Patterns that resolve outside basePath are skipped as a safety measure against
424+
// path traversal. basePath itself is never removed.
436425
func RemoveFiles(ctx context.Context, basePath string, filePaths []string) error {
437-
contextLogger := log.FromContext(ctx)
438-
439426
for _, pattern := range filePaths {
440-
if len(pattern) >= 2 && pattern[len(pattern)-2:] == "/*" {
441-
dirPath := filepath.Join(basePath, pattern[:len(pattern)-2])
442-
dirExists, err := FileExists(dirPath)
443-
if err != nil {
427+
if dirPattern, ok := strings.CutSuffix(pattern, "/*"); ok {
428+
if err := removeDirectoryPattern(ctx, basePath, dirPattern); err != nil {
444429
return err
445430
}
446-
if dirExists {
447-
contextLogger.Debug("Removing directory", "dirPath", dirPath)
448-
if err := RemoveDirectoryContent(dirPath); err != nil {
449-
return err
450-
}
451-
}
452431
continue
453432
}
454433

455-
matches, err := filepath.Glob(filepath.Join(basePath, pattern))
456-
if err != nil {
434+
if err := removeGlobMatches(ctx, basePath, pattern); err != nil {
457435
return err
458436
}
459-
for _, match := range matches {
460-
contextLogger.Debug("Removing file", "fileName", match)
461-
if err := RemoveFile(match); err != nil {
462-
return err
463-
}
437+
}
438+
return nil
439+
}
440+
441+
// removeDirectoryPattern removes the contents of the directory identified by
442+
// dirPattern relative to basePath, preserving the directory itself.
443+
func removeDirectoryPattern(ctx context.Context, basePath, dirPattern string) error {
444+
contextLogger := log.FromContext(ctx)
445+
cleanBasePath := filepath.Clean(basePath)
446+
dirPath := filepath.Join(basePath, dirPattern)
447+
448+
if dirPath != cleanBasePath && !strings.HasPrefix(dirPath, cleanBasePath+string(filepath.Separator)) {
449+
contextLogger.Warning("Skipping path outside basePath", "path", dirPath, "basePath", cleanBasePath)
450+
return nil
451+
}
452+
453+
dirExists, err := FileExists(dirPath)
454+
if err != nil {
455+
return err
456+
}
457+
if !dirExists {
458+
return nil
459+
}
460+
461+
contextLogger.Debug("Removing directory contents", "dirPath", dirPath)
462+
return RemoveDirectoryContent(dirPath)
463+
}
464+
465+
// removeGlobMatches removes all files and directories matching the glob pattern
466+
// relative to basePath. Matches outside basePath are skipped.
467+
func removeGlobMatches(ctx context.Context, basePath, pattern string) error {
468+
contextLogger := log.FromContext(ctx)
469+
basePrefix := filepath.Clean(basePath) + string(filepath.Separator)
470+
471+
matches, err := filepath.Glob(filepath.Join(basePath, pattern))
472+
if err != nil {
473+
return err
474+
}
475+
for _, match := range matches {
476+
if !strings.HasPrefix(match, basePrefix) {
477+
contextLogger.Warning("Skipping path outside basePath", "path", match, "basePath", basePath)
478+
continue
479+
}
480+
contextLogger.Debug("Removing path", "path", match)
481+
if err := os.RemoveAll(match); err != nil {
482+
return err
464483
}
465484
}
466485
return nil

pkg/fileutils/fileutils_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,73 @@ var _ = Describe("RemoveFiles", func() {
290290
_, err = os.Stat(filepath.Join(tempDir, "dir1"))
291291
Expect(err).NotTo(HaveOccurred(), "Expected dir1 to not be removed")
292292
})
293+
294+
It("removes non-empty directories matched by glob patterns", func(ctx SpecContext) {
295+
Expect(os.Mkdir(filepath.Join(tempDir, "pgsql_tmp"), 0o750)).To(Succeed())
296+
Expect(os.WriteFile(filepath.Join(tempDir, "pgsql_tmp", "tmpfile"), []byte("test"), 0o600)).To(Succeed())
297+
Expect(os.Mkdir(filepath.Join(tempDir, "pgsql_tmp_ext_sampling"), 0o750)).To(Succeed())
298+
Expect(os.WriteFile(
299+
filepath.Join(tempDir, "pgsql_tmp_ext_sampling", "data"), []byte("test"), 0o600,
300+
)).To(Succeed())
301+
Expect(os.WriteFile(filepath.Join(tempDir, "pgsql_tmp1234.0"), []byte("test"), 0o600)).To(Succeed())
302+
303+
err := RemoveFiles(ctx, tempDir, []string{"pgsql_tmp*"})
304+
Expect(err).NotTo(HaveOccurred())
305+
306+
_, err = os.Stat(filepath.Join(tempDir, "pgsql_tmp"))
307+
Expect(os.IsNotExist(err)).To(BeTrue(), "Expected pgsql_tmp directory to be removed")
308+
309+
_, err = os.Stat(filepath.Join(tempDir, "pgsql_tmp_ext_sampling"))
310+
Expect(os.IsNotExist(err)).To(BeTrue(), "Expected pgsql_tmp_ext_sampling directory to be removed")
311+
312+
_, err = os.Stat(filepath.Join(tempDir, "pgsql_tmp1234.0"))
313+
Expect(os.IsNotExist(err)).To(BeTrue(), "Expected pgsql_tmp1234.0 file to be removed")
314+
})
315+
316+
It("does not remove basePath itself when a pattern matches it", func(ctx SpecContext) {
317+
err := RemoveFiles(ctx, tempDir, []string{"", "."})
318+
Expect(err).NotTo(HaveOccurred())
319+
320+
_, err = os.Stat(tempDir)
321+
Expect(err).NotTo(HaveOccurred(), "Expected basePath to not be removed")
322+
323+
_, err = os.Stat(filepath.Join(tempDir, "file1.txt"))
324+
Expect(err).NotTo(HaveOccurred(), "Expected file1.txt to not be removed")
325+
})
326+
327+
It("removes basePath contents but not basePath itself with ./* pattern", func(ctx SpecContext) {
328+
err := RemoveFiles(ctx, tempDir, []string{"./*"})
329+
Expect(err).NotTo(HaveOccurred())
330+
331+
_, err = os.Stat(tempDir)
332+
Expect(err).NotTo(HaveOccurred(), "Expected basePath to not be removed")
333+
334+
entries, err := os.ReadDir(tempDir)
335+
Expect(err).NotTo(HaveOccurred())
336+
Expect(entries).To(BeEmpty(), "Expected basePath contents to be removed")
337+
})
338+
339+
It("does not remove paths outside basePath via traversal patterns", func(ctx SpecContext) {
340+
siblingDir := filepath.Join(filepath.Dir(tempDir), "sibling_safe")
341+
Expect(os.Mkdir(siblingDir, 0o750)).To(Succeed())
342+
Expect(os.WriteFile(filepath.Join(siblingDir, "important.txt"), []byte("keep"), 0o600)).To(Succeed())
343+
defer func() { _ = os.RemoveAll(siblingDir) }()
344+
345+
// Exercises both glob ("..","../*") and directory pattern ("../sibling_safe/*") branches
346+
err := RemoveFiles(ctx, tempDir, []string{"..", "../*", "../sibling_safe/*"})
347+
Expect(err).NotTo(HaveOccurred())
348+
349+
_, err = os.Stat(filepath.Dir(tempDir))
350+
Expect(err).NotTo(HaveOccurred(), "Expected parent directory to not be removed")
351+
352+
_, err = os.Stat(siblingDir)
353+
Expect(err).NotTo(HaveOccurred(), "Expected sibling directory to not be removed")
354+
_, err = os.Stat(filepath.Join(siblingDir, "important.txt"))
355+
Expect(err).NotTo(HaveOccurred(), "Expected sibling contents to not be removed")
356+
357+
_, err = os.Stat(filepath.Join(tempDir, "file1.txt"))
358+
Expect(err).NotTo(HaveOccurred(), "Expected basePath contents to not be removed")
359+
})
293360
})
294361

295362
var _ = Describe("RemoveRestoreExcludedFiles", func() {
@@ -315,8 +382,14 @@ var _ = Describe("RemoveRestoreExcludedFiles", func() {
315382
Expect(os.MkdirAll(dirOfTheFile, 0o750)).To(Succeed())
316383
}
317384
Expect(os.WriteFile(fullPath, []byte("test"), 0o600)).To(Succeed())
318-
319385
}
386+
387+
// Also create a pgsql_tmp directory with contents to simulate
388+
// extensions (e.g. vchord) that create directories matching pgsql_tmp*
389+
Expect(os.Mkdir(filepath.Join(tempDir, "pgsql_tmp_ext_sampling"), 0o750)).To(Succeed())
390+
Expect(os.WriteFile(
391+
filepath.Join(tempDir, "pgsql_tmp_ext_sampling", "data"), []byte("test"), 0o600,
392+
)).To(Succeed())
320393
})
321394

322395
AfterEach(func() {
@@ -336,6 +409,10 @@ var _ = Describe("RemoveRestoreExcludedFiles", func() {
336409
Expect(os.IsNotExist(err)).To(BeTrue(), "Expected file to be removed: "+fullPath)
337410
}
338411
}
412+
413+
// Verify that pgsql_tmp* directories created by extensions are also removed
414+
_, err := os.Stat(filepath.Join(tempDir, "pgsql_tmp_ext_sampling"))
415+
Expect(os.IsNotExist(err)).To(BeTrue(), "Expected pgsql_tmp_ext_sampling directory to be removed")
339416
})
340417
})
341418

0 commit comments

Comments
 (0)