diff --git a/.golangci.yml b/.golangci.yml index 7ec7a5083..470a096f0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,7 @@ linters: - musttag - nestif - nlreturn + - noinlineerr - nonamedreturns - paralleltest - prealloc diff --git a/cmd/syncthing/main.go b/cmd/syncthing/main.go index 6f8f91fd0..455f53088 100644 --- a/cmd/syncthing/main.go +++ b/cmd/syncthing/main.go @@ -920,8 +920,10 @@ func (browserCmd) Run() error { } type debugCmd struct { - ResetDatabase resetDatabaseCmd `cmd:"" help:"Reset the database, forcing a full rescan and resync"` - DatabaseStatistics databaseStatsCmd `cmd:"" help:"Display database size statistics"` + ResetDatabase resetDatabaseCmd `cmd:"" help:"Reset the database, forcing a full rescan and resync"` + DatabaseStatistics databaseStatsCmd `cmd:"" help:"Display database size statistics"` + DatabaseCounts databaseCountsCmd `cmd:"" help:"Display database folder counts"` + DatabaseFile databaseFileCmd `cmd:"" help:"Display database file metadata"` } type resetDatabaseCmd struct{} @@ -956,6 +958,33 @@ func (c databaseStatsCmd) Run() error { return tw.Flush() } +type databaseCountsCmd struct { + Folder string `arg:"" required:""` +} + +func (c databaseCountsCmd) Run() error { + db, err := sqlite.Open(locations.Get(locations.Database)) + if err != nil { + return err + } + + return db.DebugCounts(os.Stdout, c.Folder) +} + +type databaseFileCmd struct { + Folder string `arg:"" required:""` + File string `arg:"" required:""` +} + +func (c databaseFileCmd) Run() error { + db, err := sqlite.Open(locations.Get(locations.Database)) + if err != nil { + return err + } + + return db.DebugFilePattern(os.Stdout, c.Folder, c.File) +} + func (c databaseStatsCmd) printStat(w io.Writer, s *sqlite.DatabaseStatistics) { for _, table := range s.Tables { fmt.Fprintf(w, "%s\t%s\t%s\t%8d KiB\t%5.01f %%\n", s.Name, cmp.Or(s.FolderID, "-"), table.Name, table.Size/1024, float64(table.Size-table.Unused)*100/float64(table.Size)) diff --git a/internal/db/sqlite/db_folderdb.go b/internal/db/sqlite/db_folderdb.go index 077b73a6a..5daa67bf0 100644 --- a/internal/db/sqlite/db_folderdb.go +++ b/internal/db/sqlite/db_folderdb.go @@ -10,6 +10,7 @@ import ( "database/sql" "errors" "fmt" + "io" "iter" "path/filepath" "strings" @@ -376,6 +377,22 @@ func (s *DB) DropDevice(device protocol.DeviceID) error { }) } +func (s *DB) DebugCounts(out io.Writer, folder string) error { + fdb, err := s.getFolderDB(folder, false) + if err != nil { + return err + } + return fdb.DebugCounts(out) +} + +func (s *DB) DebugFilePattern(out io.Writer, folder, name string) error { + fdb, err := s.getFolderDB(folder, false) + if err != nil { + return err + } + return fdb.DebugFilePattern(out, name) +} + // forEachFolder runs the function for each currently open folderDB, // returning the first error that was encountered. func (s *DB) forEachFolder(fn func(fdb *folderDB) error) error { diff --git a/internal/db/sqlite/folderdb_counts.go b/internal/db/sqlite/folderdb_counts.go index e4bb0d637..d83da2793 100644 --- a/internal/db/sqlite/folderdb_counts.go +++ b/internal/db/sqlite/folderdb_counts.go @@ -16,7 +16,7 @@ type countsRow struct { Count int Size int64 Deleted bool - LocalFlags int64 `db:"local_flags"` + LocalFlags protocol.FlagLocal `db:"local_flags"` } func (s *folderDB) CountLocal(device protocol.DeviceID) (db.Counts, error) { diff --git a/internal/db/sqlite/folderdb_local.go b/internal/db/sqlite/folderdb_local.go index 51d47ebe0..53319a79d 100644 --- a/internal/db/sqlite/folderdb_local.go +++ b/internal/db/sqlite/folderdb_local.go @@ -8,9 +8,14 @@ package sqlite import ( "database/sql" + "encoding/base64" "errors" "fmt" + "io" "iter" + "strings" + "text/tabwriter" + "time" "github.com/syncthing/syncthing/internal/db" "github.com/syncthing/syncthing/internal/itererr" @@ -126,3 +131,82 @@ func (s *folderDB) ListDevicesForFolder() ([]protocol.DeviceID, error) { } return devs, nil } + +func (s *folderDB) DebugCounts(out io.Writer) error { + type deviceCountsRow struct { + countsRow + + DeviceID string + } + + delMap := map[bool]string{ + true: "del", + false: "---", + } + + var res []deviceCountsRow + if err := s.stmt(` + SELECT d.device_id as deviceid, s.type, s.count, s.size, s.local_flags, s.deleted FROM counts s + INNER JOIN devices d ON d.idx = s.device_idx + `).Select(&res); err != nil { + return wrap(err) + } + + tw := tabwriter.NewWriter(out, 2, 2, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", "DEVICE", "TYPE", "FLAGS", "DELETED", "COUNT", "SIZE") + for _, row := range res { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%d\n", shortDevice(row.DeviceID), shortType(row.Type), row.LocalFlags.HumanString(), delMap[row.Deleted], row.Count, row.Size) + } + return tw.Flush() +} + +func (s *folderDB) DebugFilePattern(out io.Writer, name string) error { + type hashFileMetadata struct { + db.FileMetadata + + Version dbVector + BlocklistHash []byte + DeviceID string + } + name = "%" + name + "%" + res := itererr.Zip(iterStructs[hashFileMetadata](s.stmt(` + SELECT f.sequence, f.name, f.type, f.modified as modnanos, f.size, f.deleted, f.local_flags as localflags, f.version, f.blocklist_hash as blocklisthash, d.device_id as deviceid FROM files f + INNER JOIN devices d ON d.idx = f.device_idx + WHERE f.name LIKE ? + ORDER BY f.name, f.device_idx + `).Queryx(name))) + + delMap := map[bool]string{ + true: "del", + false: "---", + } + + tw := tabwriter.NewWriter(out, 2, 2, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "DEVICE", "TYPE", "NAME", "SEQUENCE", "DELETED", "MODIFIED", "SIZE", "FLAGS", "VERSION", "BLOCKLIST") + for row, err := range res { + if err != nil { + return err + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%s\n", shortDevice(row.DeviceID), shortType(row.Type), row.Name, row.Sequence, delMap[row.Deleted], row.ModTime().UTC().Format(time.RFC3339Nano), row.Size, row.LocalFlags.HumanString(), row.Version.HumanString(), shortHash(row.BlocklistHash)) + } + return tw.Flush() +} + +func shortDevice(s string) string { + if dev, err := protocol.DeviceIDFromString(s); err == nil && dev == protocol.LocalDeviceID { + return "-local-" + } + short, _, _ := strings.Cut(s, "-") + return short +} + +func shortType(t protocol.FileInfoType) string { + return strings.TrimPrefix(t.String(), "FILE_INFO_TYPE_") +} + +func shortHash(bs []byte) string { + if len(bs) == 0 { + return "-nil-" + } + return base64.RawStdEncoding.EncodeToString(bs)[:8] +} diff --git a/lib/protocol/bep_fileinfo.go b/lib/protocol/bep_fileinfo.go index 9227a8f5b..168481275 100644 --- a/lib/protocol/bep_fileinfo.go +++ b/lib/protocol/bep_fileinfo.go @@ -11,6 +11,8 @@ import ( "crypto/sha256" "encoding/binary" "fmt" + "slices" + "strings" "time" "github.com/syncthing/syncthing/internal/gen/bep" @@ -40,10 +42,52 @@ const ( LocalAllFlags = FlagLocalUnsupported | FlagLocalIgnored | FlagLocalMustRescan | FlagLocalReceiveOnly | FlagLocalGlobal | FlagLocalNeeded | FlagLocalRemoteInvalid ) +// localFlagBitNames maps flag values to characters which can be used to +// build a permission-like bit string for easier reading. +var localFlagBitNames = map[FlagLocal]string{ + FlagLocalUnsupported: "u", + FlagLocalIgnored: "i", + FlagLocalMustRescan: "r", + FlagLocalReceiveOnly: "e", + FlagLocalGlobal: "G", + FlagLocalNeeded: "n", + FlagLocalRemoteInvalid: "v", +} + func (f FlagLocal) IsInvalid() bool { return f&LocalInvalidFlags != 0 } +// HumanString returns a permission-like string representation of the flag bits +func (f FlagLocal) HumanString() string { + if f == 0 { + return strings.Repeat("-", len(localFlagBitNames)) + } + + bit := FlagLocal(1) + var res bytes.Buffer + var extra strings.Builder + for f != 0 { + if f&bit != 0 { + if name, ok := localFlagBitNames[bit]; ok { + res.WriteString(name) + } else { + fmt.Fprintf(&extra, "+0x%x", bit) + } + } else { + res.WriteString("-") + } + f &^= bit + bit <<= 1 + } + if res.Len() < len(localFlagBitNames) { + res.WriteString(strings.Repeat("-", len(localFlagBitNames)-res.Len())) + } + base := res.Bytes() + slices.Reverse(base) + return string(base) + extra.String() +} + // BlockSizes is the list of valid block sizes, from min to max var BlockSizes []int diff --git a/lib/protocol/vector.go b/lib/protocol/vector.go index adaaa7494..d0d776d6e 100644 --- a/lib/protocol/vector.go +++ b/lib/protocol/vector.go @@ -38,6 +38,17 @@ func (v *Vector) String() string { return buf.String() } +func (v *Vector) HumanString() string { + var buf strings.Builder + for i, c := range v.Counters { + if i > 0 { + buf.WriteRune(',') + } + fmt.Fprintf(&buf, "%s:%d", c.ID, c.Value) + } + return buf.String() +} + func (v *Vector) ToWire() *bep.Vector { counters := make([]*bep.Counter, len(v.Counters)) for i, c := range v.Counters {