150 findings fixed: linter autofixes for whitespace style (wsl_v5, nlreturn, noinlineerr), named returns removed, magic numbers replaced with named constants, static errNotRegular error, explicit Close/Parse error handling, unused cobra params renamed to _, and runReport/ runTrees/hashPass split into helpers to satisfy cyclop, gocognit, and funlen. Behavior verified unchanged against the README smoke test.
235 lines
5.7 KiB
Go
235 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// fileSig is a file's duplicate signature; mtime is excluded.
|
|
type fileSig struct {
|
|
size int64
|
|
head string
|
|
tail string
|
|
}
|
|
|
|
// treeNode is one directory reconstructed from the scan stream.
|
|
type treeNode struct {
|
|
path string
|
|
parent *treeNode
|
|
dirs map[string]*treeNode
|
|
files map[string]fileSig
|
|
digest [sha256.Size]byte
|
|
fileCount int64
|
|
totalSize int64
|
|
}
|
|
|
|
// runTrees implements the trees subcommand: it reads a scan stream from
|
|
// the named file (or stdin when absent or "-"), reconstructs the
|
|
// directory hierarchy from the record paths, computes a Merkle-style
|
|
// digest per directory, and prints maximal duplicate-tree groups as TSV
|
|
// on stdout. It never touches the scanned filesystem; its only I/O is
|
|
// the scan input, stdout, and stderr. args holds the positional
|
|
// arguments already validated by cobra (at most one).
|
|
func runTrees(args []string) {
|
|
in, name, closer := openScanInput(args)
|
|
defer closer()
|
|
|
|
recs, malformed := parseScanStream(in, name)
|
|
records := len(recs) + malformed
|
|
|
|
super, allDirs := buildHierarchy(recs)
|
|
super.compute()
|
|
|
|
dupes := collectTreeGroups(allDirs, super)
|
|
|
|
out := bufio.NewWriterSize(os.Stdout, ioBufSize)
|
|
|
|
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize")
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
dupeTrees := 0
|
|
|
|
var reclaimable int64
|
|
|
|
for _, g := range dupes {
|
|
first := g[0]
|
|
for _, n := range g[1:] {
|
|
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\n",
|
|
first.path, n.path, first.fileCount, first.totalSize)
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
dupeTrees++
|
|
reclaimable += first.totalSize
|
|
}
|
|
}
|
|
|
|
err = out.Flush()
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr,
|
|
"trees: %d records read%s, %d duplicate tree groups, %d dupe trees, %s reclaimable\n",
|
|
records, malformedNote(malformed), len(dupes), dupeTrees,
|
|
humanBytes(reclaimable))
|
|
}
|
|
|
|
// buildHierarchy reconstructs the directory hierarchy from the record
|
|
// paths under a synthetic super-root. Paths are split on "/"; for
|
|
// absolute paths the first component is empty, which simply becomes a
|
|
// top-level node representing "/". It returns the super-root and every
|
|
// directory node created.
|
|
func buildHierarchy(recs []scanRec) (*treeNode, []*treeNode) {
|
|
super := &treeNode{}
|
|
|
|
var allDirs []*treeNode
|
|
|
|
for _, r := range recs {
|
|
comps := strings.Split(r.path, "/")
|
|
|
|
node := super
|
|
for _, c := range comps[:len(comps)-1] {
|
|
child := node.dirs[c]
|
|
if child == nil {
|
|
childPath := c
|
|
if node != super {
|
|
childPath = node.path + "/" + c
|
|
}
|
|
|
|
child = &treeNode{path: childPath, parent: node}
|
|
if node.dirs == nil {
|
|
node.dirs = make(map[string]*treeNode)
|
|
}
|
|
|
|
node.dirs[c] = child
|
|
allDirs = append(allDirs, child)
|
|
}
|
|
|
|
node = child
|
|
}
|
|
|
|
if node.files == nil {
|
|
node.files = make(map[string]fileSig)
|
|
}
|
|
|
|
node.files[comps[len(comps)-1]] = fileSig{
|
|
size: r.size, head: r.head, tail: r.tail,
|
|
}
|
|
}
|
|
|
|
return super, allDirs
|
|
}
|
|
|
|
// collectTreeGroups groups directories by digest and returns every
|
|
// maximal group with two or more members, each group's members sorted
|
|
// by path, groups ordered by tree size descending then by first path
|
|
// ascending.
|
|
func collectTreeGroups(allDirs []*treeNode, super *treeNode) [][]*treeNode {
|
|
groups := make(map[[sha256.Size]byte][]*treeNode)
|
|
for _, d := range allDirs {
|
|
groups[d.digest] = append(groups[d.digest], d)
|
|
}
|
|
|
|
var dupes [][]*treeNode
|
|
|
|
for _, g := range groups {
|
|
if len(g) < minGroupSize || suppressed(g, super) {
|
|
continue
|
|
}
|
|
|
|
slices.SortFunc(g, func(a, b *treeNode) int {
|
|
return strings.Compare(a.path, b.path)
|
|
})
|
|
dupes = append(dupes, g)
|
|
}
|
|
|
|
// Biggest reclaimable space first; ties broken by first path.
|
|
slices.SortFunc(dupes, func(a, b []*treeNode) int {
|
|
if a[0].totalSize != b[0].totalSize {
|
|
if a[0].totalSize > b[0].totalSize {
|
|
return -1
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
return strings.Compare(a[0].path, b[0].path)
|
|
})
|
|
|
|
return dupes
|
|
}
|
|
|
|
// compute fills in digest, fileCount, and totalSize for n and all of
|
|
// its descendants. A directory's digest is the SHA-256 of its child
|
|
// entries — files serialized with name and signature, subdirectories
|
|
// with name and recursive digest — sorted byte-lexicographically.
|
|
// Filenames cannot contain NUL or "/", so NUL delimiters are
|
|
// unambiguous.
|
|
func (n *treeNode) compute() {
|
|
entries := make([]string, 0, len(n.dirs)+len(n.files))
|
|
for name, sig := range n.files {
|
|
entries = append(entries,
|
|
"f\x00"+name+"\x00"+strconv.FormatInt(sig.size, 10)+
|
|
"\x00"+sig.head+"\x00"+sig.tail)
|
|
n.fileCount++
|
|
n.totalSize += sig.size
|
|
}
|
|
|
|
for name, child := range n.dirs {
|
|
child.compute()
|
|
entries = append(entries, "d\x00"+name+"\x00"+string(child.digest[:]))
|
|
n.fileCount += child.fileCount
|
|
n.totalSize += child.totalSize
|
|
}
|
|
|
|
slices.Sort(entries)
|
|
|
|
h := sha256.New()
|
|
for _, e := range entries {
|
|
h.Write([]byte(e))
|
|
h.Write([]byte{0})
|
|
}
|
|
|
|
copy(n.digest[:], h.Sum(nil))
|
|
}
|
|
|
|
// suppressed reports whether a duplicate-tree group is non-maximal: its
|
|
// members' parents are pairwise distinct real directories that all
|
|
// share a single digest, so the group is wholly implied by its parents'
|
|
// (or a further ancestor's) group. Groups containing siblings (shared
|
|
// parent) or members whose parents differ are always reported.
|
|
func suppressed(g []*treeNode, super *treeNode) bool {
|
|
seen := make(map[*treeNode]bool, len(g))
|
|
|
|
var parentDigest [sha256.Size]byte
|
|
|
|
for i, n := range g {
|
|
p := n.parent
|
|
if p == nil || p == super {
|
|
return false
|
|
}
|
|
|
|
if seen[p] {
|
|
return false // siblings: not implied by any parent group
|
|
}
|
|
|
|
seen[p] = true
|
|
if i == 0 {
|
|
parentDigest = p.digest
|
|
} else if p.digest != parentDigest {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|