List the files under a path by path, not by string prefix (closes #223)
check / check (push) Successful in 12m16s

FileRepository.ListByPrefix matched with SQL LIKE: a plain string
prefix that ignores ASCII case and treats _ and % as wildcards.
Restoring /home/u/doc also restored doc2, DOC and doc.txt.bak, and a
backup counted the files of a longer sibling path as deleted. It is
now ListUnderPath, which returns the file at the path and every file
whose path starts with the path plus a slash, compared exactly. A
trailing slash is ignored, so "/" still lists every file.

Three tests relied on string-prefix matching and now name a full path
or call ListAll.

ListIDsWithChunksNotInUploadedBlobs keeps its LIKE: it only adds file
IDs the scan never looks up.

Model: opus-5-5
This commit was merged in pull request #250.
This commit is contained in:
2026-10-06 22:12:16 +02:00
parent f59086c0e5
commit 5d685f03ce
11 changed files with 118 additions and 23 deletions
+11
View File
@@ -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
+11 -6
View File
@@ -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)
} }
+78
View File
@@ -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)
} }
+1 -1
View File
@@ -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)
} }
+1 -1
View File
@@ -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)
} }
+6 -4
View File
@@ -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))
+1 -1
View File
@@ -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)
} }
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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 {