List the files under a path by path, not by string prefix #250
@@ -22,6 +22,17 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-06: Made a restore path argument select only that path and
|
||||||
|
what is beneath it
|
||||||
|
([issue #223](https://git.eeqj.de/sneak/vaultik/issues/223)). The
|
||||||
|
lookup matched with SQL `LIKE`, so `/home/u/doc` also restored
|
||||||
|
`doc2`, `DOC` and `doc.txt.bak`, and a `_` or `%` in the path acted
|
||||||
|
as a wildcard. A backup used the same lookup to load the known files
|
||||||
|
of each configured path, so files of a longer sibling path were
|
||||||
|
counted as deleted. `FileRepository.ListUnderPath`, which replaces
|
||||||
|
`ListByPrefix`, returns the file at the path and every file whose
|
||||||
|
path starts with the path plus `/`, compared case-sensitively.
|
||||||
|
|
||||||
- 2026-10-06: Made restore return an error instead of panicking on a
|
- 2026-10-06: Made restore return an error instead of panicking on a
|
||||||
malformed snapshot database
|
malformed snapshot database
|
||||||
([issue #231](https://git.eeqj.de/sneak/vaultik/issues/231)). A chunk
|
([issue #231](https://git.eeqj.de/sneak/vaultik/issues/231)). A chunk
|
||||||
|
|||||||
@@ -228,19 +228,24 @@ func (r *FileRepository) DeleteByID(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByPrefix returns all files whose path starts with prefix, ordered by
|
// ListUnderPath returns the file at path and every file beneath it,
|
||||||
// path.
|
// ordered by path. Paths are compared case-sensitively, and a trailing
|
||||||
func (r *FileRepository) ListByPrefix(
|
// slash on path is ignored, so "/" lists every file.
|
||||||
ctx context.Context, prefix string,
|
func (r *FileRepository) ListUnderPath(
|
||||||
|
ctx context.Context, path string,
|
||||||
) ([]*File, error) {
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
// LIKE would ignore ASCII case and treat _ and % in path as wildcards.
|
||||||
query := `
|
query := `
|
||||||
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
||||||
FROM files
|
FROM files
|
||||||
WHERE path LIKE ? || '%'
|
WHERE path = ? OR substr(path, 1, length(?)) = ?
|
||||||
ORDER BY path
|
ORDER BY path
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
rows, err := r.db.conn.QueryContext(ctx, query, path, dirPrefix, dirPrefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying files: %w", err)
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errTestRollback is the sentinel returned from transaction bodies to
|
// errTestRollback is the sentinel returned from transaction bodies to
|
||||||
@@ -134,6 +136,82 @@ func TestFileRepositoryListDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileRepositoryListUnderPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewFileRepository(db)
|
||||||
|
|
||||||
|
const (
|
||||||
|
docDir = "/home/u/doc"
|
||||||
|
docFile = "/home/u/doc/a.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In path order, so the root case can expect all of them as listed.
|
||||||
|
paths := []string{
|
||||||
|
"/home/u/50%/x.txt",
|
||||||
|
"/home/u/50percent/y.txt",
|
||||||
|
"/home/u/DOC/c.txt",
|
||||||
|
"/home/u/a_b/x.txt",
|
||||||
|
"/home/u/axb/y.txt",
|
||||||
|
docDir,
|
||||||
|
"/home/u/doc.txt.bak",
|
||||||
|
docFile,
|
||||||
|
"/home/u/doc/sub/b.txt",
|
||||||
|
"/home/u/doc2/b.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := repo.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docTree := []string{docDir, docFile, "/home/u/doc/sub/b.txt"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"directory", docDir, docTree},
|
||||||
|
{"directory with trailing slash", docDir + "/", docTree},
|
||||||
|
{"directory differing only in case", "/home/u/DOC",
|
||||||
|
[]string{"/home/u/DOC/c.txt"}},
|
||||||
|
{"file", docFile, []string{docFile}},
|
||||||
|
{"underscore is literal", "/home/u/a_b",
|
||||||
|
[]string{"/home/u/a_b/x.txt"}},
|
||||||
|
{"percent is literal", "/home/u/50%",
|
||||||
|
[]string{"/home/u/50%/x.txt"}},
|
||||||
|
{"root", "/", paths},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
files, err := repo.ListUnderPath(ctx, tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: failed to list files: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, tt.want) {
|
||||||
|
t.Errorf("%s: listing %q got %q, want %q",
|
||||||
|
tt.name, tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ func TestTransactionIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the file was not created (transaction rolled back)
|
// Verify the file was not created (transaction rolled back)
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/tx-test")
|
files, err := repos.Files.ListUnderPath(ctx, "/tx-test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -916,7 +916,7 @@ func TestConcurrentOrphanedCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify correct files were deleted
|
// Verify correct files were deleted
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/concurrent-")
|
files, err := repos.Files.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
|
|||||||
t.Logf("Files count after cleanup: %d", count)
|
t.Logf("Files count after cleanup: %d", count)
|
||||||
|
|
||||||
// List remaining files
|
// List remaining files
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/")
|
files, err := repos.Files.ListUnderPath(ctx, "/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,12 +442,12 @@ func TestLargeDatasets(t *testing.T) {
|
|||||||
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount)
|
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test ListByPrefix performance
|
// Test ListUnderPath performance
|
||||||
//nolint:paralleltest // phases share one database and are order-dependent
|
//nolint:paralleltest // phases share one database and are order-dependent
|
||||||
t.Run("list by prefix performance", func(t *testing.T) {
|
t.Run("list under path performance", func(t *testing.T) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
files, err := repos.Files.ListUnderPath(ctx, "/large/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -472,7 +472,7 @@ func TestLargeDatasets(t *testing.T) {
|
|||||||
t.Logf("Cleaned up orphaned files in %v", time.Since(start))
|
t.Logf("Cleaned up orphaned files in %v", time.Since(start))
|
||||||
|
|
||||||
// Verify correct number remain
|
// Verify correct number remain
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
files, err := repos.Files.ListUnderPath(ctx, "/large/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func verifyBackupFiles(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "")
|
files, err := repos.Files.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to list files: %v", err)
|
t.Fatalf("Failed to list files: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,14 +456,16 @@ func (s *Scanner) finalizeScanResult(ctx context.Context, result *ScanResult) {
|
|||||||
result.EndTime = time.Now().UTC()
|
result.EndTime = time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadKnownFiles loads all known files from the database into a map for fast lookup
|
// loadKnownFiles loads the known files at and beneath path from the
|
||||||
// This avoids per-file database queries during the scan phase
|
// database into a map for fast lookup. Every loaded file the scan does
|
||||||
|
// not find is counted as deleted. This avoids per-file database queries
|
||||||
|
// during the scan phase.
|
||||||
func (s *Scanner) loadKnownFiles(
|
func (s *Scanner) loadKnownFiles(
|
||||||
ctx context.Context, path string,
|
ctx context.Context, path string,
|
||||||
) (map[string]*database.File, error) {
|
) (map[string]*database.File, error) {
|
||||||
files, err := s.repos.Files.ListByPrefix(ctx, path)
|
files, err := s.repos.Files.ListUnderPath(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing files by prefix: %w", err)
|
return nil, fmt.Errorf("listing files under %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]*database.File, len(files))
|
result := make(map[string]*database.File, len(files))
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func verifySimpleScanDatabase(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Verify files in database - includes regular files and directories
|
// Verify files in database - includes regular files and directories
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/source")
|
files, err := repos.Files.ListUnderPath(ctx, "/source")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to list files: %v", err)
|
t.Fatalf("failed to list files: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func verifyEndToEndBackupState(
|
|||||||
assert.Positive(t, blobUploads, "Should upload at least one blob")
|
assert.Positive(t, blobUploads, "Should upload at least one blob")
|
||||||
|
|
||||||
// Verify files in database
|
// Verify files in database
|
||||||
files, err := repos.Files.ListByPrefix(ctx, "/home/user")
|
files, err := repos.Files.ListUnderPath(ctx, "/home/user")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// Count only regular files (not directories)
|
// Count only regular files (not directories)
|
||||||
regularFiles := 0
|
regularFiles := 0
|
||||||
|
|||||||
@@ -830,10 +830,9 @@ func (v *Vaultik) getFilesToRestore(
|
|||||||
// Normalize the filter path
|
// Normalize the filter path
|
||||||
filter = filepath.Clean(filter)
|
filter = filepath.Clean(filter)
|
||||||
|
|
||||||
// Get files with this prefix
|
files, err := repos.Files.ListUnderPath(ctx, filter)
|
||||||
files, err := repos.Files.ListByPrefix(ctx, filter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing files with prefix %s: %w", filter, err)
|
return nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
Reference in New Issue
Block a user