Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d2ea50674 | ||
|
|
ee09cda355 |
@@ -393,6 +393,20 @@ All four subcommands operate on a single SQLite database file:
|
|||||||
content BLOB NOT NULL -- raw 32-byte SHA-256; whole file under 100 MiB, else samples
|
content BLOB NOT NULL -- raw 32-byte SHA-256; whole file under 100 MiB, else samples
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
CREATE INDEX files_signature ON files (size, head, tail, content);
|
CREATE INDEX files_signature ON files (size, head, tail, content);
|
||||||
|
CREATE TABLE totals (
|
||||||
|
records INTEGER NOT NULL, -- number of records in files
|
||||||
|
size INTEGER NOT NULL -- sizes of all records in files added up
|
||||||
|
);
|
||||||
|
INSERT INTO totals (records, size) VALUES (0, 0);
|
||||||
|
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
|
||||||
|
UPDATE totals SET records = records + 1, size = size + NEW.size;
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
|
||||||
|
UPDATE totals SET records = records - 1, size = size - OLD.size;
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
|
||||||
|
UPDATE totals SET size = size + NEW.size - OLD.size;
|
||||||
|
END;
|
||||||
```
|
```
|
||||||
|
|
||||||
Paths are stored as BLOBs because Unix paths are raw bytes, not guaranteed
|
Paths are stored as BLOBs because Unix paths are raw bytes, not guaranteed
|
||||||
@@ -406,6 +420,13 @@ All four subcommands operate on a single SQLite database file:
|
|||||||
`files_signature` index lets `report` read the records in signature order
|
`files_signature` index lets `report` read the records in signature order
|
||||||
without sorting the table.
|
without sorting the table.
|
||||||
|
|
||||||
|
`totals` holds one row: the number of records in `files` and the sum of
|
||||||
|
their sizes. Its three triggers keep that row current. Each runs in the
|
||||||
|
transaction of the change to `files` that fires it, so the row and the
|
||||||
|
records always agree. `info` and the progress displays read the number of
|
||||||
|
records from this row. Counting them in `files` would read every record,
|
||||||
|
which takes minutes on a database of tens of millions.
|
||||||
|
|
||||||
### Duplicate detection
|
### Duplicate detection
|
||||||
|
|
||||||
Two files are duplicates when they have the same size and the same three hashes.
|
Two files are duplicates when they have the same size and the same three hashes.
|
||||||
@@ -455,29 +476,32 @@ scan and are enumerated concurrently: every operand seeds the shared walk worker
|
|||||||
pool. Overlapping operands are harmless — an operand that duplicates another or
|
pool. Overlapping operands are harmless — an operand that duplicates another or
|
||||||
lies under another is dropped before walking, so every file is reached exactly
|
lies under another is dropped before walking, so every file is reached exactly
|
||||||
once and produces one database record. An operand under another one is kept,
|
once and produces one database record. An operand under another one is kept,
|
||||||
though, when the walk of the outer operand does not reach it. It is then walked
|
though, and walked on its own when any directory from it (from its directory,
|
||||||
on its own. The walk of the outer operand does not reach the inner one when it,
|
for a regular file) up to but not including the operand covering it is a default
|
||||||
or any directory between the two, is:
|
exclude, is not on a persistent filesystem (see "Rules for the walk"), or, with
|
||||||
|
`-x`, is on a different filesystem than that operand, because that operand's
|
||||||
|
walk does not enter such a directory and so does not reach it.
|
||||||
|
|
||||||
- named `.zfs`;
|
An operand is not scanned when it is:
|
||||||
- a symlink;
|
|
||||||
- a default exclude;
|
|
||||||
- not on a persistent filesystem (see "Rules for the walk");
|
|
||||||
- with `-x`, on a different filesystem than the outer operand.
|
|
||||||
|
|
||||||
For a regular-file operand, only the directories between the two count.
|
- a symlink, which `scan` never follows, not even as an operand;
|
||||||
|
- a socket, FIFO or device node;
|
||||||
|
- at or inside a directory named `.zfs`.
|
||||||
|
|
||||||
An operand that is a symlink (never followed, not even as an operand), socket,
|
An operand reached through a symlink into a `.zfs` directory is not scanned
|
||||||
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
either.
|
||||||
a one-line warning naming the path and what it is, counts it as skipped, and
|
|
||||||
drops it from the scanned operands before reading the database. Another operand
|
This holds even when the operand lies under another operand. `scan` prints a
|
||||||
beneath it is still scanned. The records stored beneath it are not deleted: they
|
one-line warning naming the path and what it is, counts it as skipped, and drops
|
||||||
are treated like any other record outside the scanned operands. If it lies under
|
it from the scanned operands before reading the database. An operand beneath a
|
||||||
another operand, they are under that operand instead, and are treated like any
|
symlink operand is still scanned. The records stored beneath the dropped operand
|
||||||
other record there. This is not an error: a scan whose every operand is dropped
|
are not deleted: they are treated like any other record outside the scanned
|
||||||
walks nothing and exits 0. An operand that is not on a persistent filesystem
|
operands. If it lies under another operand, they are under that operand instead,
|
||||||
(see "Rules for the walk") is dropped the same way. The line `scan` prints for
|
and are treated like any other record there. This is not an error: a scan whose
|
||||||
it is not a warning, though, and it is not counted as skipped.
|
every operand is dropped walks nothing and exits 0. An operand that is not on a
|
||||||
|
persistent filesystem (see "Rules for the walk") is dropped the same way. The
|
||||||
|
line `scan` prints for it is not a warning, though, and it is not counted as
|
||||||
|
skipped.
|
||||||
|
|
||||||
`scan` synchronizes the database with the filesystem state under the scanned
|
`scan` synchronizes the database with the filesystem state under the scanned
|
||||||
operands:
|
operands:
|
||||||
@@ -685,14 +709,14 @@ Processing:
|
|||||||
database state.
|
database state.
|
||||||
|
|
||||||
`report` runs in two phases, each with its own progress display on stderr (see
|
`report` runs in two phases, each with its own progress display on stderr (see
|
||||||
"Progress"): `open` opens the database; `read` counts the records, then reads
|
"Progress"): `open` opens the database; `read` gets the number of records from
|
||||||
them in signature order, largest size first. That is the order of the
|
`totals`, then reads the records in signature order, largest size first. That is
|
||||||
`files_signature` index read backwards, so SQLite sorts nothing and returns the
|
the order of the `files_signature` index read backwards, so SQLite sorts nothing
|
||||||
first record at once. The records sharing a signature come together, and so do
|
and returns the first record at once. The records sharing a signature come
|
||||||
the records of one size, so `report` holds only the duplicate groups of the size
|
together, and so do the records of one size, so `report` holds only the
|
||||||
it is reading, and writes them, ordered by `first` path, once it reaches a
|
duplicate groups of the size it is reading, and writes them, ordered by `first`
|
||||||
smaller size. Its memory holds at most the duplicate paths of one size at a
|
path, once it reaches a smaller size. Its memory holds at most the duplicate
|
||||||
time, never every record.
|
paths of one size at a time, never every record.
|
||||||
|
|
||||||
#### Report output format
|
#### Report output format
|
||||||
|
|
||||||
@@ -766,10 +790,10 @@ Processing:
|
|||||||
ascending. Output must be fully deterministic for a given input.
|
ascending. Output must be fully deterministic for a given input.
|
||||||
|
|
||||||
`trees` runs in four phases, each with its own progress display on stderr (see
|
`trees` runs in four phases, each with its own progress display on stderr (see
|
||||||
"Progress"): `open` opens the database; `read` counts the records, then reads
|
"Progress"): `open` opens the database; `read` gets the number of records from
|
||||||
them in path order, building the hierarchy and computing the digests as it goes;
|
`totals`, then reads the records in path order, building the hierarchy and
|
||||||
`group` groups the directories by digest and picks the maximal groups; `write`
|
computing the digests as it goes; `group` groups the directories by digest and
|
||||||
writes the groups to stdout.
|
picks the maximal groups; `write` writes the groups to stdout.
|
||||||
|
|
||||||
#### Trees output format
|
#### Trees output format
|
||||||
|
|
||||||
@@ -820,11 +844,10 @@ size of recorded files 150000000000000 (136.4 TiB)
|
|||||||
of all records, in bytes, then in human units.
|
of all records, in bytes, then in human units.
|
||||||
|
|
||||||
`info` runs in two phases, each with its own progress display on stderr (see
|
`info` runs in two phases, each with its own progress display on stderr (see
|
||||||
"Progress"): `open` opens the database; `count` reads the schema version and
|
"Progress"): `open` opens the database; `count` reads the schema version and the
|
||||||
counts the records with one query, a single pass over the table, both in one
|
row of `totals` (see "Database") in one read-only transaction, so the numbers
|
||||||
read-only transaction, so the numbers agree with each other while a scan is
|
agree with each other while a scan is committing. `info` writes its lines once
|
||||||
committing. `info` writes its lines once the `count` display has ended, so on a
|
the `count` display has ended, so on a terminal they come after the displays.
|
||||||
terminal they come after the displays.
|
|
||||||
|
|
||||||
### Progress
|
### Progress
|
||||||
|
|
||||||
@@ -840,8 +863,9 @@ progress, its elapsed time and its time remaining.
|
|||||||
A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
|
A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
|
||||||
`[4/4] update`):
|
`[4/4] update`):
|
||||||
|
|
||||||
- `load` counts the records first, then reads them against that count. While the
|
- `load` gets the number of records from `totals` first, then reads the records
|
||||||
count runs, the display says it is counting the records.
|
against that number. Until it has the number, the display says it is counting
|
||||||
|
the records.
|
||||||
- `walk` has no exact total. Its estimate is the number of records the last scan
|
- `walk` has no exact total. Its estimate is the number of records the last scan
|
||||||
left under the operands, known once `load` is done and shown with a `~`. The
|
left under the operands, known once `load` is done and shown with a `~`. The
|
||||||
time remaining is unknown once the walk passes the estimate, and when there
|
time remaining is unknown once the walk passes the estimate, and when there
|
||||||
@@ -854,8 +878,9 @@ A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
|
|||||||
|
|
||||||
- `open` has no total. While it opens the database, waiting on a busy one
|
- `open` has no total. While it opens the database, waiting on a busy one
|
||||||
included, the display says it is opening the database.
|
included, the display says it is opening the database.
|
||||||
- `read` counts the records first, then reads them against that count. While the
|
- `read` gets the number of records from `totals` first, then reads the records
|
||||||
count runs, the display says it is counting the records.
|
against that number. Until it has the number, the display says it is counting
|
||||||
|
the records.
|
||||||
- `group` has the directories `read` found as its total, and `write` the
|
- `group` has the directories `read` found as its total, and `write` the
|
||||||
duplicate-tree groups.
|
duplicate-tree groups.
|
||||||
|
|
||||||
@@ -863,17 +888,16 @@ A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
|
|||||||
|
|
||||||
- `open` has no total. While it opens the database, waiting on a busy one
|
- `open` has no total. While it opens the database, waiting on a busy one
|
||||||
included, the display says it is opening the database.
|
included, the display says it is opening the database.
|
||||||
- `read` counts the records first, then reads them against that count, writing
|
- `read` gets the number of records from `totals` first, then reads the records
|
||||||
the rows to stdout as it goes. While the count runs, the display says it is
|
against that number, writing the rows to stdout as it goes. Until it has the
|
||||||
counting the records.
|
number, the display says it is counting the records.
|
||||||
|
|
||||||
`info` has two phases (`[1/2] open`, `[2/2] count`, see "`info` mode"):
|
`info` has two phases (`[1/2] open`, `[2/2] count`, see "`info` mode"):
|
||||||
|
|
||||||
- `open` has no total. While it opens the database, waiting on a busy one
|
- `open` has no total. While it opens the database, waiting on a busy one
|
||||||
included, the display says it is opening the database.
|
included, the display says it is opening the database.
|
||||||
- `count` has no total either, because one query counts everything: the display
|
- `count` has no total either, because it reads one row: the display says it is
|
||||||
says it is counting the records until the query returns, and its time
|
counting the records until it has the row, and its time remaining is unknown.
|
||||||
remaining is unknown.
|
|
||||||
|
|
||||||
Each display is one line holding:
|
Each display is one line holding:
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,15 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- an operand under another is walked on its own when a directory between them,
|
- the database keeps the number of records and the sum of their sizes in the one
|
||||||
the inner operand included, is named `.zfs` or is a symlink. The walk of the
|
row of a `totals` table, which three triggers on `files` keep current. `info`
|
||||||
outer operand does not reach it then. `scan` now records the files below it
|
reads that row, and so do `scan`'s `load` phase and the `read` phases of
|
||||||
and no longer deletes their records (2026-10-26,
|
`report` and `trees` for their totals; no count reads every record any more
|
||||||
|
(2026-10-27, https://git.eeqj.de/sneak/sfdupes/issues/146)
|
||||||
|
|
||||||
|
- an operand at or inside a directory named `.zfs` is never scanned, whether
|
||||||
|
given alone or under another operand. `scan` warns about it, counts it as
|
||||||
|
skipped and does not walk it (2026-10-26,
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||||
|
|
||||||
- a file or directory removed while the scan runs is not an error, in the walk
|
- a file or directory removed while the scan runs is not an error, in the walk
|
||||||
|
|||||||
@@ -69,6 +69,49 @@ const createIndexSQL = `
|
|||||||
CREATE INDEX files_signature ON files (size, head, tail, content)
|
CREATE INDEX files_signature ON files (size, head, tail, content)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// createTotalsSQL creates the totals table, which holds one row: the
|
||||||
|
// number of records and the sizes of all records added up. Counting
|
||||||
|
// them over files would read every record. The three triggers below
|
||||||
|
// keep the row current, in the transaction of each change to files.
|
||||||
|
const createTotalsSQL = `
|
||||||
|
CREATE TABLE totals (
|
||||||
|
records INTEGER NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// insertTotalsSQL adds the one row of totals, for an empty files table.
|
||||||
|
const insertTotalsSQL = `
|
||||||
|
INSERT INTO totals (records, size) VALUES (0, 0)
|
||||||
|
`
|
||||||
|
|
||||||
|
// totalsAfterInsertSQL counts each inserted record into totals.
|
||||||
|
const totalsAfterInsertSQL = `
|
||||||
|
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
|
||||||
|
UPDATE totals SET records = records + 1, size = size + NEW.size;
|
||||||
|
END
|
||||||
|
`
|
||||||
|
|
||||||
|
// totalsAfterDeleteSQL takes each deleted record out of totals.
|
||||||
|
const totalsAfterDeleteSQL = `
|
||||||
|
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
|
||||||
|
UPDATE totals SET records = records - 1, size = size - OLD.size;
|
||||||
|
END
|
||||||
|
`
|
||||||
|
|
||||||
|
// totalsAfterUpdateSQL replaces a record's old size in totals with its
|
||||||
|
// new one. scan's upsert of an existing record is such an update.
|
||||||
|
const totalsAfterUpdateSQL = `
|
||||||
|
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
|
||||||
|
UPDATE totals SET size = size + NEW.size - OLD.size;
|
||||||
|
END
|
||||||
|
`
|
||||||
|
|
||||||
|
// recordCountSQL reads the number of records from totals.
|
||||||
|
const recordCountSQL = `
|
||||||
|
SELECT records FROM totals
|
||||||
|
`
|
||||||
|
|
||||||
// upsertSQL inserts one file record, replacing any existing record for
|
// upsertSQL inserts one file record, replacing any existing record for
|
||||||
// the same path.
|
// the same path.
|
||||||
const upsertSQL = `
|
const upsertSQL = `
|
||||||
@@ -426,14 +469,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
|
|||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, createTableSQL)
|
for _, stmt := range []string{
|
||||||
if err != nil {
|
createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
|
||||||
return fmt.Errorf("create schema: %w", err)
|
totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
|
||||||
}
|
} {
|
||||||
|
_, err = tx.ExecContext(ctx, stmt)
|
||||||
_, err = tx.ExecContext(ctx, createIndexSQL)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("create schema: %w", err)
|
||||||
return fmt.Errorf("create schema: %w", err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
_, err = tx.ExecContext(ctx,
|
||||||
@@ -480,7 +523,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
|||||||
|
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("count records: %w", err)
|
return fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -556,7 +599,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
|
|||||||
|
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("count records: %w", err)
|
return fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -622,7 +665,7 @@ func (r *scanRec) setHashes(head, tail, content []byte) error {
|
|||||||
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count records: %w", err)
|
return 0, fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -630,12 +673,10 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseCountsSQL counts the records and adds up their sizes, in one
|
// databaseCountsSQL reads the number of records and the sizes of all
|
||||||
// pass over the table. A SUM over no rows is NULL, so on an empty table
|
// records added up from totals.
|
||||||
// the SUM falls back to 0.
|
|
||||||
const databaseCountsSQL = `
|
const databaseCountsSQL = `
|
||||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
SELECT records, size FROM totals
|
||||||
FROM files
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// databaseCounts is what info reads from the database: its schema
|
// databaseCounts is what info reads from the database: its schema
|
||||||
|
|||||||
+98
-10
@@ -535,17 +535,12 @@ func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
|
// queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY
|
||||||
// from the files_signature index alone, with nothing to sort: the
|
// PLAN for query.
|
||||||
// first record comes at once, so report's read phase counts each
|
func queryPlan(t *testing.T, db *sql.DB, query string) []string {
|
||||||
// record as it comes rather than waiting on SQLite for all of them.
|
t.Helper()
|
||||||
func TestSignatureRowsNeedNoSort(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := openTestDB(t)
|
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
|
||||||
|
|
||||||
rows, err := db.QueryContext(t.Context(),
|
|
||||||
"EXPLAIN QUERY PLAN "+signatureRowsSQL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -573,12 +568,105 @@ func TestSignatureRowsNeedNoSort(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
|
||||||
|
// from the files_signature index alone, with nothing to sort: the
|
||||||
|
// first record comes at once, so report's read phase counts each
|
||||||
|
// record as it comes rather than waiting on SQLite for all of them.
|
||||||
|
func TestSignatureRowsNeedNoSort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
plan := queryPlan(t, db, signatureRowsSQL)
|
||||||
|
|
||||||
want := []string{"SCAN files USING COVERING INDEX files_signature"}
|
want := []string{"SCAN files USING COVERING INDEX files_signature"}
|
||||||
if !slices.Equal(plan, want) {
|
if !slices.Equal(plan, want) {
|
||||||
t.Errorf("query plan = %q, want %q", plan, want)
|
t.Errorf("query plan = %q, want %q", plan, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCountsReadOnlyTotals checks that SQLite answers each query that
|
||||||
|
// counts the records from the one row of totals. A plan that reads
|
||||||
|
// files or its index reads every record.
|
||||||
|
func TestCountsReadOnlyTotals(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
for _, query := range []string{recordCountSQL, databaseCountsSQL} {
|
||||||
|
plan := queryPlan(t, db, query)
|
||||||
|
|
||||||
|
want := []string{"SCAN totals"}
|
||||||
|
if !slices.Equal(plan, want) {
|
||||||
|
t.Errorf("query plan of %q = %q, want %q", query, plan, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertTotalsMatchFiles fails unless the row of totals holds the
|
||||||
|
// number of records and the sizes of all records added up, as counted
|
||||||
|
// over files.
|
||||||
|
func assertTotalsMatchFiles(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, err := readDatabaseCounts(t.Context(), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var records, size int64
|
||||||
|
|
||||||
|
err = db.QueryRowContext(t.Context(),
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(size), 0) FROM files").
|
||||||
|
Scan(&records, &size)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.records != records || got.size != size {
|
||||||
|
t.Errorf("totals = %d records of %d bytes, files holds %d of %d",
|
||||||
|
got.records, got.size, records, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTotalsMatchFiles scans the smoke tree, rescans it after two files
|
||||||
|
// change size, and rescans it after two files are removed. After each
|
||||||
|
// scan the row of totals must agree with files.
|
||||||
|
func TestTotalsMatchFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := buildSmokeTree(t)
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
syncTree(t, db, dir)
|
||||||
|
assertTotalsMatchFiles(t, db)
|
||||||
|
|
||||||
|
writeFile(t, dir, "a/one.bin", pattern(10, 2500))
|
||||||
|
writeFile(t, dir, "t1/f1", pattern(30, 1000))
|
||||||
|
|
||||||
|
if st := syncTree(t, db, dir); st.updated != 2 {
|
||||||
|
t.Fatalf("rescan stats = %+v, want 2 updated", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTotalsMatchFiles(t, db)
|
||||||
|
|
||||||
|
for _, name := range []string{"a/unique.bin", "t2/sub/f2"} {
|
||||||
|
err := os.Remove(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if st := syncTree(t, db, dir); st.removed != 2 {
|
||||||
|
t.Fatalf("rescan stats = %+v, want 2 removed", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTotalsMatchFiles(t, db)
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyChangesRoundTrip(t *testing.T) {
|
func TestApplyChangesRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ func infoPhase(name string) string {
|
|||||||
// it. Any database problem, including a missing database, is fatal.
|
// it. Any database problem, including a missing database, is fatal.
|
||||||
//
|
//
|
||||||
// The work runs in two phases, each under a progress display of its
|
// The work runs in two phases, each under a progress display of its
|
||||||
// own on stderr: open (open the database) and count (count the records,
|
// own on stderr: open (open the database) and count (read the number of
|
||||||
// with one query). Each display ends before the next phase starts, and
|
// records and their total size from the totals table). Each display
|
||||||
// before an error is returned to be printed. The details are written
|
// ends before the next phase starts, and before an error is returned
|
||||||
// once the last display has ended, so no display is drawn while they
|
// to be printed. The details are written once the last display has
|
||||||
// are written.
|
// ended, so no display is drawn while they are written.
|
||||||
func runInfo(ctx context.Context, stdout io.Writer) error {
|
func runInfo(ctx context.Context, stdout io.Writer) error {
|
||||||
dbPath, source, err := databasePath()
|
dbPath, source, err := databasePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,7 +56,7 @@ func runInfo(ctx context.Context, stdout io.Writer) error {
|
|||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
// One query counts everything, so the phase never has a total.
|
// One row holds both counts, so the phase never has a total.
|
||||||
prog = newProgress(progressParams{
|
prog = newProgress(progressParams{
|
||||||
label: infoPhase("count"), total: -1,
|
label: infoPhase("count"), total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords,
|
||||||
|
|||||||
+128
-15
@@ -180,9 +180,9 @@ func capture(t *testing.T, std **os.File) func() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// brokenDatabase writes a database that opens cleanly and passes the
|
// brokenDatabase writes a database that opens cleanly and passes the
|
||||||
// schema-version check but has no files table, so the first query
|
// schema-version check but has no tables, so the first query fails
|
||||||
// fails with the database already open: a fatal error on a path that
|
// with the database already open: a fatal error on a path that owns an
|
||||||
// owns an open database. It closes the database the way scan does.
|
// open database. It closes the database the way scan does.
|
||||||
func brokenDatabase(t *testing.T) string {
|
func brokenDatabase(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -265,8 +265,9 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
|
|||||||
assertFatalOutput(t, stderr.String(), stdout())
|
assertFatalOutput(t, stderr.String(), stdout())
|
||||||
|
|
||||||
// Proof that the failure happened after the open: only a
|
// Proof that the failure happened after the open: only a
|
||||||
// query against the opened database can report this.
|
// query against the opened database can report this. Each
|
||||||
if !strings.Contains(stderr.String(), "no such table: files") {
|
// subcommand's first query reads totals.
|
||||||
|
if !strings.Contains(stderr.String(), "no such table: totals") {
|
||||||
t.Errorf("stderr = %q, want the failure to come from a "+
|
t.Errorf("stderr = %q, want the failure to come from a "+
|
||||||
"query on the open database", stderr.String())
|
"query on the open database", stderr.String())
|
||||||
}
|
}
|
||||||
@@ -928,24 +929,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
||||||
path := testDBPath(t)
|
|
||||||
t.Setenv(databaseEnv, path)
|
|
||||||
|
|
||||||
zfs := filepath.Join(t.TempDir(), ".zfs")
|
zfs := filepath.Join(t.TempDir(), ".zfs")
|
||||||
snapshot := filepath.Join(zfs, "snapshot", "hourly")
|
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
|
||||||
f := writeFile(t, snapshot, "f", pattern(1, 10))
|
|
||||||
|
|
||||||
// An operand beneath a .zfs directory is walked, because it is not
|
// No scan records a file inside a .zfs directory, so the record
|
||||||
// itself named .zfs.
|
// beneath the operand is written straight into the database.
|
||||||
scanOK(t, snapshot)
|
path := seedDatabase(t, []scanRec{{size: 10, path: f}})
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertOperandSkipped scans operand alone and checks that it is skipped
|
// assertOperandSkipped scans operand alone and checks that it is skipped
|
||||||
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
||||||
// record for kept, which an earlier scan stored beneath operand, still
|
// record for kept, stored beneath operand before this scan, still in the
|
||||||
// in the database at dbPath.
|
// database at dbPath.
|
||||||
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||||
kept string,
|
kept string,
|
||||||
) {
|
) {
|
||||||
@@ -974,6 +972,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
|||||||
recordByPath(t, dbRecords(t, db), kept)
|
recordByPath(t, dbRecords(t, db), kept)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
|
||||||
|
// directory, first alone and then under another operand. Each time it
|
||||||
|
// is warned about and counted as skipped, and nothing in it is walked.
|
||||||
|
// The other operand is scanned as usual.
|
||||||
|
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
|
||||||
|
writeFile(t, hourly, "f", pattern(1, 10))
|
||||||
|
f := writeFile(t, dir, "f", pattern(2, 10))
|
||||||
|
|
||||||
|
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
operands []string
|
||||||
|
summary string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
[]string{hourly},
|
||||||
|
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[]string{dir, hourly},
|
||||||
|
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
stderr := scanOK(t, tc.operands...)
|
||||||
|
if !strings.Contains(stderr, warning) ||
|
||||||
|
!strings.Contains(stderr, tc.summary) {
|
||||||
|
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
|
||||||
|
stderr, warning, tc.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(path, reportParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
|
||||||
|
t.Errorf("records = %q, want %q", got, []string{f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
|
||||||
|
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
|
||||||
|
// by the path given, counted as skipped, and not walked.
|
||||||
|
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
|
||||||
|
t.Setenv(databaseEnv, testDBPath(t))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
||||||
|
|
||||||
|
snaps := filepath.Join(dir, "snaps")
|
||||||
|
|
||||||
|
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := filepath.Join(snaps, "daily")
|
||||||
|
stderr := scanOK(t, daily)
|
||||||
|
|
||||||
|
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
|
||||||
|
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n"
|
||||||
|
|
||||||
|
if !strings.Contains(stderr, warning) ||
|
||||||
|
!strings.Contains(stderr, summary) {
|
||||||
|
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
|
||||||
|
// a symlink to a directory outside any .zfs directory. The operand is
|
||||||
|
// scanned as usual, and its file is recorded by the path given.
|
||||||
|
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
|
||||||
|
|
||||||
|
data := filepath.Join(dir, "data")
|
||||||
|
|
||||||
|
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := filepath.Join(data, "daily")
|
||||||
|
stderr := scanOK(t, daily)
|
||||||
|
|
||||||
|
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 0 skipped\n"
|
||||||
|
if !strings.Contains(stderr, summary) {
|
||||||
|
t.Errorf("stderr = %q, want %q", stderr, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(path, reportParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunReportSucceeds(t *testing.T) {
|
func TestRunReportSucceeds(t *testing.T) {
|
||||||
path := testDBPath(t)
|
path := testDBPath(t)
|
||||||
t.Setenv(databaseEnv, path)
|
t.Setenv(databaseEnv, path)
|
||||||
|
|||||||
@@ -48,11 +48,6 @@ const sampleWindow = 1024 * 1024
|
|||||||
// and hash worker pools.
|
// and hash worker pools.
|
||||||
const workQueueDepth = 1024
|
const workQueueDepth = 1024
|
||||||
|
|
||||||
// zfsDirName is the name of the directory in which ZFS shows a
|
|
||||||
// filesystem's snapshots. The walk never enters one, since it would
|
|
||||||
// list every file once per snapshot.
|
|
||||||
const zfsDirName = ".zfs"
|
|
||||||
|
|
||||||
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
|
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
|
||||||
// has already printed its line, so run prints nothing more.
|
// has already printed its line, so run prints nothing more.
|
||||||
var errInterrupted = errors.New("scan interrupted")
|
var errInterrupted = errors.New("scan interrupted")
|
||||||
@@ -277,10 +272,9 @@ func pruneRoots(roots []string, oneFS bool, rules walkRules,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkReaches reports whether the walk of operand above reaches r, an
|
// walkReaches reports whether the walk of operand above reaches r, an
|
||||||
// operand under it. That walk never follows a symlink. It does not
|
// operand under it. That walk does not enter a default exclude in
|
||||||
// enter a directory named .zfs, nor a default exclude in rules, nor a
|
// rules, nor a directory that device puts on a filesystem in rules that
|
||||||
// directory that device puts on a filesystem in rules that is not
|
// is not persistent, nor, with -x, one that device puts on a different
|
||||||
// persistent, nor, with -x, one that device puts on a different
|
|
||||||
// filesystem than above, when device knows above's. It reaches every
|
// filesystem than above, when device knows above's. It reaches every
|
||||||
// regular file in a directory it enters, so for a regular-file operand
|
// regular file in a directory it enters, so for a regular-file operand
|
||||||
// the directories checked start at its directory; they end below above.
|
// the directories checked start at its directory; they end below above.
|
||||||
@@ -301,15 +295,6 @@ func walkReaches(above, r string, oneFS bool, rules walkRules,
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if filepath.Base(dir) == zfsDirName {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err = os.Lstat(dir)
|
|
||||||
if err == nil && fi.Mode()&fs.ModeSymlink != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
dev, ok := device(dir)
|
dev, ok := device(dir)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -398,7 +383,8 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
) error {
|
) error {
|
||||||
// Types and filesystems are checked before pruning so that an
|
// Types and filesystems are checked before pruning so that an
|
||||||
// operand under a dropped one is still scanned, not dropped as lying
|
// operand under a dropped one is still scanned, not dropped as lying
|
||||||
// under it.
|
// under it, and so that an operand inside a .zfs directory gets its
|
||||||
|
// warning even when it lies under another operand.
|
||||||
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
||||||
deviceOfPath)
|
deviceOfPath)
|
||||||
|
|
||||||
@@ -426,15 +412,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkableRoots returns the operands the walk can start from: regular
|
// walkableRoots returns the operands the walk can start from: regular
|
||||||
// files, and directories not named .zfs. Every other operand is warned
|
// files, and directories not named .zfs, none of them inside a
|
||||||
// about, counted as skipped, and dropped. A dropped operand is no
|
// directory named .zfs. Every other operand is warned about, counted as
|
||||||
// longer a root, so the records stored beneath it are treated as if it
|
// skipped, and dropped. A dropped operand is no longer a root, so the
|
||||||
// had not been given: those under no other root count as outside the
|
// records stored beneath it are treated as if it had not been given:
|
||||||
// roots and are not deleted as unverified. An operand on a filesystem
|
// those under no other root count as outside the roots and are not
|
||||||
// in rules that is not persistent is dropped the same way, with a line
|
// deleted as unverified. An operand on a filesystem in rules that is not
|
||||||
// saying so that is not a warning, and is not counted as skipped. An
|
// persistent is dropped the same way, with a line saying so that is not
|
||||||
// operand that fails lstat here is kept, and the walk warns about it
|
// a warning, and is not counted as skipped. An operand that fails lstat
|
||||||
// unless it no longer exists.
|
// here is kept, and the walk warns about it unless it no longer exists.
|
||||||
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||||
kept := make([]string, 0, len(roots))
|
kept := make([]string, 0, len(roots))
|
||||||
|
|
||||||
@@ -469,8 +455,8 @@ func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
|||||||
// included, so its display counts against the number of records.
|
// included, so its display counts against the number of records.
|
||||||
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||||
// Indexing tens of millions of records takes real time; without a
|
// Indexing tens of millions of records takes real time; without a
|
||||||
// display the scan looks hung before the walk begins. Counting them
|
// display the scan looks hung before the walk begins. The number of
|
||||||
// first gives the display its total.
|
// records, read first, gives the display its total.
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("load"), unit: scanUnit, total: -1,
|
label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||||
waitingFor: countingRecords, stalledOn: "db",
|
waitingFor: countingRecords, stalledOn: "db",
|
||||||
@@ -889,16 +875,31 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
|
|||||||
|
|
||||||
// operandWarning returns the one-line warning for an operand the walk
|
// operandWarning returns the one-line warning for an operand the walk
|
||||||
// does not start from, naming the path and what it is, or "" for one it
|
// does not start from, naming the path and what it is, or "" for one it
|
||||||
// does: a regular file, or a directory not named .zfs. Symlinks are
|
// does: a regular file, or a directory not named .zfs, that is not
|
||||||
// never followed, including as operands.
|
// inside a directory named .zfs, neither as given nor with its symlinks
|
||||||
|
// resolved. Symlinks are never followed, including as operands.
|
||||||
func operandWarning(root string, fi fs.FileInfo) string {
|
func operandWarning(root string, fi fs.FileInfo) string {
|
||||||
|
// The walk never enters a .zfs directory, so it starts from nothing
|
||||||
|
// inside one either, not even through a symlink. An operand whose
|
||||||
|
// symlinks cannot be resolved is checked as given.
|
||||||
|
resolved, err := filepath.EvalSymlinks(root)
|
||||||
|
if err != nil {
|
||||||
|
resolved = root
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
|
||||||
|
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
|
||||||
|
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
|
||||||
|
root)
|
||||||
|
}
|
||||||
|
|
||||||
var kind string
|
var kind string
|
||||||
|
|
||||||
switch mode := fi.Mode(); {
|
switch mode := fi.Mode(); {
|
||||||
case mode.IsRegular():
|
case mode.IsRegular():
|
||||||
return ""
|
return ""
|
||||||
case mode.IsDir():
|
case mode.IsDir():
|
||||||
if filepath.Base(root) != zfsDirName {
|
if filepath.Base(root) != ".zfs" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,7 +1169,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
parent dirJob, oneFS bool, rules walkRules,
|
parent dirJob, oneFS bool, rules walkRules,
|
||||||
events chan<- walkEvent,
|
events chan<- walkEvent,
|
||||||
) (dirJob, bool) {
|
) (dirJob, bool) {
|
||||||
if e.Name() == zfsDirName {
|
if e.Name() == ".zfs" {
|
||||||
return dirJob{}, false
|
return dirJob{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1672,81 +1672,6 @@ func TestSyncScanOverlappingRoots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScanWalksOperandInZFSDirectory checks that an operand inside a
|
|
||||||
// directory named .zfs below another operand is walked on its own,
|
|
||||||
// since the walk of the other operand does not enter .zfs. The file
|
|
||||||
// under it is recorded, and a second scan keeps the record.
|
|
||||||
func TestScanWalksOperandInZFSDirectory(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
f := writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
|
||||||
daily := filepath.Dir(f)
|
|
||||||
|
|
||||||
syncTree(t, db, dir, daily)
|
|
||||||
recordByPath(t, dbRecords(t, db), f)
|
|
||||||
|
|
||||||
syncTree(t, db, dir, daily)
|
|
||||||
recordByPath(t, dbRecords(t, db), f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScanWalksOperandBehindSymlink checks that an operand reached
|
|
||||||
// through a symlinked directory below another operand is walked on its
|
|
||||||
// own, since the walk of the other operand does not follow the symlink.
|
|
||||||
// The file under it is recorded, and a second scan keeps the record.
|
|
||||||
func TestScanWalksOperandBehindSymlink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
target := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
writeFile(t, target, "sub/f", pattern(1, 10))
|
|
||||||
|
|
||||||
link := filepath.Join(dir, "link")
|
|
||||||
|
|
||||||
err := os.Symlink(target, link)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := filepath.Join(link, "sub")
|
|
||||||
f := filepath.Join(sub, "f")
|
|
||||||
|
|
||||||
syncTree(t, db, dir, sub)
|
|
||||||
recordByPath(t, dbRecords(t, db), f)
|
|
||||||
|
|
||||||
syncTree(t, db, dir, sub)
|
|
||||||
recordByPath(t, dbRecords(t, db), f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScanDropsOperandReachedBehindSymlink checks that an operand under
|
|
||||||
// another one is still dropped when both lie behind the same symlinked
|
|
||||||
// directory: the walk of the other operand starts past the symlink and
|
|
||||||
// reaches it, so the file under it is walked once.
|
|
||||||
func TestScanDropsOperandReachedBehindSymlink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
target := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
writeFile(t, target, "sub/deeper/f", pattern(1, 10))
|
|
||||||
|
|
||||||
link := filepath.Join(dir, "link")
|
|
||||||
|
|
||||||
err := os.Symlink(target, link)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := filepath.Join(link, "sub")
|
|
||||||
|
|
||||||
st := syncTree(t, db, sub, filepath.Join(sub, "deeper"))
|
|
||||||
if st != (scanStats{walked: 1, added: 1}) {
|
|
||||||
t.Fatalf("stats = %+v, want 1 walked and added", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScanHashesUniqueSize checks that a file whose size no other file
|
// TestScanHashesUniqueSize checks that a file whose size no other file
|
||||||
// shares is hashed like every other file.
|
// shares is hashed like every other file.
|
||||||
func TestScanHashesUniqueSize(t *testing.T) {
|
func TestScanHashesUniqueSize(t *testing.T) {
|
||||||
@@ -2160,16 +2085,6 @@ func TestPruneRoots(t *testing.T) {
|
|||||||
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
||||||
pqRoot, got, want)
|
pqRoot, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nor does it enter a directory named .zfs, so /p/.zfs/s is kept. The
|
|
||||||
// walk of /p/.zfs/s reaches /p/.zfs/s/t, which is dropped.
|
|
||||||
got = pruneRoots([]string{"/p", "/p/.zfs/s", "/p/.zfs/s/t"}, false,
|
|
||||||
walkRules{}, device)
|
|
||||||
|
|
||||||
want = []string{"/p", "/p/.zfs/s"}
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Fatalf("pruneRoots with .zfs = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user