diff --git a/CHANGELOG.md b/CHANGELOG.md index 435c72a9..ab1629ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ because it turns other people's test suites red. `. A checksum that does not match ends with code 7, the same as `tfg verify`. The file is only read. `tfg tool list` and `tfg tool show ` say what there is, both with `--json`. +- **Checksums of a whole folder.** `tfg tool checksum-write ` writes + SHA256SUMS beside the files, byte for byte what `sha256sum` writes, so + `sha256sum -c` checks it later. It never writes over a checksum file that is + there, and a folder with anything in it that cannot be read gets no checksum + file at all, with everything that could not be read named. `tfg tool + checksum-check ` checks every file a checksum file lists. It + reads what `sha256sum` and `shasum` write, and names the lines that are not + checksums, such as those of a signature, rather than failing on them. Links, + pipes and anything else that is not a file are left out and named, never + opened. Both are on the Tools tab, where the checksum a file should have now + shows in full. - **The window in Polish, and a Preferences tab.** The window now speaks the language your system is set to when it has that language, and English otherwise - so on a system set to Polish it opens in Polish. Preferences diff --git a/README.md b/README.md index 367d4ffe..6fbf7112 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,8 @@ tfg formats what a single format accepts tfg tool list [--json] the tools this build has tfg tool show [--json] what one tool works on and takes tfg tool checksum [--algorithm sha256] [--expected ] [--json] +tfg tool checksum-write [--algorithm sha256] [--json] +tfg tool checksum-check [--json] ``` Small things to do with files you already have, beside the generator. Every tool @@ -396,6 +398,12 @@ unless you ask - and compares it with one you were given, telling the algorithm from its length. A checksum that does not match ends with code 7, the same as `tfg verify`. crc32 is the one ZIP and PNG use, not the one `cksum` prints. +`checksum-write` writes the checksum of every file in a folder into SHA256SUMS +beside them, the same bytes `sha256sum` writes, and never over one that is +there. `checksum-check` checks every file a checksum file lists, in the folder +the checksum file is in. Files it does not list are not looked at, the same as +`sha256sum -c`, and a path that leaves that folder is refused rather than read. + ### `tfg damage` ``` diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 72b82ed5..123a4d20 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -15,7 +15,6 @@ import ( "encoding/hex" "fmt" "io" - "io/fs" "os" "path" "path/filepath" @@ -294,7 +293,7 @@ func Verify(ctx context.Context, dir string, m *manifest.Manifest, skip string) return nil, err } - found, stopped := inOrder(ctx, len(claimed), func(i int, scratch []byte) Difference { + found, stopped := InOrder(ctx, len(claimed), func(i int, scratch []byte) Difference { return compare(claimed[i], full[i], scratch) }) for _, d := range found { @@ -428,50 +427,6 @@ func comparablePath(p string) string { return path.Clean(p) } -// walk lists every file under dir as a slash separated path relative to it. -// -// Recursive because the manifest carries a path rather than a bare name, and -// a run that groups its output into folders has to verify the same way. -// -// It takes the context because this is the part with no upper bound: the loop -// over a manifest is as long as the manifest, and this is as long as whatever -// directory somebody pointed at. Until 2026-08-25 only the loop asked, so -// Ctrl+C during the walk of a large tree did nothing until the walk was over. -func walk(ctx context.Context, dir string) ([]string, error) { - // The root is resolved first, because WalkDir does not follow links and a - // directory that is itself one would be handed to the callback as a single - // entry that is not a directory. Found on 2026-08-03 by the guard for - // generating into a linked directory: verify reported "extra ." and called - // the whole run a mismatch. People keep fixtures on redirected paths, so - // this is an ordinary setup rather than a corner. - if resolved, err := filepath.EvalSymlinks(dir); err == nil { - dir = resolved - } - - var out []string - err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - if d.IsDir() { - return nil - } - rel, relErr := filepath.Rel(dir, p) - if relErr != nil { - return relErr - } - out = append(out, filepath.ToSlash(rel)) - return nil - }) - if err != nil { - return nil, err - } - return out, nil -} - // claimedPaths is where each claimed file sits on the disk, in the order the // manifest gives them, refusing on the first one that leaves the directory. // diff --git a/internal/audit/cleanup.go b/internal/audit/cleanup.go index 7e659db6..c5c5aac3 100644 --- a/internal/audit/cleanup.go +++ b/internal/audit/cleanup.go @@ -79,7 +79,7 @@ func Inspect(ctx context.Context, dir string, m *manifest.Manifest) ([]Candidate // In order, because this list is what cleanup removes from and what it // printed to a person beforehand. - return inOrder(ctx, len(claimed), func(i int, scratch []byte) Candidate { + return InOrder(ctx, len(claimed), func(i int, scratch []byte) Candidate { return look(claimed[i], full[i], scratch) }) } diff --git a/internal/audit/parallel.go b/internal/audit/parallel.go index ea264b4d..5469895a 100644 --- a/internal/audit/parallel.go +++ b/internal/audit/parallel.go @@ -54,7 +54,7 @@ func widthFor(n int) int { return w } -// inOrder answers one question for each of n items, over several goroutines, +// InOrder answers one question for each of n items, over several goroutines, // and hands the answers back in the order the items were given. // // Three properties, and each one is depended on by something else in this @@ -75,7 +75,12 @@ func widthFor(n int) int { // own sake: if a refusal could arrive here, stopping the other goroutines // would mean a LOWER index never got asked, and the same manifest would // name a different file on different days. -func inOrder[T any](ctx context.Context, n int, one func(i int, scratch []byte) T) ([]T, error) { +// +// Exported since 2026-09-30 for the checksum tools, which hash a folder the +// same way and are held to the same three properties: a file that cannot be +// read is an answer of one, never a stop, and the tool decides afterwards +// what the answers come to. The goroutines stay in this file. +func InOrder[T any](ctx context.Context, n int, one func(i int, scratch []byte) T) ([]T, error) { out := make([]T, n) done := make([]bool, n) @@ -138,6 +143,34 @@ func drain[T any](ctx context.Context, next *atomic.Int64, out []T, done []bool, } } +// Tally adds up what several goroutines have read and tells one listener the +// running total, one call at a time. +// +// Here because this is the file where things run beside each other. A tool +// hashing a folder counts bytes in every worker, and the listener at the other +// end - a window's progress bar - is not written to be called from two +// goroutines at once. So the calls are made one after another under a lock, +// rather than every listener having to be written for it and every package +// that counts having to be one the race detector is run for +// (docs/NARZEDZIA-SUMY-2026-09-29.md §15.3). +type Tally struct { + mu sync.Mutex + done int64 + tell func(done int64) +} + +// NewTally is a tally that tells tell each new total. +func NewTally(tell func(done int64)) *Tally { return &Tally{tell: tell} } + +// Add counts n more and tells the listener the total, before the next Add +// from any goroutine can. +func (t *Tally) Add(n int64) { + t.mu.Lock() + defer t.mu.Unlock() + t.done += n + t.tell(t.done) +} + // finishedPrefix is how many items were answered before the first one that was // not - which on a cancelled pass is everything the caller may speak about. func finishedPrefix(done []bool) int { diff --git a/internal/audit/walk.go b/internal/audit/walk.go new file mode 100644 index 00000000..e5a6e78d --- /dev/null +++ b/internal/audit/walk.go @@ -0,0 +1,136 @@ +package audit + +import ( + "context" + "io/fs" + "path/filepath" +) + +// EntryKind is what a name found under a directory is, told from the listing +// of the directory rather than by opening the name. +type EntryKind int + +// The three kinds. A file is the only one anything here reads. +const ( + // Regular is an ordinary file. + Regular EntryKind = iota + 1 + // Link is a symbolic link. Never followed: a link can lead out of the + // directory, or back into it for ever. + Link + // Other is a pipe, a device, a socket, or a junction on Windows - which + // the listing reports as irregular rather than as a link or a directory, + // measured with go1.27.0 on 2026-09-30. Opening one for reading can wait + // for ever, so nothing here does. + Other +) + +// Entry is one name found under a directory that is not a directory itself. +type Entry struct { + // Path is relative to the directory walked and slash separated on every + // system, the way a manifest and a checksum file both write a path. + Path string + Kind EntryKind + + found fs.DirEntry +} + +// Info is what the system says about the entry, asked when somebody needs it +// rather than during the walk. Verify never does, and on Linux and macOS each +// answer is one more call to the system per file. +func (e Entry) Info() (fs.FileInfo, error) { return e.found.Info() } + +// Found is what a walk of a directory came to. +type Found struct { + Entries []Entry + // Unreadable is every directory under the one walked that could not be + // listed, in the order the walk met them, each error naming its path. + // The walk goes on past them, so a caller refusing the whole directory can + // name all of them at once rather than the first one on each try. + Unreadable []error +} + +// Walk lists every name under dir that is not a directory, with what kind of +// thing each one is. +// +// Recursive because the manifest carries a path rather than a bare name, and +// a run that groups its output into folders has to verify the same way. Shared +// by verify and the checksum tools, because "what is in this folder" is one +// question and a checksum file and a manifest have to answer it alike +// (docs/NARZEDZIA-SUMY-2026-09-29.md §3.3). +// +// It takes the context because this is the part with no upper bound: the loop +// over a manifest is as long as the manifest, and this is as long as whatever +// directory somebody pointed at. Until 2026-08-25 only the loop asked, so +// Ctrl+C during the walk of a large tree did nothing until the walk was over. +func Walk(ctx context.Context, dir string) (Found, error) { + // The root is resolved first, because WalkDir does not follow links and a + // directory that is itself one would be handed to the callback as a single + // entry that is not a directory. Found on 2026-08-03 by the guard for + // generating into a linked directory: verify reported "extra ." and called + // the whole run a mismatch. People keep fixtures on redirected paths, so + // this is an ordinary setup rather than a corner. + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + var found Found + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + found.Unreadable = append(found.Unreadable, err) + return pastIt(d) + } + if err := ctx.Err(); err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, relErr := filepath.Rel(dir, p) + if relErr != nil { + return relErr + } + found.Entries = append(found.Entries, Entry{Path: filepath.ToSlash(rel), Kind: kindOf(d), found: d}) + return nil + }) + return found, err +} + +// pastIt is how the walk carries on after a directory it could not list: it +// leaves that directory and goes on with the rest. A root that could not be +// looked at has no entry, and then there is nothing to go on with. +func pastIt(d fs.DirEntry) error { + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil +} + +// kindOf is what the listing says an entry is. Asked of the type bits the +// listing already has, so nothing is opened and nothing is followed. +func kindOf(d fs.DirEntry) EntryKind { + switch { + case d.Type().IsRegular(): + return Regular + case d.Type()&fs.ModeSymlink != 0: + return Link + } + return Other +} + +// walk is Walk for verify, which needs only the paths and refuses on the first +// directory it could not list - the same error, in the same place, that it +// gave before the walk learned to go past one. +func walk(ctx context.Context, dir string) ([]string, error) { + found, err := Walk(ctx, dir) + if len(found.Unreadable) > 0 { + return nil, found.Unreadable[0] + } + if err != nil { + return nil, err + } + out := make([]string, 0, len(found.Entries)) + for _, e := range found.Entries { + out = append(out, e.Path) + } + return out, nil +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 5b98b24a..b7aee8f5 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -187,6 +187,8 @@ func classifyTool(err error) (int, bool) { return ExitUsage, true case tool.Reading: return ExitIO, true + case tool.Room: + return ExitSpace, true } return 0, false } diff --git a/internal/cli/toolcmd.go b/internal/cli/toolcmd.go index ed18d814..eb2be61d 100644 --- a/internal/cli/toolcmd.go +++ b/internal/cli/toolcmd.go @@ -8,6 +8,7 @@ import ( "io" "strings" + "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) @@ -93,7 +94,7 @@ func toolUsage(w io.Writer) { Usage: tfg tool list the tools this build has tfg tool show what one tool works on and takes - tfg tool [flags] run it + tfg tool [flags] run it Every tool is also on the Tools tab of the window, with the same settings. `) @@ -310,7 +311,10 @@ func renderToolResult(d tool.Descriptor, r tool.Result, asJSON bool, out, errOut return code } } else { - printToolTable(d.Columns, r.Rows, w) + if len(r.Rows) > 0 { + printToolTable(d.Columns, r.Rows, w) + } + printNotes(d, r.Notes, w) if said := r.Verdict.Said(); said != "" { fmt.Fprintf(w, "\n%s\n", said) } @@ -321,6 +325,27 @@ func renderToolResult(d tool.Descriptor, r tool.Result, asJSON bool, out, errOut return ExitOK } +// printNotes prints what a run noted, each as the sentence its tool declared +// and its items: one item on the same line, several on lines of their own. +// +// Every item goes through core.Shown, because an item is usually a file name +// and a name may hold a line break or an escape sequence - printed as it is, a +// name could end the list early or rewrite the lines above it in a terminal. +// The same reason verify shows its paths that way. +func printNotes(d tool.Descriptor, notes []tool.Noted, w io.Writer) { + for _, n := range notes { + says := d.NoteSays(n.ID) + if len(n.Items) == 1 { + fmt.Fprintf(w, "%s %s\n", says, core.Shown(n.Items[0])) + continue + } + fmt.Fprintln(w, says) + for _, item := range n.Items { + fmt.Fprintf(w, " %s\n", core.Shown(item)) + } + } +} + // printToolTable prints a result as columns under their headings, each as wide // as its longest cell. func printToolTable(columns []string, rows [][]string, w io.Writer) { diff --git a/internal/format/format.go b/internal/format/format.go index 42edcd95..035bbbd2 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -196,6 +196,17 @@ type Property struct { // Declared here rather than known by the places that care, because a // second secret property added later would otherwise have to find them. Secret bool + + // Long marks free text whose value is long by nature - a checksum of 64 or + // 128 digits - so a window gives its box the whole row, as it gives a path. + // + // It says something about the value rather than about pixels, and a + // terminal has nothing to do with it. Every other box of text is as wide + // as a short name (the owner's report of 2026-09-21), which is right for a + // name and showed about twenty of the sixty four digits a pasted sha256 + // has (docs/NARZEDZIA-SUMY-2026-09-29.md §14, the owner's decision of + // 2026-09-30). Ignored by every kind but text. + Long bool } // JointLimit is a rule binding two settings that neither of them can state diff --git a/internal/guard/boxwidth_test.go b/internal/guard/boxwidth_test.go index 0633b6bd..df985263 100644 --- a/internal/guard/boxwidth_test.go +++ b/internal/guard/boxwidth_test.go @@ -12,6 +12,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // A box is a promise about what goes in it. @@ -444,3 +445,77 @@ func TestOnlyAPathTakesTheWholeRow(t *testing.T) { } t.Logf("%d boxes held under half the column, %d paths allowed the row", checked, paths) } + +// A box for a value declared long takes the row, and on the Tools tab nothing +// else but a path does. +// +// The guard above holds three screens and none of them declares a long value. +// The Tools tab has one: the checksum a file should have, 64 digits for a +// sha256, drawn 140 px wide until 2026-09-30 and showing about twenty of them +// (docs/NARZEDZIA-SUMY-2026-09-29.md §14). Every tool of the registry is chosen +// in turn, so one registered tomorrow is held the day it arrives, and the long +// boxes are counted, so a screen that stopped drawing the one there is today +// turns this red rather than passing on nothing. +func TestALongValueTakesTheRowAndOnTheToolsTabNothingElseButAPathDoes(t *testing.T) { + ourTheme(t) + host := newFakeHost(t) + window.Open(host) + w := test.NewWindow(host.content) + t.Cleanup(w.Close) + layOut := func() { + w.Resize(fyne.NewSize(window.LargestOpening.Width, 1599)) + w.Resize(fyne.NewSize(window.LargestOpening.Width, 1600)) + } + screen := selectTab(t, host.content, text.TabTools()) + menu := chooserUnder(t, screen, text.FieldTool()) + + half := float32(parts.ColumnWidth) / 2 + long, short := 0, 0 + for _, d := range tool.All() { + menu.SetSelected(text.ToolQuestion(d.ID, d.Question)) + layOut() + mayTakeTheRow := map[fyne.CanvasObject]bool{} + allowed := func(label string) { + if control := controlUnder(screen, label); control != nil { + walk(control, func(o fyne.CanvasObject) { mayTakeTheRow[o] = true }) + } + } + for _, in := range d.Inputs { + allowed(text.SettingLabel(in.Name)) + } + for _, p := range d.Settings { + if p.Long { + allowed(text.SettingLabel(p.Name)) + } + } + walk(screen, func(o fyne.CanvasObject) { + box, is := o.(*parts.Entry) + if !is || !box.Visible() || box.Size().Width == 0 { + return + } + switch { + case !mayTakeTheRow[box] && box.Size().Width > half: + t.Errorf("%s: the box with placeholder %q is %.0f px of a %d px column, and only a path or a long value may take the row", + d.ID, box.PlaceHolder, box.Size().Width, parts.ColumnWidth) + case mayTakeTheRow[box]: + default: + short++ + } + }) + for _, p := range d.Settings { + if !p.Long { + continue + } + width := typedInWidth(controlUnder(screen, text.SettingLabel(p.Name))) + if width <= half { + t.Errorf("%s declares %s long and its box is %.0f px, under half the %d px column", d.ID, p.Name, width, parts.ColumnWidth) + } + long++ + } + } + if long == 0 || short == 0 { + t.Fatalf("measured %d long boxes and %d short ones on the Tools tab - the checksum a file should have is declared long, "+ + "so this guard read a screen that is not the one it was written for", long, short) + } + t.Logf("%d long boxes took the row, %d short ones stayed under half the column", long, short) +} diff --git a/internal/guard/checksumfolder_test.go b/internal/guard/checksumfolder_test.go new file mode 100644 index 00000000..5874dea4 --- /dev/null +++ b/internal/guard/checksumfolder_test.go @@ -0,0 +1,647 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" + "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) + +// The two folder tools of the Tools tab - checksum-write and checksum-check, +// docs/NARZEDZIA-SUMY-2026-09-29.md §15. The format they write and read is +// somebody else's, so the guards that matter most ask somebody else: the +// sha256sum people check a folder with. + +// folderOf writes files into a fresh folder, a slash in a name making the +// folders on the way. +func folderOf(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + full := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// runTool runs a tool of tfg tool from its command line, the way a person or +// a script does, and hands back what it said. +func runTool(t *testing.T, args ...string) (code int, out, errOut string) { + t.Helper() + var o, e bytes.Buffer + code = cli.Run(context.Background(), append([]string{"tool"}, args...), &o, &e) + return code, o.String(), e.String() +} + +// checkedJSON runs checksum-check with --json and reads what it said, from +// whichever stream it said it on - a failed check writes nothing to standard +// output. +func checkedJSON(t *testing.T, sums string) (int, checksum.Checked) { + t.Helper() + code, out, errOut := runTool(t, checksum.CheckID, sums, "--json") + said := out + if code != cli.ExitOK { + said = errOut + } + var got checksum.Checked + if err := json.Unmarshal([]byte(said), &got); err != nil { + t.Fatalf("checksum-check ended with %d and did not print JSON: %v\n%s%s", code, err, out, errOut) + } + return code, got +} + +// coreutilsMajor is the major version of the sha256sum on this machine, or +// nought when there is none. A carriage return in a name is escaped only from +// coreutils 9 (measured on 2026-09-30), so the guard below asks. +func coreutilsMajor(t *testing.T) (string, int) { + t.Helper() + program, err := exec.LookPath("sha256sum") + if err != nil { + return "", 0 + } + out, err := exec.Command(program, "--version").Output() + if err != nil { + return "", 0 + } + first, _, _ := strings.Cut(string(out), "\n") + fields := strings.Fields(first) + if len(fields) == 0 { + return program, 0 + } + major := 0 + for _, c := range fields[len(fields)-1] { + if c < '0' || c > '9' { + break + } + major = major*10 + int(c-'0') + } + return program, major +} + +// TestTheChecksumFileIsTheOneSha256sumWrites writes a checksum file of a +// folder with every kind of name that is escaped, or that looks as if it +// might be, and holds it BYTE FOR BYTE to what sha256sum writes about the same +// files in the same order - then has sha256sum check it. +// +// Byte for byte rather than "sha256sum -c passes", because -c passes lines it +// cannot read over with a warning and still ends with success (measured, 8.32 +// and 9.7), so a wrong escape could pass it. A name with a backslash or a line +// break exists only where the system allows one, which Windows does not. A +// carriage return is added where coreutils is 9 or later, the first to escape +// one. +func TestTheChecksumFileIsTheOneSha256sumWrites(t *testing.T) { + files := map[string]string{ + "plain.txt": "abc", "with space.txt": "b", " leading space": "c", + "zażółć.txt": "d", "sub/inner.txt": "e", "empty": "", + } + program, major := coreutilsMajor(t) + if runtime.GOOS != "windows" { + files["*star"] = "f" + files[`back\slash`] = "g" + files["new\nline"] = "h" + if major >= 9 { + files["carriage\rreturn"] = "i" + } + } + dir := folderOf(t, files) + if code, out, errOut := runTool(t, checksum.WriteID, dir); code != cli.ExitOK { + t.Fatalf("checksum-write ended with %d: %s%s", code, out, errOut) + } + ours, err := os.ReadFile(filepath.Join(dir, "SHA256SUMS")) + if err != nil { + t.Fatal(err) + } + + if program == "" { + if runtime.GOOS == "linux" && os.Getenv("CI") != "" { + t.Fatal("no sha256sum on a Linux runner, so the checksum file was held to nothing") + } + t.Skip("SKIPPED: sha256sum is not installed here, so there is nothing to hold the file to") + } + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + // -t, the text mode, which is what sha256sum writes on Linux by default and + // what this tool writes everywhere. The one Git ships for Windows writes the + // binary mode unless told, "hash *name" - measured on 2026-09-30. Both are + // read by both, so the difference is the oracle's default, not a fault. + write := exec.Command(program, append([]string{"-t", "--"}, names...)...) + write.Dir = dir + theirs, err := write.Output() + if err != nil { + t.Fatalf("sha256sum about the same files: %v", err) + } + if !bytes.Equal(ours, theirs) { + t.Errorf("the checksum file is not what sha256sum %d writes about the same files in the same order.\nours:\n%q\ntheirs:\n%q", major, ours, theirs) + } + + check := exec.Command(program, "-c", "--strict", "SHA256SUMS") + check.Dir = dir + if said, err := check.CombinedOutput(); err != nil { + t.Errorf("sha256sum -c --strict does not pass the checksum file: %v\n%s", err, said) + } + t.Logf("%d names held to sha256sum %d", len(names), major) +} + +// The lines a checksum file may hold, as the tools people use write them - +// taken from the measurement of 2026-09-30 rather than from memory. The +// checksums are of "abc", which every file of the folder below holds. +const ( + abcSHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + abcMD5 = "900150983cd24fb0d6963f7d28e17f72" +) + +// TestTheCheckReadsTheLinesOtherToolsWrite hands checksum-check one file of +// every way a checksum line is written - GNU, the tagged form of --tag and +// shasum, a star, one space, capitals, a Windows line end, a byte order mark - +// among the lines a signed checksum file is full of, and holds each line to +// what it is: checked and matched, or named with its number and passed over. +func TestTheCheckReadsTheLinesOtherToolsWrite(t *testing.T) { + dir := folderOf(t, map[string]string{"a.txt": "abc", "b.txt": "abc", "c.txt": "abc", "d.txt": "abc", "e.txt": "abc", "f.txt": "abc", "g.txt": "abc"}) + lines := []string{ + "\xef\xbb\xbf" + abcSHA256 + " a.txt", // 1: a byte order mark, which sha256sum refuses + "iQIzBAEBCAAdFiEE", // 2: a line of a signature's body + "Hash: SHA256", // 3 + "", // 4 + abcSHA256 + " *b.txt", // 5: the star of binary mode + abcSHA256 + " c.txt", // 6: one space + strings.ToUpper(abcSHA256) + " d.txt", // 7: capitals + abcSHA256 + " e.txt\r", // 8: a Windows line end + "SHA256 (f.txt) = " + abcSHA256, // 9: --tag and shasum --tag + "MD5 (g.txt) = " + abcMD5, // 10 + "# a comment", // 11 + abcSHA256[:63] + " a.txt", // 12: a digit lost + strings.Repeat("a", 56) + " a.txt", // 13: sha224 + "SHA3-256 (a.txt) = " + abcSHA256, // 14 + "=vW3x", // 15: the check line that ends a signature + } + sums := filepath.Join(dir, "SHA256SUMS") + if err := os.WriteFile(sums, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + code, got := checkedJSON(t, sums) + if code != cli.ExitOK { + t.Errorf("every checksum line matches and the check ended with %d, %+v", code, got.Problems) + } + if got.Checked != 7 || got.Matched != 7 { + t.Errorf("seven checksum lines were written and %d were checked, %d matched", got.Checked, got.Matched) + } + if want := []int{2, 3, 4, 11, 12, 15}; !equalInts(got.NotChecked.NotChecksums, want) { + t.Errorf("the lines that are not checksums are %v and the check named %v", want, got.NotChecked.NotChecksums) + } + if want := []string{"line 13: sha224", "line 14: SHA3-256"}; strings.Join(got.NotChecked.Unknown, "|") != strings.Join(want, "|") { + t.Errorf("the lines of other algorithms are %q and the check named %q", want, got.NotChecked.Unknown) + } +} + +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestTheCheckUndoesTheEscapingOfANameAndKeepsItInTheFolder reads the lines +// sha256sum writes for names with a backslash and a line break, and a path +// that climbs out, and holds each to where it points. The names cannot be +// files on Windows, so they are asked about while missing - which is the path +// the check reports either way, with the escaping undone. +func TestTheCheckUndoesTheEscapingOfANameAndKeepsItInTheFolder(t *testing.T) { + dir := folderOf(t, map[string]string{"sub/abc.txt": "abc"}) + sums := filepath.Join(dir, "sub", "SHA256SUMS") + body := `\` + abcSHA256 + ` back\\slash` + "\n" + + `\` + abcSHA256 + ` new\nline` + "\n" + + abcSHA256 + " ../sub/abc.txt\n" + + abcSHA256 + " abc.txt\n" + + abcSHA256 + " abc.txt\n" + if err := os.WriteFile(sums, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + code, got := checkedJSON(t, sums) + if code != cli.ExitVerify { + t.Errorf("two listed files are not there and one path climbs out, and the check ended with %d", code) + } + want := map[string]string{`back\slash`: "missing", "new\nline": "missing", "../sub/abc.txt": "outside"} + if runtime.GOOS == "windows" { + // Windows refuses a name with a line break in it as a name, which is + // not the same answer as nothing being there, and the check says the + // system's reason rather than guessing which it meant. + want["new\nline"] = "unreadable" + } + for _, p := range got.Problems { + if want[p.Path] != p.Kind { + t.Errorf("%q came back %s, and it is %q", p.Path, p.Kind, want[p.Path]) + } + delete(want, p.Path) + } + for path, kind := range want { + t.Errorf("%q is %s and the check did not say so", path, kind) + } + if got.Matched != 2 || strings.Join(got.NotChecked.Twice, "|") != "abc.txt" { + t.Errorf("abc.txt is listed twice, matched twice and named once, and the check said matched %d, twice %q", got.Matched, got.NotChecked.Twice) + } +} + +// FuzzChecksumFile hands the parser of checksum files whatever the fuzzer +// makes, and asks two things of every answer. Every line is filed under +// exactly one of checked, not a checksum and another algorithm - nothing lost, +// nothing twice. And a name written by SumsLine reads back as the same name, +// whatever it holds: an escape that loses a byte is a checksum file that +// checks a file nobody listed. +func FuzzChecksumFile(f *testing.F) { + for _, seed := range []string{ + abcSHA256 + " a.txt\n", `\` + abcSHA256 + ` back\\slash` + "\n", "SHA256 (x) = " + abcSHA256 + "\n", + "\xef\xbb\xbf" + abcSHA256 + " a\r\n", "", "\n\n", abcSHA256 + " ", `\` + abcSHA256 + ` a\q`, + "MD5 (a) = b) = " + abcMD5, strings.Repeat("x", 300000) + "\n" + abcSHA256 + " z", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input string) { + parsed, err := checksum.ParseSums(strings.NewReader(input)) + if err != nil { + t.Fatalf("reading from memory failed: %v", err) + } + filed := map[int]int{} + for _, l := range parsed.Listed { + filed[l.Line]++ + } + for _, n := range parsed.NotSums { + filed[n]++ + } + lines := strings.Count(input, "\n") + if input != "" && !strings.HasSuffix(input, "\n") { + lines++ + } + if len(parsed.Listed)+len(parsed.NotSums)+len(parsed.Unknown) != lines { + t.Fatalf("%d lines came back as %d checked, %d not checksums and %d unknown", lines, len(parsed.Listed), len(parsed.NotSums), len(parsed.Unknown)) + } + for n, times := range filed { + if times != 1 || n < 1 || n > lines { + t.Fatalf("line %d was filed %d times among %d lines", n, times, lines) + } + } + + // A name longer than a line may be is read past on purpose - no system + // has one - so it cannot come back, and asking it to would be wrong. + if input == "" || strings.ContainsRune(input, 0) || len(input) > 100000 { + return + } + back, err := checksum.ParseSums(strings.NewReader(checksum.SumsLine(abcSHA256, input))) + if err != nil || len(back.Listed) != 1 || back.Listed[0].Path != input || back.Listed[0].Sum != abcSHA256 { + t.Fatalf("the name %q was written as %q and read back as %+v (%v)", input, checksum.SumsLine(abcSHA256, input), back, err) + } + }) +} + +// TestTheFolderToolsEndWithTheCodeOfWhatHappened holds checksum-write and +// checksum-check to the frozen table of exit codes, and to the rule that a +// failure writes nothing to standard output. +func TestTheFolderToolsEndWithTheCodeOfWhatHappened(t *testing.T) { + good := folderOf(t, map[string]string{"a.txt": "abc"}) + if code, out, errOut := runTool(t, checksum.WriteID, good); code != cli.ExitOK { + t.Fatalf("writing the checksum file of a folder of one file ended with %d: %s%s", code, out, errOut) + } + sums := filepath.Join(good, "SHA256SUMS") + changed := folderOf(t, map[string]string{"a.txt": "abc"}) + if err := os.WriteFile(filepath.Join(changed, "SHA256SUMS"), []byte(strings.Repeat("0", 32)+" a.txt\n"), 0o644); err != nil { + t.Fatal(err) + } + notSums := writeTemp(t, "notes.txt", "hello\n") + empty := t.TempDir() + + for _, c := range []struct { + name string + args []string + want int + }{ + {"a folder written again over its checksum file", []string{checksum.WriteID, good}, cli.ExitIO}, + {"a file where a folder goes", []string{checksum.WriteID, sums}, cli.ExitUsage}, + {"a folder that is not there", []string{checksum.WriteID, good + "-gone"}, cli.ExitIO}, + {"an empty folder", []string{checksum.WriteID, empty}, cli.ExitUsage}, + {"an algorithm with no checksum file", []string{checksum.WriteID, empty, "--algorithm", "crc32"}, cli.ExitUsage}, + {"no folder", []string{checksum.WriteID}, cli.ExitUsage}, + {"a checksum file that holds", []string{checksum.CheckID, sums}, cli.ExitOK}, + {"a checksum file that does not", []string{checksum.CheckID, filepath.Join(changed, "SHA256SUMS")}, cli.ExitVerify}, + {"a file with no checksum line", []string{checksum.CheckID, notSums}, cli.ExitIO}, + {"a folder where the checksum file goes", []string{checksum.CheckID, good}, cli.ExitUsage}, + {"a checksum file that is not there", []string{checksum.CheckID, sums + "-gone"}, cli.ExitIO}, + } { + code, out, errOut := runTool(t, c.args...) + if code != c.want { + t.Errorf("%s: ended with %d and the table says %d. It said: %s%s", c.name, code, c.want, out, errOut) + } + if code != cli.ExitOK && out != "" { + t.Errorf("%s: failed and still wrote to standard output: %s", c.name, out) + } + } +} + +// TestAChecksumFileIsNeverWrittenOver holds untouchable rule 7 for the one +// tool that writes: a checksum file already there stays as it is, byte for +// byte, and so does a half written one another run left - which is named in +// the answer rather than listed as a file of the folder. +func TestAChecksumFileIsNeverWrittenOver(t *testing.T) { + dir := folderOf(t, map[string]string{"a.txt": "abc", "SHA256SUMS": "somebody's own\n"}) + if code, _, _ := runTool(t, checksum.WriteID, dir); code != cli.ExitIO { + t.Errorf("a checksum file was there and writing another ended with %d, not %d", code, cli.ExitIO) + } + if got, _ := os.ReadFile(filepath.Join(dir, "SHA256SUMS")); string(got) != "somebody's own\n" { + t.Errorf("the checksum file that was there now reads %q", got) + } + + left := folderOf(t, map[string]string{"a.txt": "abc", "b.bin.tfg-writing": "half"}) + code, out, errOut := runTool(t, checksum.WriteID, left, "--json") + if code != cli.ExitOK { + t.Fatalf("a folder with a half written file ended with %d: %s%s", code, out, errOut) + } + var written checksum.Written + if err := json.Unmarshal([]byte(out), &written); err != nil { + t.Fatal(err) + } + if written.Files != 1 || strings.Join(written.LeftOut.Unfinished, "|") != "b.bin.tfg-writing" { + t.Errorf("one file and one half written one, and the tool listed %d and left out %q", written.Files, written.LeftOut.Unfinished) + } + if got, _ := os.ReadFile(filepath.Join(left, "b.bin.tfg-writing")); string(got) != "half" { + t.Errorf("the half written file now reads %q", got) + } +} + +// TestAStoppedWriteLeavesNothing stops checksum-write in the middle of reading +// and asks the folder: no checksum file, and no half of one under the name it +// is written under first (G7). +func TestAStoppedWriteLeavesNothing(t *testing.T) { + dir := folderOf(t, map[string]string{"big.bin": strings.Repeat("x", 8<<20), "small.txt": "abc"}) + d, err := tool.Get(checksum.WriteID) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stopped := false + _, err = d.Start(ctx, tool.Request{Inputs: map[string]string{checksum.InputFolder: dir}}, func(done, _ int64) { + if done > 0 && !stopped { + stopped = true + cancel() + } + }) + if !stopped { + t.Fatal("the tool never said it had read anything, so this guard stopped nothing") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("stopped in the middle and the tool answered %v rather than that it was stopped", err) + } + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() != "big.bin" && e.Name() != "small.txt" { + t.Errorf("a stopped run left %s in the folder", e.Name()) + } + } +} + +// TestTheToolsTabWritesAndChecksAFolder runs both folder tools from the +// screen, through the pickers a person uses: the folder picker for the folder +// and the file picker for the checksum file, each asked, each answer landing +// in its box. Then the screen has to say what the command line says. +func TestTheToolsTabWritesAndChecksAFolder(t *testing.T) { + dir := folderOf(t, map[string]string{"a.txt": "abc", "sub/b.txt": "b"}) + host := newFakeHost(t) + host.picked = dir + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + menu := chooserUnder(t, screen, text.FieldTool()) + + write, _ := tool.Get(checksum.WriteID) + menu.SetSelected(text.ToolQuestion(write.ID, write.Question)) + pressNamed(t, screen, text.ButtonChoose()) + if host.asked == 0 { + t.Fatal("the browse button of checksum-write asked nobody for a folder") + } + if got := entryUnder(t, screen, text.SettingLabel(checksum.InputFolder)).Text; got != dir { + t.Fatalf("%s was chosen and the box holds %q", dir, got) + } + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + sums := filepath.Join(dir, "SHA256SUMS") + if _, err := os.Stat(sums); err != nil { + t.Fatalf("Run on checksum-write wrote no checksum file: %v\nThe screen says:\n%s", err, allText(screen)) + } + if words := allText(screen); !strings.Contains(words, text.ToolNote(write.ID, "written", write.NoteSays("written"))) { + t.Errorf("the screen does not say the file was written. It says:\n%s", words) + } + + check, _ := tool.Get(checksum.CheckID) + menu.SetSelected(text.ToolQuestion(check.ID, check.Question)) + host.pickedFile = sums + pressNamed(t, screen, text.ButtonChoose()) + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + if words := allText(screen); !strings.Contains(words, text.ToolListMatches("SHA256SUMS")) { + t.Errorf("the folder is what its checksum file says and the screen does not say so. It says:\n%s", words) + } + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + if words := allText(screen); !strings.Contains(words, text.ToolListDoesNotMatch("SHA256SUMS")) || !strings.Contains(words, "a.txt") { + t.Errorf("a.txt changed and the screen does not say which file does not match. It says:\n%s", words) + } +} + +// TestTheToolsTabCutsALongListShort checks a checksum file listing more +// missing files than the screen draws, and holds the screen to drawing the +// first NoteItemsShown and saying how many more there are - a folder that +// moved lists every one of its files as missing, and a hundred thousand +// lines drawn on a canvas is a window that stops answering. +func TestTheToolsTabCutsALongListShort(t *testing.T) { + dir := t.TempDir() + var body strings.Builder + listed := 25 + for i := 1; i <= listed; i++ { + body.WriteString(fmt.Sprintf("%s missing-%02d.txt\n", abcSHA256, i)) + } + sums := filepath.Join(dir, "SHA256SUMS") + if err := os.WriteFile(sums, []byte(body.String()), 0o644); err != nil { + t.Fatal(err) + } + host := newFakeHost(t) + host.pickedFile = sums + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + check, _ := tool.Get(checksum.CheckID) + chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolQuestion(check.ID, check.Question)) + pressNamed(t, screen, text.ButtonChoose()) + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + + words := allText(screen) + shown := parts.NoteItemsShown + if !strings.Contains(words, fmt.Sprintf("missing-%02d.txt", shown)) { + t.Fatalf("the screen does not show the first %d missing files at all, so this guard read a screen that is not the result. It says:\n%s", shown, words) + } + if strings.Contains(words, fmt.Sprintf("missing-%02d.txt", shown+1)) { + t.Errorf("the screen draws more than %d items of one note", shown) + } + if want := text.ToolMoreItems(listed-shown, check.ID); !strings.Contains(words, want) { + t.Errorf("the screen cut the list short without saying %q. It says:\n%s", want, words) + } +} + +// TestAFolderToolNeverWaitsOnAPipeOrAJunction puts a thing that is not a file +// in a folder - a pipe where the system has them, a junction on Windows - and +// holds both tools to naming it without opening it. The failure being guarded +// is a run that never returns, so every run has a deadline. +func TestAFolderToolNeverWaitsOnAPipeOrAJunction(t *testing.T) { + dir := folderOf(t, map[string]string{"a.txt": "abc", "target/inside.txt": "x"}) + name := notAFileIn(t, dir) + within := func(args ...string) (int, string, string) { + t.Helper() + type ending struct { + code int + out, err string + } + ended := make(chan ending, 1) + go func() { + code, out, errOut := runTool(t, args...) + ended <- ending{code, out, errOut} + }() + select { + case e := <-ended: + return e.code, e.out, e.err + case <-time.After(20 * time.Second): + t.Fatalf("tfg tool %s has not returned after twenty seconds", strings.Join(args, " ")) + return 0, "", "" + } + } + + code, out, errOut := within(checksum.WriteID, dir, "--json") + if code != cli.ExitOK { + t.Fatalf("checksum-write ended with %d: %s%s", code, out, errOut) + } + var written checksum.Written + if err := json.Unmarshal([]byte(out), &written); err != nil { + t.Fatal(err) + } + if strings.Join(written.LeftOut.NotFiles, "|") != name { + t.Errorf("%s is not a file and the tool left out %q", name, written.LeftOut.NotFiles) + } + + sums := filepath.Join(dir, "SHA256SUMS") + list := abcSHA256 + " " + name + "\n" + abcSHA256 + " a.txt\n" + if err := os.WriteFile(sums+"2", []byte(list), 0o644); err != nil { + t.Fatal(err) + } + code, _, errOut = within(checksum.CheckID, sums+"2", "--json") + var checked checksum.Checked + if err := json.Unmarshal([]byte(errOut), &checked); err != nil { + t.Fatalf("checksum-check ended with %d and printed no JSON: %v", code, err) + } + // A pipe is opened without waiting and found not to be a file. A junction + // is not opened at all: the boundary cannot follow one to see where it + // leads, so it is refused as a way out of the folder (O265). + wantKind := "not_a_file" + if runtime.GOOS == "windows" { + wantKind = "outside" + } + if code != cli.ExitVerify || len(checked.Problems) != 1 || checked.Problems[0].Kind != wantKind { + t.Errorf("a checksum file lists %s and the check ended with %d, saying %+v", name, code, checked.Problems) + } +} + +// TestAFolderThatCannotBeListedIsRefusedWholeAndVerifyStillStopsAtIt holds the +// walk verify and checksum-write share to two answers about a directory that +// cannot be listed. checksum-write refuses the whole checksum file and names +// EVERY such directory, since a checksum file promises every file. Verify, +// which the walk learned to go past one for on 2026-09-30, still refuses on +// the first one with the code it gave before - reporting a clean run over a +// folder it could not see into would be the one answer it must never give. +// +// Windows has no mode that stops the owner of a folder listing it, and +// neither does root anywhere, so this runs where it can say something. +func TestAFolderThatCannotBeListedIsRefusedWholeAndVerifyStillStopsAtIt(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("SKIPPED: no way to make a folder unreadable to its own user here") + } + lock := func(dirs ...string) { + for _, d := range dirs { + if err := os.Chmod(d, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(d, 0o755) }) + } + } + + dir := folderOf(t, map[string]string{"a.txt": "abc", "first/x": "x", "second/y": "y"}) + lock(filepath.Join(dir, "first"), filepath.Join(dir, "second")) + code, out, errOut := runTool(t, checksum.WriteID, dir) + if code != cli.ExitIO || !strings.Contains(errOut, "first") || !strings.Contains(errOut, "second") { + t.Errorf("two folders could not be listed and checksum-write ended with %d, saying:\n%s%s", code, out, errOut) + } + if _, err := os.Stat(filepath.Join(dir, "SHA256SUMS")); err == nil { + t.Error("checksum-write refused and still wrote a checksum file") + } + + generatedDir, manifest := generated(t) + locked := filepath.Join(generatedDir, "locked") + if err := os.Mkdir(locked, 0o755); err != nil { + t.Fatal(err) + } + lock(locked) + code, out, errOut = runVerify(manifest) + if code != cli.ExitIO || !strings.Contains(errOut, "locked") { + t.Errorf("verify could not list a folder of the run and ended with %d, saying:\n%s%s", code, out, errOut) + } +} + +// runVerify is tfg verify of one manifest. +func runVerify(manifest string) (int, string, string) { + var out, errOut bytes.Buffer + code := cli.Run(context.Background(), []string{"verify", manifest}, &out, &errOut) + return code, out.String(), errOut.String() +} + +// notAFileIn makes one thing in dir that is not a file, and says its name: a +// pipe on Linux and macOS, a junction on Windows - which an ordinary user can +// make, and which the walk reports as neither a link nor a folder. +func notAFileIn(t *testing.T, dir string) string { + t.Helper() + if runtime.GOOS != "windows" { + if err := exec.Command("mkfifo", filepath.Join(dir, "pipe")).Run(); err != nil { + t.Fatalf("mkfifo: %v", err) + } + return "pipe" + } + out, err := exec.Command("cmd", "/c", "mklink", "/J", filepath.Join(dir, "junction"), filepath.Join(dir, "target")).CombinedOutput() + if err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + return "junction" +} diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go index b2f13e80..622dff6b 100644 --- a/internal/guard/concurrency_test.go +++ b/internal/guard/concurrency_test.go @@ -79,7 +79,7 @@ var mayBeConcurrent = map[string]string{ // is settled before the goroutines start, so a worker answers about one // file and cannot fail - which is what makes the order of the answers, and // the file a refusal names, the same on every run. - "internal/audit/parallel.go": "hashing the claimed files runs beside itself, and nothing else in the package does", + "internal/audit/parallel.go": "hashing runs beside itself - the files a manifest claims and the files of a checksum tool - and nothing else in the package does", // Writing the files IS the run. Measured 2026-09-06, after P7 stopped // planning from encoding the picture twice: planning 300 PNGs is 51 ms and // writing them is 2741 ms, so the write loop is 98% of it and the plan is diff --git a/internal/guard/help_test.go b/internal/guard/help_test.go index 40b4841c..f12241b8 100644 --- a/internal/guard/help_test.go +++ b/internal/guard/help_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // Asking for help is not a mistake. @@ -45,11 +46,25 @@ var commandsTakingHelp = [][]string{ {"tool"}, {"tool", "list"}, {"tool", "show"}, - {"tool", "checksum"}, +} + +// takingHelp is commandsTakingHelp and every tool of the registry after +// "tfg tool" - read from the registry rather than written here, since a list +// kept by hand beside one that grows is the list that falls behind. It held +// "tool checksum" alone until 2026-09-30, the day two more tools arrived. +func takingHelp() [][]string { + out := append([][]string{}, commandsTakingHelp...) + for _, id := range tool.Names() { + out = append(out, []string{"tool", id}) + } + return out } func TestAskingForHelpIsNotAMistake(t *testing.T) { - for _, cmd := range commandsTakingHelp { + if len(tool.Names()) == 0 { + t.Fatal("the registry of tools is empty here, so no tool would be asked for its help") + } + for _, cmd := range takingHelp() { for _, flag := range []string{"--help", "-h"} { args := append(append([]string{}, cmd...), flag) name := strings.Join(args, " ") diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 8cc246d4..9ed1ec41 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -125,6 +125,9 @@ var notProvenByMutation = map[string]bool{ // "proven another way" are different states and lumping them together would // send a later session to re-prove what is already proven. var provenByProbe = map[string]string{ + "TestAFolderThatCannotBeListedIsRefusedWholeAndVerifyStillStopsAtIt": "broken on 2026-09-30 in a Linux container (golang:1.27-alpine, the repository read only, an ordinary user), through the command line rather than through this test, which cannot build there without the window's libraries. " + + "With internal/audit/walk.go as it is, verify of a run with a folder nobody may list ended with 5 naming it. With the three lines that hand verify the first unreadable folder removed - the overlay put the mutated file in place - the same verify ended with 0 and said the directory matches. " + + "A probe rather than a mutation entry because this guard skips on Windows, where the runner runs, and would score NOT CAUGHT about a healthy guard.", "TestThePackageSourcesAreTrackedByGit": "broken by hand on 2026-09-25 and put back: git rm --cached on packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in made it red, naming that file, and git add made it green again. " + "A probe rather than a mutation entry because what it reads is git's index, not the text of a file - no substitution in any file untracks one.", "TestAManifestCarryingACredentialIsWrittenForItsOwner": "broken by hand on 2026-09-06 and put back, because the mutation is expressible and the OBSERVATION is not - Windows has no permission bits, Go maps only the owner write bit onto its read only attribute, and this machine is the one the mutation runner runs on. Changed internal/manifest mode() from 0o600 to 0o666, cross compiled the guard binary for linux/amd64 and ran it in a debian container against the real repository: red, naming the case - \"a manifest with a password came out 0644 and should be 0600\" - while the two cases that must stay 0644 stayed green. A probe rather than a mutation entry because a runner on Windows would score this NOT CAUGHT about a healthy guard, which is the worst answer of the three. The half of this pair that runs everywhere is TestTheRecordAndTheRegistryAgreeOnWhatIsACredential, and that one has a mutation.", diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index ca0548d4..d04627bb 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -289,6 +289,13 @@ var reachableFromTheWindow = []string{ "tool:checksum.algorithm", "tool:checksum.expected", "tool:checksum.file", + // Held by TestTheToolsTabWritesAndChecksAFolder as well, which runs both + // from the screen through the pickers. + "tool:checksum-check", + "tool:checksum-check.checksum_file", + "tool:checksum-write", + "tool:checksum-write.algorithm", + "tool:checksum-write.folder", } // notYetReachable is everything the engine can do that the window cannot. diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 02e5835d..d3556a8e 100644 Binary files a/internal/guard/testdata/screens/catalogue.png and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml index 091b154e..8bc577d9 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -2339,12 +2339,12 @@ - - - - + + + + PropertyField - + @@ -2569,11 +2569,57 @@ + + + + + long free text + + + + + + + + + + Expected + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + + - + @@ -2703,7 +2749,7 @@ - + @@ -2817,7 +2863,7 @@ - + @@ -2898,7 +2944,7 @@ - + @@ -2947,7 +2993,7 @@ - + @@ -3368,7 +3414,7 @@ - + @@ -3708,7 +3754,7 @@ - + @@ -4219,7 +4265,7 @@ - + @@ -4289,7 +4335,7 @@ - + @@ -4873,7 +4919,7 @@ - + @@ -5457,7 +5503,7 @@ - + @@ -5570,7 +5616,7 @@ - + diff --git a/internal/guard/testdata/screens/tools-refused.png b/internal/guard/testdata/screens/tools-refused.png index d8c2b43c..6909a403 100644 Binary files a/internal/guard/testdata/screens/tools-refused.png and b/internal/guard/testdata/screens/tools-refused.png differ diff --git a/internal/guard/testdata/screens/tools-refused.xml b/internal/guard/testdata/screens/tools-refused.xml index 6a070d4f..8453a2b9 100644 --- a/internal/guard/testdata/screens/tools-refused.xml +++ b/internal/guard/testdata/screens/tools-refused.xml @@ -89,9 +89,9 @@ - - - + + + Tool @@ -99,19 +99,19 @@ - - - - + + + + - + Is this file the one it claims to be? - + - + @@ -130,10 +130,10 @@ - - - - + + + + @@ -156,8 +156,8 @@ - - + + @@ -236,33 +236,33 @@ - - - - Expected - - - - - - - - - - - - - - + + + + + Expected + + + + + + + + + + + + + not set - + - + @@ -272,7 +272,7 @@ - + diff --git a/internal/guard/testdata/screens/tools-result.png b/internal/guard/testdata/screens/tools-result.png index eaebd53a..ca35bfe5 100644 Binary files a/internal/guard/testdata/screens/tools-result.png and b/internal/guard/testdata/screens/tools-result.png differ diff --git a/internal/guard/testdata/screens/tools-result.xml b/internal/guard/testdata/screens/tools-result.xml index 0ddc90f9..fdd87f78 100644 --- a/internal/guard/testdata/screens/tools-result.xml +++ b/internal/guard/testdata/screens/tools-result.xml @@ -89,9 +89,9 @@ - - - + + + Tool @@ -99,19 +99,19 @@ - - - - + + + + - + Is this file the one it claims to be? - + - + @@ -130,10 +130,10 @@ - - - - + + + + @@ -156,8 +156,8 @@ - - + + @@ -224,38 +224,30 @@ - - - - Expected - - - - - - - - - - - - - - + + + + + Expected + + + + + + + + + + + + + ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad - - - - - - - - - + @@ -265,7 +257,7 @@ - + diff --git a/internal/guard/testdata/screens/tools.png b/internal/guard/testdata/screens/tools.png index e85e8df9..195cd160 100644 Binary files a/internal/guard/testdata/screens/tools.png and b/internal/guard/testdata/screens/tools.png differ diff --git a/internal/guard/testdata/screens/tools.xml b/internal/guard/testdata/screens/tools.xml index 41ccc36e..9d8bfc40 100644 --- a/internal/guard/testdata/screens/tools.xml +++ b/internal/guard/testdata/screens/tools.xml @@ -89,9 +89,9 @@ - - - + + + Tool @@ -99,19 +99,19 @@ - - - - + + + + - + Is this file the one it claims to be? - + - + @@ -130,10 +130,10 @@ - - - - + + + + @@ -156,8 +156,8 @@ - - + + @@ -227,33 +227,33 @@ - - - - Expected - - - - - - - - - - - - - - + + + + + Expected + + + + + + + + + + + + + not set - + - + @@ -263,7 +263,7 @@ - + diff --git a/internal/guard/tools_test.go b/internal/guard/tools_test.go index 4b384c41..b12f6ac8 100644 --- a/internal/guard/tools_test.go +++ b/internal/guard/tools_test.go @@ -158,6 +158,16 @@ func TestTheWindowSaysAVerdictAsTheCommandLineDoes(t *testing.T) { if said := (tool.Verdict{Outcome: tool.Unasked}).Said(); said != "" { t.Errorf("nothing was compared and the command line says %q", said) } + // A list of files compared with a checksum file, which is said about the + // list rather than about one value (checksum-check). + listMatch := tool.Verdict{Outcome: tool.Match, About: "SHA256SUMS", Listed: true} + if got, want := text.ToolListMatches(listMatch.About), listMatch.Said(); got != want { + t.Errorf("a checksum file that holds reads %q in the window and %q on the command line", got, want) + } + listMiss := tool.Verdict{Outcome: tool.Mismatch, About: "SHA256SUMS", Listed: true} + if got, want := text.ToolListDoesNotMatch(listMiss.About), listMiss.Said(); got != want { + t.Errorf("a checksum file that does not hold reads %q in the window and %q on the command line", got, want) + } } // TestTheToolsScreenOffersEveryToolWithEveryBox walks the registry, not a list: diff --git a/internal/guard/verify_test.go b/internal/guard/verify_test.go index 00f5ee49..d842856c 100644 --- a/internal/guard/verify_test.go +++ b/internal/guard/verify_test.go @@ -652,9 +652,9 @@ func TestACancelledVerifyStopsInTheWalkToo(t *testing.T) { // durability_test.go. The walk is the part of verify with no upper bound: // the loop above is as long as the manifest, this is as long as whatever // directory somebody pointed at. - body := functionSource(t, "internal/audit/audit.go", "walk") + body := functionSource(t, "internal/audit/walk.go", "Walk") if !strings.Contains(body, "ctx.Err()") { - t.Error("audit.walk never asks whether the run was cancelled, so Ctrl+C during the " + + t.Error("audit.Walk never asks whether the run was cancelled, so Ctrl+C during the " + "walk of a large tree does nothing until the walk is over") } } diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index 577a0d82..f62ea71a 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -109,6 +109,7 @@ func propertyField() Entry { {"a yes or no", format.Property{Name: "bom", Kind: format.PropertyBool, Default: "false", Detail: "Whether the file starts with a byte order mark."}}, {"a size", format.Property{Name: "member_size", Kind: format.PropertySize, Default: "1kb", Detail: "How big each file inside is."}}, {"free text", format.Property{Name: "password", Kind: format.PropertyText, Shape: "text", Detail: "What the archive is locked with."}}, + {"long free text", format.Property{Name: "expected", Kind: format.PropertyText, Long: true, Shape: "a checksum in hexadecimal", Detail: "The checksum the file should have."}}, } var states []State for _, d := range declared { diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index f8d67eb0..ee91ef7f 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -268,12 +268,22 @@ func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, // whose window and whose recipe file are two ways into one engine, so // somebody who finds a setting here has to be able to write it down. // For a tool that is a flag rather than a recipe key - see WrittenAs. - objects = append(objects, into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p), - tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control))) + field := into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p), + tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control)) + if longText(p) { + field = Wide(field) + } + objects = append(objects, field) } return fields, objects } +// longText is free text the declaration calls long, which takes the row the +// way a path does - see Property.Long. +func longText(p format.Property) bool { + return p.Long && p.Kind == format.PropertyText +} + // ShapedFor gives a control the width the value in it needs. // // A box is a promise about what goes in it. Measured off a render on @@ -292,8 +302,13 @@ func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, // Shrinking to the first alone clipped "worked out from the size" mid-word - // which is the same defect the other way up, since a box has to be able to // show what it is already showing. +// +// Text the declaration calls long is left to the row it is given - see +// Property.Long and DeclaredFields. func ShapedFor(p format.Property, control fyne.CanvasObject) fyne.CanvasObject { switch { + case longText(p): + return control case narrowOnAScreen(p): return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control) case p.Kind == format.PropertyText: diff --git a/internal/gui/parts/tokens.go b/internal/gui/parts/tokens.go index 850673b6..1d405bad 100644 --- a/internal/gui/parts/tokens.go +++ b/internal/gui/parts/tokens.go @@ -178,7 +178,9 @@ const ( // took the whole row on the sentence that free text has no length to // promise, and the owner's report from the running window was the // obvious one: why are they so long. A path is the one value that can - // be, so a path still takes the row. + // be, so a path still takes the row - and since 2026-09-30 so does a + // value its declaration calls long (format.Property.Long), a checksum of + // 64 digits, which this width showed about twenty of. // // One column of the grid since the prototype of 2026-09-23, the same as a // number: in a form of GridColumns columns a name sized for two numbers @@ -290,3 +292,10 @@ func EdgeWidth() float32 { return edgeWidth } // RowGutter is the room in front of the first thing on a list row, for a // guard asking whether the words start there or a column later. func RowGutter() float32 { return rowGutter } + +// NoteItemsShown is how many items of one note of a tool's result the window +// lists before it says how many more there are. Each item is a line drawn on +// the canvas, and a checksum file of a folder that moved can have a hundred +// thousand of them - drawn whole, that is a window the desktop calls not +// responding. The command line prints every one. +const NoteItemsShown = 20 diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 698d8b70..9ffe3863 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -749,10 +749,22 @@ "description": "Shown in the window. Carries one value, {{.Tool}}, which has to stay spelled exactly that way.", "other": "Written as the path after tfg tool {{.Tool}} on the command line." }, + "ToolListDoesNotMatch": { + "description": "Shown in the window. Carries one value, {{.About}}, which has to stay spelled exactly that way.", + "other": "Does not match: {{.About}} lists files that are not what it says." + }, + "ToolListMatches": { + "description": "Shown in the window. Carries one value, {{.About}}, which has to stay spelled exactly that way.", + "other": "Matches: every file {{.About}} lists is what it says." + }, "ToolMatches": { "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.About}}, {{.Got}}.", "other": "Matches: the {{.About}} is {{.Got}}, as expected." }, + "ToolMoreItems": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.More}}, {{.Tool}}.", + "other": "And {{.More}} more. tfg tool {{.Tool}} lists them all." + }, "ToolNothingYet": { "description": "Shown in the window.", "other": "Nothing worked out yet. Choose what to work on and press Run." @@ -763,7 +775,7 @@ }, "ToolStopped": { "description": "Shown in the window.", - "other": "Stopped before it finished. A tool only reads, so nothing was changed." + "other": "Stopped before it finished. Nothing was written or changed." }, "UseSmallestSize": { "description": "Shown in the window. Carries one value, {{.Size}}, which has to stay spelled exactly that way.", diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index 601fda94..8670c275 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -186,10 +186,13 @@ "ToolDoesNotMatch": { "other": "Nie zgadza się: {{.About}} to {{.Got}}, a oczekiwano {{.Wanted}}." }, "ToolFlag": { "other": "W linii poleceń zapisuje się jako --{{.Key}} po tfg tool." }, "ToolInputWrittenAs": { "other": "W linii poleceń zapisuje się jako ścieżka po tfg tool {{.Tool}}." }, + "ToolListDoesNotMatch": { "other": "Nie zgadza się: {{.About}} wymienia pliki, które nie są takie, jak tam zapisano." }, + "ToolListMatches": { "other": "Zgadza się: każdy plik wymieniony w {{.About}} jest taki, jak tam zapisano." }, "ToolMatches": { "other": "Zgadza się: {{.About}} to {{.Got}}, tak jak oczekiwano." }, + "ToolMoreItems": { "other": "I jeszcze {{.More}}. tfg tool {{.Tool}} wypisuje wszystkie." }, "ToolNothingYet": { "other": "Jeszcze nic nie policzono. Wybierz, na czym narzędzie ma pracować, i naciśnij Uruchom." }, "ToolReading": { "other": "Przeczytano {{.Done}} z {{.Total}}." }, - "ToolStopped": { "other": "Zatrzymano przed końcem. Narzędzie tylko czyta, więc nic nie zostało zmienione." }, + "ToolStopped": { "other": "Zatrzymano przed końcem. Nic nie zostało zapisane ani zmienione." }, "UseSmallestSize": { "other": "Użyj najmniejszego rozmiaru, {{.Size}}" }, "WillGoTo": { "other": "zapis do {{.Directory}}" }, "WindowRefused": { "other": "Nie udało się otworzyć okna. Rysuje ono przez OpenGL 2.1, a biblioteka graficzna nie zdołała uzyskać go od sterownika na tym komputerze. Komunikat biblioteki: {{.Cause}}. Wszystko, co robi okno, jest też dostępne w linii komend - uruchom „tfg --help” - i ona nie potrzebuje sterownika grafiki. Żeby mieć okno, użyj sterownika grafiki obsługującego OpenGL 2.1." }, diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index bd33f7f2..a3d2149e 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -609,6 +609,11 @@ "hash": "sha256-be929d76067d", "other": "The size limit your upload form declares. This set takes one step either side of it - for a file at every distance, run the size-boundaries preset." }, + "Detail.tool/checksum-write.algorithm": { + "description": "The sentence under the algorithm setting of the checksum-write tool, after what it takes.", + "hash": "sha256-bc3d55ed0c3c", + "other": "Which checksum to write. sha256 is the one sha256sum and most release pages use." + }, "Detail.tool/checksum.algorithm": { "description": "The sentence under the algorithm setting of the checksum tool, after what it takes.", "hash": "sha256-b2c6c5ddeaa1", @@ -624,6 +629,16 @@ "hash": "sha256-7115a778ab47", "other": "Document properties" }, + "Input.checksum-check.checksum_file": { + "description": "The sentence under the checksum_file box of the checksum-check tool.", + "hash": "sha256-90d7baf0d228", + "other": "The checksum file. The paths in it are read from the folder it is in, and nothing is written." + }, + "Input.checksum-write.folder": { + "description": "The sentence under the folder box of the checksum-write tool.", + "hash": "sha256-3040f185111b", + "other": "The folder to list. Every file under it is read, and the checksum file is written into it." + }, "Input.checksum.file": { "description": "The sentence under the file box of the checksum tool.", "hash": "sha256-a87fb4cce79e", @@ -699,6 +714,11 @@ "hash": "sha256-4c8906cf76f5", "other": "Channels" }, + "Label.checksum_file": { + "description": "The name beside the box of what the checksum-check tool works on.", + "hash": "sha256-6413f83e729a", + "other": "Checksum file" + }, "Label.columns": { "description": "The name beside the box of a setting a recipe writes as columns.", "hash": "sha256-53aade77cd69", @@ -799,6 +819,11 @@ "hash": "sha256-50009ce1da4d", "other": "File" }, + "Label.folder": { + "description": "The name beside the box of what the checksum-write tool works on.", + "hash": "sha256-74ccd4330384", + "other": "Folder" + }, "Label.format": { "description": "The name beside the box of a setting a recipe writes as format.", "hash": "sha256-2f343666aaa8", @@ -1064,6 +1089,106 @@ "hash": "sha256-eb266034b6a8", "other": "the password, in plain text" }, + "Tool.checksum-check.Detail": { + "description": "One sentence under the question of the checksum-check tool, saying what it does.", + "hash": "sha256-3480ee8eb9ae", + "other": "Reads a checksum file - SHA256SUMS, MD5SUMS or the tagged kind shasum writes - and checks every file it lists in the folder the checksum file is in. Files it does not list are not looked at, the same as sha256sum -c." + }, + "Tool.checksum-check.Note.changed": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-3f2770c1e166", + "other": "Changed while it was being read, so no checksum is given:" + }, + "Tool.checksum-check.Note.checked": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-cb6fcab36d77", + "other": "Files checked:" + }, + "Tool.checksum-check.Note.itself": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-5e9a4797e536", + "other": "The checksum file itself, which cannot hold its own checksum - not checked:" + }, + "Tool.checksum-check.Note.mismatched": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-d0bf74f3a786", + "other": "Not what the checksum file says:" + }, + "Tool.checksum-check.Note.missing": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-fd0f2e8d9bb6", + "other": "Listed and not there:" + }, + "Tool.checksum-check.Note.not_a_file": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-eee42d6fa58b", + "other": "Listed and not a file - a folder, a pipe, a device or a socket - so not read:" + }, + "Tool.checksum-check.Note.not_checksums": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-e297bc7fabca", + "other": "Lines that are not checksums, not checked:" + }, + "Tool.checksum-check.Note.outside": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-5751322174e9", + "other": "Listed with a path that leaves the folder of the checksum file, so not read:" + }, + "Tool.checksum-check.Note.twice": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-7f2a58a6ec1c", + "other": "Listed more than once - every line was checked:" + }, + "Tool.checksum-check.Note.unknown": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-ab57ed3087cc", + "other": "Lines of an algorithm this tool does not work out, not checked:" + }, + "Tool.checksum-check.Note.unreadable": { + "description": "A line of the result of the checksum-check tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-37ad21c9b5c7", + "other": "Listed and could not be read:" + }, + "Tool.checksum-check.Question": { + "description": "The question the checksum-check tool answers - its name in the list of tools and the title over it.", + "hash": "sha256-b3706c34f494", + "other": "Is every file still what its checksum file says?" + }, + "Tool.checksum-write.Detail": { + "description": "One sentence under the question of the checksum-write tool, saying what it does.", + "hash": "sha256-78015d348f2a", + "other": "Writes the checksum of every file in a folder into one file beside them, SHA256SUMS for sha256. Check it later with tfg tool checksum-check, or with sha256sum -c in that folder." + }, + "Tool.checksum-write.Note.links": { + "description": "A line of the result of the checksum-write tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-e1078e9b1204", + "other": "Left out, because they are links and a link is not followed:" + }, + "Tool.checksum-write.Note.listed": { + "description": "A line of the result of the checksum-write tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-6d35a92a455f", + "other": "Files in it:" + }, + "Tool.checksum-write.Note.others": { + "description": "A line of the result of the checksum-write tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-71cbbcf6dd9f", + "other": "Left out, because they are not files - a pipe, a device, a socket or a junction - and reading one may never end:" + }, + "Tool.checksum-write.Note.ours": { + "description": "A line of the result of the checksum-write tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-4d087172bfc7", + "other": "Left out, because a run of this program left them half written:" + }, + "Tool.checksum-write.Note.written": { + "description": "A line of the result of the checksum-write tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-a460d3635ba3", + "other": "Written:" + }, + "Tool.checksum-write.Question": { + "description": "The question the checksum-write tool answers - its name in the list of tools and the title over it.", + "hash": "sha256-3d55c8d03d3a", + "other": "How will I know later that nothing in this folder changed?" + }, "Tool.checksum.Detail": { "description": "One sentence under the question of the checksum tool, saying what it does.", "hash": "sha256-f3e1a6e1c983", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 78608bbd..f34b3e05 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -121,9 +121,12 @@ "Detail.preset/upload-validation.deny": { "hash": "sha256-f9e5c633d218", "other": "Jakie rozszerzenia twój formularz ma odrzucać. Rozszerzenie, dla którego ta wersja nie ma formatu, i tak dostaje plik o tej nazwie, ze zwykłym tekstem w środku." }, "Detail.preset/upload-validation.far-over": { "hash": "sha256-6a0093c6d7a0", "other": "Jak daleko ponad limit sięga jeden duży plik. Wyłącz tam, gdzie zapis kilkukrotności limitu nie jest wart miejsca na dysku." }, "Detail.preset/upload-validation.limit": { "hash": "sha256-be929d76067d", "other": "Limit rozmiaru, który deklaruje twój formularz przesyłania. Ten zestaw robi jeden krok po obu stronach - żeby dostać plik w każdej odległości, uruchom preset size-boundaries." }, + "Detail.tool/checksum-write.algorithm": { "hash": "sha256-bc3d55ed0c3c", "other": "Który skrót zapisać. sha256 to ten, którego używa sha256sum i większość stron z wydaniami." }, "Detail.tool/checksum.algorithm": { "hash": "sha256-b2c6c5ddeaa1", "other": "Które sumy kontrolne policzyć: md5, sha1, sha256, sha512 albo crc32. Tylko sha256 i sha512 nadal pokazują, że nikt celowo nie zmienił pliku. crc32 to ta, której używają ZIP i PNG, a nie ta, którą wypisuje cksum." }, "Detail.tool/checksum.expected": { "hash": "sha256-d2a5105f8783", "other": "Suma kontrolna, jaką plik powinien mieć. Algorytm rozpoznaje się po jej długości i liczy go, nawet jeśli nie wybrano go wyżej." }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, + "Input.checksum-check.checksum_file": { "hash": "sha256-90d7baf0d228", "other": "Plik sum kontrolnych. Ścieżki w nim są czytane z folderu, w którym leży, i nic nie jest zapisywane." }, + "Input.checksum-write.folder": { "hash": "sha256-3040f185111b", "other": "Folder do spisania. Każdy plik w nim jest czytany, a plik sum kontrolnych jest zapisywany do niego." }, "Input.checksum.file": { "hash": "sha256-a87fb4cce79e", "other": "Plik, którego sumy kontrolne mają zostać policzone. Jest tylko czytany." }, "Joint.format/avif.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, "Joint.format/gif.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, @@ -139,6 +142,7 @@ "Label.bulk": { "hash": "sha256-3140100df131", "other": "Przesyłanie masowe" }, "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, "Label.channels": { "hash": "sha256-4c8906cf76f5", "other": "Kanały" }, + "Label.checksum_file": { "hash": "sha256-6413f83e729a", "other": "Plik sum kontrolnych" }, "Label.columns": { "hash": "sha256-53aade77cd69", "other": "Kolumny" }, "Label.compression": { "hash": "sha256-278b1b2a9c84", "other": "Kompresja" }, "Label.content": { "hash": "sha256-47bd29075f8b", "other": "Zawartość" }, @@ -159,6 +163,7 @@ "Label.expected": { "hash": "sha256-ca99b7f1b14e", "other": "Oczekiwana suma" }, "Label.far-over": { "hash": "sha256-874367c30d4e", "other": "Daleko ponad limit" }, "Label.file": { "hash": "sha256-50009ce1da4d", "other": "Plik" }, + "Label.folder": { "hash": "sha256-74ccd4330384", "other": "Folder" }, "Label.format": { "hash": "sha256-2f343666aaa8", "other": "Format" }, "Label.formats": { "hash": "sha256-9f01769a4278", "other": "Formaty" }, "Label.formatting": { "hash": "sha256-29d4198e41d8", "other": "Układ" }, @@ -212,6 +217,26 @@ "Shape.sizes separated by commas": { "hash": "sha256-f7e5e8c54e6f", "other": "rozmiary rozdzielone przecinkami" }, "Shape.the id of a format, as tfg formats lists them": { "hash": "sha256-b6784882fdeb", "other": "identyfikator formatu, tak jak wypisuje go tfg formats" }, "Shape.the password, in plain text": { "hash": "sha256-eb266034b6a8", "other": "hasło, zwykłym tekstem" }, + "Tool.checksum-check.Detail": { "hash": "sha256-3480ee8eb9ae", "other": "Czyta plik sum kontrolnych - SHA256SUMS, MD5SUMS albo w postaci ze znacznikami, którą pisze shasum - i sprawdza każdy wymieniony w nim plik w folderze, w którym ten plik leży. Plików, których nie wymienia, nie ogląda, tak samo jak sha256sum -c." }, + "Tool.checksum-check.Note.changed": { "hash": "sha256-3f2770c1e166", "other": "Zmienione w trakcie czytania, więc bez sumy kontrolnej:" }, + "Tool.checksum-check.Note.checked": { "hash": "sha256-cb6fcab36d77", "other": "Sprawdzone pliki:" }, + "Tool.checksum-check.Note.itself": { "hash": "sha256-5e9a4797e536", "other": "Sam plik sum kontrolnych, który nie może zawierać własnej sumy - pominięty:" }, + "Tool.checksum-check.Note.mismatched": { "hash": "sha256-d0bf74f3a786", "other": "Inne, niż podaje plik sum kontrolnych:" }, + "Tool.checksum-check.Note.missing": { "hash": "sha256-fd0f2e8d9bb6", "other": "Wymienione, a nieobecne:" }, + "Tool.checksum-check.Note.not_a_file": { "hash": "sha256-eee42d6fa58b", "other": "Wymienione, ale to nie plik - folder, potok, urządzenie albo gniazdo - więc nieczytane:" }, + "Tool.checksum-check.Note.not_checksums": { "hash": "sha256-e297bc7fabca", "other": "Linie, które nie są sumami kontrolnymi, pominięte:" }, + "Tool.checksum-check.Note.outside": { "hash": "sha256-5751322174e9", "other": "Wymienione ze ścieżką wychodzącą poza folder pliku sum kontrolnych, więc nieczytane:" }, + "Tool.checksum-check.Note.twice": { "hash": "sha256-7f2a58a6ec1c", "other": "Wymienione więcej niż raz - sprawdzono każdą linię:" }, + "Tool.checksum-check.Note.unknown": { "hash": "sha256-ab57ed3087cc", "other": "Linie algorytmu, którego to narzędzie nie liczy, pominięte:" }, + "Tool.checksum-check.Note.unreadable": { "hash": "sha256-37ad21c9b5c7", "other": "Wymienione, ale nie dało się ich przeczytać:" }, + "Tool.checksum-check.Question": { "hash": "sha256-b3706c34f494", "other": "Czy każdy plik jest wciąż taki, jak podaje jego plik sum kontrolnych?" }, + "Tool.checksum-write.Detail": { "hash": "sha256-78015d348f2a", "other": "Zapisuje sumę kontrolną każdego pliku w folderze do jednego pliku obok nich, SHA256SUMS dla sha256. Sprawdzisz go później przez tfg tool checksum-check albo sha256sum -c w tym folderze." }, + "Tool.checksum-write.Note.links": { "hash": "sha256-e1078e9b1204", "other": "Pominięte, bo to dowiązania, a narzędzie za nimi nie idzie:" }, + "Tool.checksum-write.Note.listed": { "hash": "sha256-6d35a92a455f", "other": "Plików w nim:" }, + "Tool.checksum-write.Note.others": { "hash": "sha256-71cbbcf6dd9f", "other": "Pominięte, bo to nie pliki - potok, urządzenie, gniazdo albo junction - a ich czytanie może się nigdy nie skończyć:" }, + "Tool.checksum-write.Note.ours": { "hash": "sha256-4d087172bfc7", "other": "Pominięte, bo to niedokończone pliki po przerwanym przebiegu tego programu:" }, + "Tool.checksum-write.Note.written": { "hash": "sha256-a460d3635ba3", "other": "Zapisano:" }, + "Tool.checksum-write.Question": { "hash": "sha256-3d55c8d03d3a", "other": "Jak później poznam, że nic w tym folderze się nie zmieniło?" }, "Tool.checksum.Detail": { "hash": "sha256-f3e1a6e1c983", "other": "Liczy sumę kontrolną pliku i porównuje ją z tą, którą dostałeś - ze strony pobierania, z opisu wydania albo od kogoś z zespołu." }, "Tool.checksum.Question": { "hash": "sha256-f7c87f2771e3", "other": "Czy ten plik jest tym, za który się podaje?" }, "Unit.bytes": { "hash": "sha256-277089d91c0b", "other": "bajtów" }, diff --git a/internal/gui/text/registry.go b/internal/gui/text/registry.go index 7c0a9dec..7bf28c90 100644 --- a/internal/gui/text/registry.go +++ b/internal/gui/text/registry.go @@ -83,9 +83,10 @@ func NoteKey(preset, about string) string { return "Note." + preset + "." + a // The words of a tool, under keys of their own rather than beside a preset's: // a tool and a preset may one day share a name, and "Question." would then // be one key for two sentences. -func ToolQuestionKey(id string) string { return "Tool." + id + ".Question" } -func ToolDetailKey(id string) string { return "Tool." + id + ".Detail" } -func InputKey(id, name string) string { return "Input." + id + "." + name } +func ToolQuestionKey(id string) string { return "Tool." + id + ".Question" } +func ToolDetailKey(id string) string { return "Tool." + id + ".Detail" } +func InputKey(id, name string) string { return "Input." + id + "." + name } +func ToolNoteKey(id, note string) string { return "Tool." + id + ".Note." + note } // lookup is say for a sentence whose key is made of identifiers and whose // English comes from a registry rather than from this package. Nothing to say @@ -151,6 +152,9 @@ func ToolDetail(id, english string) string { return lookup(ToolDetailKey(id), en // ToolInput is the sentence beside what a tool works on. func ToolInput(id, name, english string) string { return lookup(InputKey(id, name), english) } +// ToolNote is a line a tool's result may carry, before its items. +func ToolNote(id, note, english string) string { return lookup(ToolNoteKey(id, note), english) } + // HumanBytes is core.HumanBytes with the decimal mark of the window's language. // // The pseudo language keeps the mark the number was written with: it disguises diff --git a/internal/gui/text/registrywords.go b/internal/gui/text/registrywords.go index 9f97f79f..81c8cae6 100644 --- a/internal/gui/text/registrywords.go +++ b/internal/gui/text/registrywords.go @@ -86,6 +86,9 @@ func (w registryWords) tool(d tool.Descriptor) { w.add(LabelKey(in.Name), EnglishLabel(in.Name), "The name beside the box of what "+whose+" works on.") w.add(InputKey(d.ID, in.Name), in.Detail, "The sentence under the "+in.Name+" box of "+whose+".") } + for _, n := range d.Notes { + w.add(ToolNoteKey(d.ID, n.ID), n.Says, "A line of the result of "+whose+", ending in a colon - what follows it is a list of names or one number.") + } w.settings(ToolOwner(d.ID), whose, d.Settings) } diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 1bbb827a..29ef61d9 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -921,10 +921,18 @@ func ToolReading(done, total string) string { return sayf("ToolReading", "Read {{.Done}} of {{.Total}}.", map[string]any{"Done": done, "Total": total}) } -// ToolStopped is said when Cancel stopped a tool. A tool only reads, so -// nothing is left half done anywhere. +// ToolStopped is said when Cancel stopped a tool. A tool that writes writes +// last, and under a name of its own until the file is whole, so a stop leaves +// nothing half done anywhere. Until 2026-09-30 this said a tool only reads, +// which checksum-write made untrue. func ToolStopped() string { - return say("ToolStopped", "Stopped before it finished. A tool only reads, so nothing was changed.") + return say("ToolStopped", "Stopped before it finished. Nothing was written or changed.") +} + +// ToolMoreItems ends a list the window cuts short. The command line prints +// every item, so it is named as the place to see them. +func ToolMoreItems(more int, tool string) string { + return sayf("ToolMoreItems", "And {{.More}} more. tfg tool {{.Tool}} lists them all.", map[string]any{"More": more, "Tool": tool}) } // ToolCopied says a value is on the clipboard, since pressing Copy changes @@ -945,6 +953,16 @@ func ToolDoesNotMatch(about, got, wanted string) string { map[string]any{"About": about, "Got": got, "Wanted": wanted}) } +// ToolListMatches and ToolListDoesNotMatch are the verdict about a checksum +// file - tool.Verdict.Said when Listed - in the window's language. +func ToolListMatches(about string) string { + return sayf("ToolListMatches", "Matches: every file {{.About}} lists is what it says.", map[string]any{"About": about}) +} + +func ToolListDoesNotMatch(about string) string { + return sayf("ToolListDoesNotMatch", "Does not match: {{.About}} lists files that are not what it says.", map[string]any{"About": about}) +} + // ToolInputWrittenAs is what the button beside the box of what a tool works // on says about writing it down: a path, with no flag in front of it. func ToolInputWrittenAs(tool string) string { diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index 0be84b17..df4c97db 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -40,11 +40,11 @@ func Open(h Host) fyne.Size { pre := NewPreset(h) rec := NewRecipe(h) tools := NewTools(h) - runners := []*runner{gen.runner, pre.runner, rec.runner} + busyScreens := []*busy{gen.runner.busy, pre.runner.busy, rec.runner.busy, tools.busy} // The way out is wired below, once everything it stops exists. The screen // is built now, so it is handed a way to reach it rather than the thing. var leave func() - prefs := NewPreferences(h, func() { leave() }, func() bool { return anyBusy(runners) }) + prefs := NewPreferences(h, func() { leave() }, func() bool { return anyBusy(busyScreens) }) // Tabs across the top rather than buttons at the foot, reported from use on // 2026-08-11. The way between the screens used to sit in the row of actions @@ -142,13 +142,14 @@ func Open(h Host) fyne.Size { // the middle of a file. // One wait for quiet for the whole window, told by every screen, and // stopped with them when the window closes - see tidy.go. - quiet := tidyWhenLeftAlone(h, runners...) + quiet := tidyWhenLeftAlone(h, busyScreens, gen.runner, pre.runner, rec.runner) leave = closeCleanly(h, []interface{ Stop() }{gen, pre, rec, tools, quiet}, working, &showing) // Restart now stands down while any screen is making files, and stands up // again when it stops - told rather than asked, so the button is right - // the moment a run ends while the Preferences tab is on show. - for _, r := range runners { - r.busy.changed = prefs.BusyChanged + // the moment a run ends while the Preferences tab is on show. The Tools tab + // tells it too since 2026-09-30, when a tool began to write. + for _, b := range busyScreens { + b.changed = prefs.BusyChanged } offerSettling(h, []interface{ Settled() }{gen, pre, rec, tools}) offerHolding(h, []interface{ HoldBeforeFinishing(func()) }{gen, pre, rec}) diff --git a/internal/gui/window/tidy.go b/internal/gui/window/tidy.go index 0a6ed10e..16a7f56d 100644 --- a/internal/gui/window/tidy.go +++ b/internal/gui/window/tidy.go @@ -49,18 +49,20 @@ const tidyAfterFrame = 12 * time.Second // tidyWhenLeftAlone gives the window its one wait for quiet and has every // screen tell it at every reading of the form - which every change and every // run makes. -func tidyWhenLeftAlone(h Host, screens ...*runner) *tidy { - t := newTidy(h, func() bool { return anyBusy(screens) }) +func tidyWhenLeftAlone(h Host, working []*busy, screens ...*runner) *tidy { + t := newTidy(h, func() bool { return anyBusy(working) }) for _, r := range screens { r.touched = t.touch } return t } -// anyBusy says whether work owns any of these screens. -func anyBusy(screens []*runner) bool { - for _, r := range screens { - if r.busy.occupied { +// anyBusy says whether work owns any of these screens - a run, a preview, or +// a tool on the Tools tab. The tool was left out until 2026-09-30 on the +// reasoning that a tool only reads, and checksum-write writes a file. +func anyBusy(working []*busy) bool { + for _, b := range working { + if b.occupied { return true } } diff --git a/internal/gui/window/tools.go b/internal/gui/window/tools.go index 1e4ba4f8..f2d0448e 100644 --- a/internal/gui/window/tools.go +++ b/internal/gui/window/tools.go @@ -185,7 +185,7 @@ func (t *Tools) onChosen(question string) { box := entry("", "") t.form.inputs[in.Name] = box t.work.Add(parts.Wide(t.fields.Add(in.Name, text.SettingLabel(in.Name), text.ToolInput(d.ID, in.Name, in.Detail), - t.tips.Say(text.ToolInputWrittenAs(d.ID)), chooserFor(box, t.host.ChooseFile)))) + t.tips.Say(text.ToolInputWrittenAs(d.ID)), chooserFor(box, t.pickerFor(in.Kind))))) } settings, objects := parts.DeclaredFields(text.ToolOwner(d.ID), d.Settings, t.fields, t.tips) t.form.settings = settings @@ -193,7 +193,16 @@ func (t *Tools) onChosen(question string) { t.work.Add(o) } t.work.Refresh() - t.showResult(nil) + t.showResult(d, nil) +} + +// pickerFor is the window's picker for what an input names: a folder for a +// folder, a file for everything else. +func (t *Tools) pickerFor(kind tool.InputKind) func(func(string)) { + if kind == tool.Folder { + return t.host.ChooseDirectory + } + return t.host.ChooseFile } // PressGenerate runs the chosen tool - the name the window's keyboard asks @@ -215,14 +224,14 @@ func (t *Tools) progressed(done, total int64) { showOn(t.status, text.ToolReading(text.HumanBytes(done), text.HumanBytes(total))) } -func (t *Tools) finished(r tool.Result, err error) { +func (t *Tools) finished(d tool.Descriptor, r tool.Result, err error) { t.busy.set(false, busyFace{}) showOn(t.status, "") if err != nil { t.refuse(err) return } - t.showResult(&r) + t.showResult(d, &r) } // refuse puts a refusal under the box it is about, or at the foot of the @@ -245,9 +254,9 @@ func (t *Tools) refuse(err error) { // The canvas is told once, after the last change - a deferred word to it read // as coming before the changes to the guard that holds this // (TestABoxThatGainsOrLosesAPieceSaysSo), which cannot see when a defer runs. -func (t *Tools) showResult(r *tool.Result) { +func (t *Tools) showResult(d tool.Descriptor, r *tool.Result) { t.result.RemoveAll() - for _, o := range t.resultObjects(r) { + for _, o := range t.resultObjects(d, r) { t.result.Add(o) } t.result.Refresh() @@ -255,7 +264,7 @@ func (t *Tools) showResult(r *tool.Result) { // resultObjects is what a run found, as the section shows it: the rows and the // verdict, or a sentence saying nothing has run yet. -func (t *Tools) resultObjects(r *tool.Result) []fyne.CanvasObject { +func (t *Tools) resultObjects(d tool.Descriptor, r *tool.Result) []fyne.CanvasObject { if r == nil { return []fyne.CanvasObject{parts.Prose(text.ToolNothingYet())} } @@ -264,18 +273,55 @@ func (t *Tools) resultObjects(r *tool.Result) []fyne.CanvasObject { rows.Add(t.resultRow(row)) } out := []fyne.CanvasObject{rows} + for _, n := range r.Notes { + out = append(out, noteObjects(d, n)...) + } switch r.Verdict.Outcome { case tool.Match: - out = append(out, parts.Prose(text.ToolMatches(r.Verdict.About, r.Verdict.Got))) + out = append(out, parts.Prose(verdictSaid(r.Verdict))) case tool.Mismatch: verdict := parts.NewErrorArea() - verdict.Say(text.ToolDoesNotMatch(r.Verdict.About, r.Verdict.Got, r.Verdict.Wanted)) + verdict.Say(verdictSaid(r.Verdict)) out = append(out, verdict.Object()) case tool.Unasked: } return out } +// verdictSaid is tool.Verdict.Said in the window's language. +func verdictSaid(v tool.Verdict) string { + switch { + case v.Outcome == tool.Match && v.Listed: + return text.ToolListMatches(v.About) + case v.Outcome == tool.Mismatch && v.Listed: + return text.ToolListDoesNotMatch(v.About) + case v.Outcome == tool.Match: + return text.ToolMatches(v.About, v.Got) + } + return text.ToolDoesNotMatch(v.About, v.Got, v.Wanted) +} + +// noteObjects is one note of a result as the section shows it: its line and +// its items, one item on the same line, several under it - and no more of +// them than NoteItemsShown, with how many were left out and where to see +// them. Each item goes through core.Shown, the same as on the command line, +// since an item is usually a file name and a name may hold a line break. +func noteObjects(d tool.Descriptor, n tool.Noted) []fyne.CanvasObject { + says := text.ToolNote(d.ID, n.ID, d.NoteSays(n.ID)) + if len(n.Items) == 1 { + return []fyne.CanvasObject{parts.Prose(says + " " + core.Shown(n.Items[0]))} + } + out := []fyne.CanvasObject{parts.Prose(says)} + for i, item := range n.Items { + if i == parts.NoteItemsShown { + out = append(out, parts.Prose(text.ToolMoreItems(len(n.Items)-i, d.ID))) + break + } + out = append(out, parts.Prose(core.Shown(item))) + } + return out +} + // resultRow is one row of a result: its first cell as the name in the column // of names, the rest beside it, and a way to copy what is beside it. The name // is data - an algorithm, a file - and is shown as it is (G8). @@ -327,7 +373,7 @@ func startTool(d tool.Descriptor, in tool.Request, screen *Tools) *toolJob { go func() { defer cancel() result, err := d.Start(ctx, in, progress) - fyne.Do(func() { screen.finished(result, err) }) + fyne.Do(func() { screen.finished(d, result, err) }) close(job.done) }() return job diff --git a/internal/tool/checksum/check.go b/internal/tool/checksum/check.go new file mode 100644 index 00000000..9eeaa29c --- /dev/null +++ b/internal/tool/checksum/check.go @@ -0,0 +1,271 @@ +package checksum + +import ( + "context" + "errors" + "io/fs" + "os" + "path" + "path/filepath" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/audit" + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// CheckID is the tool that checks the files a checksum file lists. +const CheckID = "checksum-check" + +// InputChecksumFile is the checksum file checksum-check reads. +const InputChecksumFile = "checksum_file" + +// The notes of checksum-check. The first is a count, the next six are what +// makes a check fail, and the last four are named without failing it. +const ( + noteChecked = "checked" + noteMismatched = "mismatched" + noteMissing = "missing" + noteNotAFile = "not_a_file" + noteOutside = "outside" + noteUnreadable = "unreadable" + noteChanged = "changed" + noteItself = "itself" + noteTwice = "twice" + noteNotChecksums = "not_checksums" + noteUnknown = "unknown" +) + +func init() { + tool.Register(tool.Descriptor{ + ID: CheckID, + Question: "Is every file still what its checksum file says?", + Detail: "Reads a checksum file - SHA256SUMS, MD5SUMS or the tagged kind shasum writes - and checks every file it lists " + + "in the folder the checksum file is in. Files it does not list are not looked at, the same as sha256sum -c.", + Inputs: []tool.Input{{ + Name: InputChecksumFile, Kind: tool.File, + Detail: "The checksum file. The paths in it are read from the folder it is in, and nothing is written.", + }}, + Notes: []tool.Note{ + {ID: noteChecked, Says: "Files checked:"}, + {ID: noteMismatched, Says: "Not what the checksum file says:"}, + {ID: noteMissing, Says: "Listed and not there:"}, + {ID: noteNotAFile, Says: "Listed and not a file - a folder, a pipe, a device or a socket - so not read:"}, + {ID: noteOutside, Says: "Listed with a path that leaves the folder of the checksum file, so not read:"}, + {ID: noteUnreadable, Says: "Listed and could not be read:"}, + {ID: noteChanged, Says: "Changed while it was being read, so no checksum is given:"}, + {ID: noteItself, Says: "The checksum file itself, which cannot hold its own checksum - not checked:"}, + {ID: noteTwice, Says: "Listed more than once - every line was checked:"}, + {ID: noteNotChecksums, Says: "Lines that are not checksums, not checked:"}, + {ID: noteUnknown, Says: "Lines of an algorithm this tool does not work out, not checked:"}, + }, + Run: runCheck, + }) +} + +// failing are the notes that make a check fail. +var failing = []string{noteMismatched, noteMissing, noteNotAFile, noteOutside, noteUnreadable, noteChanged} + +// Checked is what --json prints about a checksum file checked. +type Checked struct { + File string `json:"file"` + Checked int `json:"checked"` + Matched int `json:"matched"` + Problems []Problem `json:"problems"` + // NotChecked are the lines passed over without failing the check. + NotChecked NotChecked `json:"not_checked"` +} + +// Problem is one listed file that is not what the checksum file says. +type Problem struct { + Path string `json:"path"` + // Kind is one of the failing notes: mismatched, missing, not_a_file, + // outside, unreadable, changed. + Kind string `json:"kind"` + Line int `json:"line"` + Expected string `json:"expected,omitempty"` + Got string `json:"got,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// NotChecked are the lines of a checksum file named without failing a check. +type NotChecked struct { + Itself []string `json:"itself"` + Twice []string `json:"twice"` + NotChecksums []int `json:"not_checksums"` + Unknown []string `json:"unknown"` +} + +// target is one listed file on its way to being read: where it is on the +// disk, or why it is not read at all. +type target struct { + entry Listed + full string + // refused is the note a path gets without being read - outside, or the + // checksum file itself - and why. + refused, why string +} + +// runCheck reads a checksum file and checks every file it lists. +func runCheck(ctx context.Context, in tool.Request, progress tool.Progress) (tool.Result, error) { + sumsPath := in.Inputs[InputChecksumFile] + parsed, self, err := readSums(sumsPath) + if err != nil { + var notAFile *NotAFileError + if errors.As(err, ¬AFile) { + notAFile.Input = InputChecksumFile + } + return tool.Result{}, err + } + dir := filepath.Dir(sumsPath) + targets := placeAll(core.NewBoundary(dir), sumsPath, parsed.Listed) + + var total int64 + for _, t := range targets { + if info, statErr := os.Stat(t.full); t.refused == "" && statErr == nil { + total += info.Size() + } + } + tally := audit.NewTally(func(done int64) { progress(done, total) }) + answers, stopped := audit.InOrder(ctx, len(targets), func(i int, scratch []byte) Problem { + return checkOne(ctx, targets[i], self, scratch, tally) + }) + if stopped != nil { + return tool.Result{}, stopped + } + return checkResult(sumsPath, parsed, targets, answers), nil +} + +// placeAll is where each listed file is on the disk, settled in order on one +// goroutine before anything is read - the same rule verify keeps, for the +// same reason (audit.InOrder): the workers are never handed a refusal. +func placeAll(b core.Boundary, sumsPath string, all []Listed) []target { + out := make([]target, 0, len(all)) + for _, e := range all { + t := target{entry: e} + if problem := core.ContainmentProblem(e.Path); problem != "" { + t.refused, t.why = noteOutside, problem + out = append(out, t) + continue + } + t.full = filepath.Join(b.Dir(), filepath.FromSlash(e.Path)) + switch { + case b.Escapes(t.full): + // Said as what is known. A junction on Windows is refused here + // even when it points inside, because the system gives no way to + // follow one (measured on 2026-09-30, O265), so "leads out" would + // claim more than was found. + t.refused, t.why = noteOutside, "a link or a junction on the way leads out of the folder, or cannot be followed to tell" + case filepath.Clean(t.full) == filepath.Clean(sumsPath): + t.refused = noteItself + } + out = append(out, t) + } + return out +} + +// checkOne reads one listed file and says what it came to. The zero Kind is a +// file that is what the checksum file says. +func checkOne(ctx context.Context, t target, self os.FileInfo, scratch []byte, tally *audit.Tally) Problem { + p := Problem{Path: t.entry.Path, Line: t.entry.Line, Expected: t.entry.Sum} + if t.refused != "" { + p.Kind, p.Detail = t.refused, t.why + return p + } + sums, opened, err := digest(ctx, t.full, map[string]bool{t.entry.Algorithm: true}, scratch, func(read, _ int64) { tally.Add(read) }) + var notAFile *NotAFileError + var moved *ChangedError + switch { + case err == nil && os.SameFile(opened, self): + // Reached through a link or another spelling, so the path above did + // not see it. Asked of the two open files, which on Windows is the + // only way the identity is there to compare. + p.Kind = noteItself + case err == nil && sums[t.entry.Algorithm] != t.entry.Sum: + p.Kind, p.Got = noteMismatched, sums[t.entry.Algorithm] + case err == nil: + case errors.Is(err, fs.ErrNotExist): + p.Kind = noteMissing + case errors.As(err, ¬AFile): + p.Kind = noteNotAFile + case errors.As(err, &moved): + p.Kind = noteChanged + default: + p.Kind, p.Detail = noteUnreadable, err.Error() + } + return p +} + +// checkResult is what the answers come to: a note for each kind of problem, +// in the order of the lines, and the verdict about the checksum file. +func checkResult(sumsPath string, parsed Sums, targets []target, answers []Problem) tool.Result { + data := &Checked{File: sumsPath, Checked: len(answers), Problems: []Problem{}, NotChecked: notChecked(parsed, targets)} + byKind := map[string][]string{} + for _, a := range answers { + switch { + case a.Kind == "": + data.Matched++ + case a.Kind == noteItself: + data.NotChecked.Itself = append(data.NotChecked.Itself, a.Path) + default: + data.Problems = append(data.Problems, a) + byKind[a.Kind] = append(byKind[a.Kind], shownProblem(a)) + } + } + notes := []tool.Noted{{ID: noteChecked, Items: []string{strconv.Itoa(len(answers))}}} + for _, kind := range failing { + notes = appendNote(notes, kind, byKind[kind]) + } + notes = appendNote(notes, noteItself, data.NotChecked.Itself) + notes = appendNote(notes, noteTwice, data.NotChecked.Twice) + notes = appendNote(notes, noteNotChecksums, numbers(data.NotChecked.NotChecksums)) + notes = appendNote(notes, noteUnknown, data.NotChecked.Unknown) + + verdict := tool.Verdict{Outcome: tool.Match, About: filepath.Base(sumsPath), Listed: true} + if len(data.Problems) > 0 { + verdict.Outcome = tool.Mismatch + } + return tool.Result{Verdict: verdict, Notes: notes, Data: data} +} + +// shownProblem is a problem as an item of its note: the path, and the reason +// when there is one to give. +func shownProblem(p Problem) string { + if p.Detail == "" { + return p.Path + } + return p.Path + " - " + p.Detail +} + +// notChecked are the lines named without failing the check, the checksum +// file itself aside - that one is only known once its line is read. +func notChecked(parsed Sums, targets []target) NotChecked { + out := NotChecked{Itself: []string{}, Twice: []string{}, NotChecksums: parsed.NotSums, Unknown: parsed.Unknown} + if out.NotChecksums == nil { + out.NotChecksums = []int{} + } + if out.Unknown == nil { + out.Unknown = []string{} + } + seen := map[string]int{} + for _, t := range targets { + seen[path.Clean(t.entry.Path)]++ + } + for _, t := range targets { + key := path.Clean(t.entry.Path) + if seen[key] > 1 { + out.Twice = append(out.Twice, t.entry.Path) + seen[key] = 0 + } + } + return out +} + +// numbers is a list of line numbers as items of a note. +func numbers(lines []int) []string { + out := make([]string, 0, len(lines)) + for _, n := range lines { + out = append(out, strconv.Itoa(n)) + } + return out +} diff --git a/internal/tool/checksum/checksum.go b/internal/tool/checksum/checksum.go index 75523915..067b42ce 100644 --- a/internal/tool/checksum/checksum.go +++ b/internal/tool/checksum/checksum.go @@ -24,8 +24,6 @@ import ( "encoding/hex" "hash" "hash/crc32" - "io" - "os" "strings" "github.com/donislawdev/TestingFilesGenerator/internal/format" @@ -92,7 +90,7 @@ func init() { "crc32 is the one ZIP and PNG use, not the one cksum prints.", }, { - Name: SettingExpect, Kind: format.PropertyText, + Name: SettingExpect, Kind: format.PropertyText, Long: true, Shape: "a checksum in hexadecimal", Detail: "The checksum the file should have. The algorithm is told from its length, " + "and worked out even when it is not chosen above.", @@ -135,12 +133,16 @@ func run(ctx context.Context, in tool.Request, progress tool.Progress) (tool.Res } path := in.Inputs[InputFile] - digests, size, err := digest(ctx, path, chosen, progress) + var done int64 + digests, opened, err := digest(ctx, path, chosen, nil, func(read, size int64) { + done += read + progress(done, size) + }) if err != nil { return tool.Result{}, err } - out := Checksums{File: path, Bytes: size, Checksums: digests, Expected: expected} + out := Checksums{File: path, Bytes: opened.Size(), Checksums: digests, Expected: expected} result := tool.Result{Data: &out} for _, a := range algorithms { if sum, ok := digests[a.name]; ok { @@ -219,108 +221,3 @@ func readExpected(raw string) (*Expected, error) { } return nil, &ExpectedError{Given: raw, Digits: len(sum)} } - -// digest reads a file once and works out every chosen checksum of it. -func digest(ctx context.Context, path string, chosen map[string]bool, progress tool.Progress) (map[string]string, int64, error) { - f, before, err := openRegular(path) - if err != nil { - return nil, 0, err - } - defer func() { _ = f.Close() }() - - hashes := map[string]hash.Hash{} - writers := make([]io.Writer, 0, len(chosen)) - for _, a := range algorithms { - if chosen[a.name] { - h := a.make() - hashes[a.name] = h - writers = append(writers, h) - } - } - read, err := copyWatching(ctx, io.MultiWriter(writers...), f, before.Size(), progress) - if err != nil { - return nil, 0, err - } - after, err := f.Stat() - if err != nil { - return nil, 0, err - } - if changed(before, after, read) { - return nil, 0, &ChangedError{Path: path} - } - - out := make(map[string]string, len(hashes)) - for name, h := range hashes { - out[name] = hex.EncodeToString(h.Sum(nil)) - } - return out, read, nil -} - -// openRegular opens a path for reading, and hands it back only when what was -// opened is a file. -// -// Followed through a link, the way a person naming a file means it - but what -// is at the end has to be a file. A directory cannot be read as one, and a -// pipe or a device can be read forever. -// -// Asked of the open file rather than of the name, since 2026-09-30. The name -// was asked first and opened second until then, and between the two looks it -// could come to stand for a pipe, which open then waited on for good - a -// review of #157 found it. Now there is one look, at what was opened, and the -// open itself does not wait (openForLooking). The price is that a device named -// on purpose is opened and closed again, never read. -func openRegular(path string) (*os.File, os.FileInfo, error) { - f, err := openForLooking(path) - if err != nil { - return nil, nil, err - } - info, err := f.Stat() - if err == nil && !info.Mode().IsRegular() { - err = &NotAFileError{Path: path, Directory: info.IsDir()} - } - if err == nil { - err = waitAgain(f) - } - if err != nil { - _ = f.Close() - return nil, nil, err - } - return f, info, nil -} - -// changed says whether the file moved under the read. A checksum of a file -// that was being written is a checksum of no version of it. -func changed(before, after os.FileInfo, read int64) bool { - return after.Size() != before.Size() || read != before.Size() || !after.ModTime().Equal(before.ModTime()) -} - -// chunk is how much is read between two looks at the context. A megabyte is a -// millisecond of sha256 and a few of the slowest algorithm, so a stop lands at -// once and the look costs nothing. -const chunk = 1 << 20 - -// copyWatching is io.Copy that stops when asked and says how far it got. -func copyWatching(ctx context.Context, dst io.Writer, src io.Reader, total int64, progress tool.Progress) (int64, error) { - buf := make([]byte, chunk) - var done int64 - for { - if err := ctx.Err(); err != nil { - return done, err - } - // What was read is written before the error is looked at, because a - // reader may hand back its last bytes and io.EOF in one call. Nothing - // read is nothing written, so there is no need to ask first. - n, err := src.Read(buf) - if _, werr := dst.Write(buf[:n]); werr != nil { - return done, werr - } - done += int64(n) - progress(done, total) - if err == io.EOF { - return done, nil - } - if err != nil { - return done, err - } - } -} diff --git a/internal/tool/checksum/read.go b/internal/tool/checksum/read.go new file mode 100644 index 00000000..98f7c6dd --- /dev/null +++ b/internal/tool/checksum/read.go @@ -0,0 +1,136 @@ +package checksum + +import ( + "context" + "encoding/hex" + "hash" + "io" + "os" +) + +// Reading files, for all three tools of this package. One way in, so a file +// in a folder is opened with the same care as a file named on its own - the +// review of #157 found a pipe swapped in between two looks at a name, and the +// answer to that has to hold for the thousandth file of a folder as well +// (docs/NARZEDZIA-SUMY-2026-09-29.md §13.4). + +// counted is told each piece of a file as it is read, with the size the file +// had when it was opened. +type counted func(read, size int64) + +// digest reads a file once and works out every chosen checksum of it. +// +// scratch is the buffer to read through, owned by the caller - one per worker +// when a folder is read, so a hundred thousand files are not a hundred +// thousand buffers. nil asks for one of its own. +// +// What was opened is handed back as well: its size is the size of what was +// read, and its identity is what tells the checksum file itself from a file +// it lists. Asked of the open file, which on Windows is the only way to have +// the identity at all (docs/REVIEW-157-2026-09-30.md, point 4). +func digest(ctx context.Context, path string, chosen map[string]bool, scratch []byte, count counted) (map[string]string, os.FileInfo, error) { + f, before, err := openRegular(path) + if err != nil { + return nil, nil, err + } + defer func() { _ = f.Close() }() + + hashes := map[string]hash.Hash{} + writers := make([]io.Writer, 0, len(chosen)) + for _, a := range algorithms { + if chosen[a.name] { + h := a.make() + hashes[a.name] = h + writers = append(writers, h) + } + } + size := before.Size() + read, err := copyWatching(ctx, io.MultiWriter(writers...), f, scratch, func(n int64) { count(n, size) }) + if err != nil { + return nil, nil, err + } + after, err := f.Stat() + if err != nil { + return nil, nil, err + } + if changed(before, after, read) { + return nil, nil, &ChangedError{Path: path} + } + + out := make(map[string]string, len(hashes)) + for name, h := range hashes { + out[name] = hex.EncodeToString(h.Sum(nil)) + } + return out, before, nil +} + +// openRegular opens a path for reading, and hands it back only when what was +// opened is a file. +// +// Followed through a link, the way a person naming a file means it - but what +// is at the end has to be a file. A directory cannot be read as one, and a +// pipe or a device can be read forever. +// +// Asked of the open file rather than of the name, since 2026-09-30. The name +// was asked first and opened second until then, and between the two looks it +// could come to stand for a pipe, which open then waited on for good - a +// review of #157 found it. Now there is one look, at what was opened, and the +// open itself does not wait (openForLooking). The price is that a device named +// on purpose is opened and closed again, never read. +func openRegular(path string) (*os.File, os.FileInfo, error) { + f, err := openForLooking(path) + if err != nil { + return nil, nil, err + } + info, err := f.Stat() + if err == nil && !info.Mode().IsRegular() { + err = &NotAFileError{Path: path, Directory: info.IsDir()} + } + if err == nil { + err = waitAgain(f) + } + if err != nil { + _ = f.Close() + return nil, nil, err + } + return f, info, nil +} + +// changed says whether the file moved under the read. A checksum of a file +// that was being written is a checksum of no version of it. +func changed(before, after os.FileInfo, read int64) bool { + return after.Size() != before.Size() || read != before.Size() || !after.ModTime().Equal(before.ModTime()) +} + +// chunk is how much is read between two looks at the context, when the caller +// has no buffer of its own. A megabyte is a millisecond of sha256 and a few of +// the slowest algorithm, so a stop lands at once and the look costs nothing. +const chunk = 1 << 20 + +// copyWatching is io.Copy that stops when asked and says how far it got. +func copyWatching(ctx context.Context, dst io.Writer, src io.Reader, buf []byte, count func(n int64)) (int64, error) { + if buf == nil { + buf = make([]byte, chunk) + } + var done int64 + for { + if err := ctx.Err(); err != nil { + return done, err + } + // What was read is written before the error is looked at, because a + // reader may hand back its last bytes and io.EOF in one call. Nothing + // read is nothing written, so there is no need to ask first. + n, err := src.Read(buf) + if _, werr := dst.Write(buf[:n]); werr != nil { + return done, werr + } + done += int64(n) + count(int64(n)) + if err == io.EOF { + return done, nil + } + if err != nil { + return done, err + } + } +} diff --git a/internal/tool/checksum/refusals.go b/internal/tool/checksum/refusals.go index 99c288a3..0eed2c72 100644 --- a/internal/tool/checksum/refusals.go +++ b/internal/tool/checksum/refusals.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) @@ -77,13 +78,24 @@ func (e *ExpectedError) Class() tool.Class { return tool.Asked } type NotAFileError struct { Path string Directory bool + // Input is the input the path was given as. Empty is the file of the + // checksum tool, which is where this error was first raised. + Input string } // AboutSetting is the input this is about. -func (e *NotAFileError) AboutSetting() string { return InputFile } +func (e *NotAFileError) AboutSetting() string { + if e.Input == "" { + return InputFile + } + return e.Input +} // What happened. func (e *NotAFileError) What() string { + if e.Directory && e.Input == InputChecksumFile { + return fmt.Sprintf("%s is a directory and this reads one checksum file", e.Path) + } if e.Directory { return fmt.Sprintf("%s is a directory and this works out the checksum of one file", e.Path) } @@ -136,3 +148,212 @@ func (e *ChangedError) Error() string { return tool.Sentence(e.What(), e.Why(), // Class says the request was fine and the file did not hold still. func (e *ChangedError) Class() tool.Class { return tool.Reading } + +// NotAFolderError is a folder tool given something that is not a folder. +type NotAFolderError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *NotAFolderError) AboutSetting() string { return InputFolder } + +// What happened. +func (e *NotAFolderError) What() string { + return fmt.Sprintf("%s is a file and this lists a folder", e.Path) +} + +// Why a file will not do. +func (e *NotAFolderError) Why() string { + return "the checksum file is written beside the files it lists, so it needs the folder they are in" +} + +// Instead is what to name. +func (e *NotAFolderError) Instead() string { + return "name the folder, or use tfg tool checksum for the one file" +} + +func (e *NotAFolderError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *NotAFolderError) Class() tool.Class { return tool.Asked } + +// SumsExistError is a checksum file already where one would be written. +type SumsExistError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *SumsExistError) AboutSetting() string { return InputFolder } + +// What happened. +func (e *SumsExistError) What() string { + return fmt.Sprintf("%s is already there, so nothing was written", e.Path) +} + +// Why it is not written over. +func (e *SumsExistError) Why() string { + return "a checksum file is never written over, since it may be the only record of what the folder held" +} + +// Instead is what to do with it. +func (e *SumsExistError) Instead() string { + return "check the folder against it with tfg tool checksum-check, or move it away and run this again" +} + +func (e *SumsExistError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says the request was fine and the folder is not in the state it needs. +func (e *SumsExistError) Class() tool.Class { return tool.Reading } + +// NothingToListError is a folder with no file a checksum file could list. +type NothingToListError struct { + Folder string + // LeftOut is how many names under it are not files - links, pipes, and + // what a stopped run left half written. + LeftOut int +} + +// AboutSetting is the input this is about. +func (e *NothingToListError) AboutSetting() string { return InputFolder } + +// What happened. +func (e *NothingToListError) What() string { + if e.LeftOut > 0 { + return fmt.Sprintf("%s holds no file to list, only %s that are not files", e.Folder, core.Count(e.LeftOut, "name", "names")) + } + return fmt.Sprintf("%s holds no file to list", e.Folder) +} + +// Why an empty checksum file is not written. +func (e *NothingToListError) Why() string { + return "a checksum file without a line in it is refused by sha256sum -c, so it would check nothing" +} + +// Instead is what to name. +func (e *NothingToListError) Instead() string { + return "name a folder with at least one file in it" +} + +func (e *NothingToListError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *NothingToListError) Class() tool.Class { return tool.Asked } + +// FolderUnreadableError is a folder with something in it that could not be +// read, which refuses the whole checksum file. +type FolderUnreadableError struct { + Folder string + // Problems is every one of them, each a path and what the system said. + Problems []string +} + +// AboutSetting is the input this is about. +func (e *FolderUnreadableError) AboutSetting() string { return InputFolder } + +// What happened. +func (e *FolderUnreadableError) What() string { + return fmt.Sprintf("not everything under %s could be read, so nothing was written", e.Folder) +} + +// Why a checksum file with a gap is not written. +func (e *FolderUnreadableError) Why() string { + return "a checksum file promises every file in the folder, and one with a gap in it would not say where the gap is" +} + +// Instead is what to do. The list follows it. +func (e *FolderUnreadableError) Instead() string { + return "make these readable or move them out of the folder, and run this again" +} + +// Error is the sentence and then every problem on a line of its own, so none +// of them is left for the next try to find. +func (e *FolderUnreadableError) Error() string { + return tool.Sentence(e.What(), e.Why(), e.Instead()) + ":\n " + strings.Join(e.Problems, "\n ") +} + +// Class says the request was fine and the disk did not cooperate. +func (e *FolderUnreadableError) Class() tool.Class { return tool.Reading } + +// NoRoomError is a checksum file the disk has no room for. +type NoRoomError struct { + Path string + Need, Have int64 +} + +// What happened. +func (e *NoRoomError) What() string { + return fmt.Sprintf("there is no room for %s, so nothing was written", e.Path) +} + +// Why, with the two numbers. +func (e *NoRoomError) Why() string { + return fmt.Sprintf("it takes %s and the disk has %s free", core.HumanBytes(e.Need), core.HumanBytes(e.Have)) +} + +// Instead is where to find the room. +func (e *NoRoomError) Instead() string { + return "free some space on that disk and run this again" +} + +func (e *NoRoomError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says the disk is full, which has a code of its own. +func (e *NoRoomError) Class() tool.Class { return tool.Room } + +// SumsTooLargeError is a checksum file larger than one this reads. +type SumsTooLargeError struct { + Path string + Bytes int64 +} + +// AboutSetting is the input this is about. +func (e *SumsTooLargeError) AboutSetting() string { return InputChecksumFile } + +// What happened. +func (e *SumsTooLargeError) What() string { + return fmt.Sprintf("%s is %s, larger than a checksum file this reads", e.Path, core.HumanBytes(e.Bytes)) +} + +// Why there is a limit, and what it is. +func (e *SumsTooLargeError) Why() string { + return fmt.Sprintf("the most is %s, about half a million files, and a larger file is more likely not a checksum file at all", + core.HumanBytes(sumsMostBytes)) +} + +// Instead is how to check a folder that large. +func (e *SumsTooLargeError) Instead() string { + return "check the folder in parts, each with a checksum file of its own" +} + +func (e *SumsTooLargeError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says the file handed in is not one this takes. +func (e *SumsTooLargeError) Class() tool.Class { return tool.Reading } + +// NoSumsError is a file with no checksum line in it. +type NoSumsError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *NoSumsError) AboutSetting() string { return InputChecksumFile } + +// What happened. +func (e *NoSumsError) What() string { + return fmt.Sprintf("%s holds no checksum line", e.Path) +} + +// Why that is refused rather than passed. +func (e *NoSumsError) Why() string { + return "a checksum line is a checksum and a path, written as hex path or as SHA256 (path) = hex, and without one there is nothing to check" +} + +// Instead is what to name. +func (e *NoSumsError) Instead() string { + return "name the checksum file itself, such as SHA256SUMS, rather than a page or a signature beside it" +} + +func (e *NoSumsError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says the file handed in is not one this can use. +func (e *NoSumsError) Class() tool.Class { return tool.Reading } diff --git a/internal/tool/checksum/sums.go b/internal/tool/checksum/sums.go new file mode 100644 index 00000000..5307da77 --- /dev/null +++ b/internal/tool/checksum/sums.go @@ -0,0 +1,320 @@ +package checksum + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "strings" +) + +// The checksum file: the format GNU coreutils writes and reads, and the one +// "--tag" writes, which is the one BSD and perl's shasum write. Every rule +// below was measured on 2026-09-30 against coreutils 8.32, coreutils 9.7 and +// shasum rather than taken from memory - the memory was wrong about a byte +// order mark and about blank lines (docs/NARZEDZIA-SUMY-2026-09-29.md §15.2). + +// sumsAlgorithms are the algorithms a checksum file is written with. crc32 +// is not one: nothing reads a CRC32SUMS, so writing one would promise a check +// nobody can run. +var sumsAlgorithms = []string{"md5", "sha1", "sha256", "sha512"} + +// sumsName is the name coreutils and release pages give the checksum file of +// an algorithm: SHA256SUMS for sha256. +func sumsName(algorithm string) string { return strings.ToUpper(algorithm) + "SUMS" } + +// escaper is how coreutils 9 writes a name holding a backslash, a line break +// or a carriage return: each as two characters, on a line that starts with a +// backslash to say the name is written that way. +var escaper = strings.NewReplacer(`\`, `\\`, "\n", `\n`, "\r", `\r`) + +// SumsLine is one entry, the way sha256sum writes it: the checksum, two +// spaces and the path, slash separated, on a line of its own. +// +// A carriage return is escaped the way coreutils 9 does it, and that has a +// cost which is named rather than hidden: coreutils 8.32 writes one as it is +// and refuses the escaped line, and perl's shasum does not read it either. +// The newest coreutils reads both ways and the other two read neither the same +// way, so the choice is the one the tool people have today makes. A carriage +// return in a file name is rare everywhere except in a folder of test files, +// which is what this program makes. +func SumsLine(sum, name string) string { + escaped := escaper.Replace(name) + if escaped == name { + return sum + " " + name + "\n" + } + return `\` + sum + " " + escaped + "\n" +} + +// The bounds on a checksum file this reads. It comes from somewhere else, so +// how much of this program's memory and time it may take is decided before a +// byte of it is read. +const ( + // sumsMostBytes is the largest checksum file read. A line of a sha256 + // and a path of sixty characters is about 130 bytes, so this is about half + // a million files. How long a file this size takes to read is not measured. + sumsMostBytes = 64 << 20 + // lineMostBytes is the longest line kept. The longest path Windows allows + // is 32767 UTF-16 units, at most 98 KB of UTF-8, and escaping can double + // it - so a longer line is not a line anybody wrote about a real file. + lineMostBytes = 256 << 10 +) + +// Listed is one checksum line of a checksum file. +type Listed struct { + // Line is where it stands, counted from one. + Line int + Algorithm string + // Sum is in lower case, whichever case the file used. + Sum string + // Path is as the file writes it, with the escaping undone. + Path string +} + +// Sums is what a checksum file came to. +type Sums struct { + Listed []Listed + // NotSums are the lines that are not a checksum line - a blank line, a + // comment, the armour of a signature, a checksum a digit short. Named + // rather than failed, the owner's decision of 2026-09-30: sha256sum -c + // passes over them, and a signed checksum file is full of them. + NotSums []int + // Unknown are lines of an algorithm this tool does not work out, as + // "line N: NAME" - a sha224, a BLAKE2b. + Unknown []string +} + +// ParseSums reads a checksum file, line by line, never keeping more of a line +// than lineMostBytes. +func ParseSums(r io.Reader) (Sums, error) { + br := bufio.NewReaderSize(r, 64<<10) + var out Sums + for number := 1; ; number++ { + line, tooLong, last, err := readLine(br) + if err != nil { + return Sums{}, err + } + if last { + return out, nil + } + if number == 1 { + // A byte order mark, which sha256sum refuses. Taken, because a + // checksum line cannot start with one and an editor may add it. + line = bytes.TrimPrefix(line, []byte("\xef\xbb\xbf")) + } + out.add(number, line, tooLong) + } +} + +// add files one line under what it turned out to be. +func (s *Sums) add(number int, line []byte, tooLong bool) { + if tooLong { + s.NotSums = append(s.NotSums, number) + return + } + entry, unknown, ok := readEntry(string(line)) + switch { + case unknown != "": + s.Unknown = append(s.Unknown, fmt.Sprintf("line %d: %s", number, unknown)) + case ok: + entry.Line = number + s.Listed = append(s.Listed, entry) + default: + s.NotSums = append(s.NotSums, number) + } +} + +// readLine is the next line without its line break. tooLong says it was longer +// than a line may be, and then the rest of it was read past rather than kept. +// last says there was no line left. +func readLine(br *bufio.Reader) (line []byte, tooLong, last bool, err error) { + for { + piece, readErr := br.ReadSlice('\n') + line, tooLong = kept(line, piece, tooLong) + if errors.Is(readErr, bufio.ErrBufferFull) { + continue + } + if readErr != nil && !errors.Is(readErr, io.EOF) { + return nil, false, false, readErr + } + // A file that ends in a line break has nothing after it, and that is + // the end rather than an empty last line. + last = readErr != nil && len(line) == 0 && !tooLong + return withoutLineBreak(line), tooLong, last, nil + } +} + +// kept is a line with one more piece of it read - or nothing, once the line +// is longer than a line may be, and for the rest of it. +func kept(line, piece []byte, tooLong bool) ([]byte, bool) { + if tooLong || len(line)+len(piece) > lineMostBytes { + return nil, true + } + return append(line, piece...), false +} + +// withoutLineBreak takes the line break off the end of a line, and a carriage +// return before it - a file written on Windows, which coreutils 9 reads. +func withoutLineBreak(line []byte) []byte { + line = bytes.TrimSuffix(line, []byte("\n")) + return bytes.TrimSuffix(line, []byte("\r")) +} + +// readEntry reads one line as a checksum line: GNU, "hex path", or tagged, +// "SHA256 (path) = hex". unknown names an algorithm the line is written in +// that this tool does not work out. +func readEntry(text string) (entry Listed, unknown string, ok bool) { + escaped := strings.HasPrefix(text, `\`) + if escaped { + text = text[1:] + } + algorithm, sum, path, unknown, ok := gnuEntry(text) + if !ok && unknown == "" { + algorithm, sum, path, unknown, ok = taggedEntry(text) + } + if !ok { + return Listed{}, unknown, false + } + if escaped { + if path, ok = unescaped(path); !ok { + return Listed{}, "", false + } + } + return Listed{Algorithm: algorithm, Sum: strings.ToLower(sum), Path: path}, "", true +} + +// gnuEntry is "hex path": the checksum, a space, a space or a star for the +// mode, and the path. One space alone is taken too - both coreutils do. +func gnuEntry(text string) (algorithm, sum, path, unknown string, ok bool) { + digits := hexPrefix(text) + if digits == 0 || digits == len(text) || text[digits] != ' ' { + return "", "", "", "", false + } + path = text[digits+1:] + if strings.HasPrefix(path, " ") || strings.HasPrefix(path, "*") { + path = path[1:] + } + if path == "" { + return "", "", "", "", false + } + sum = text[:digits] + for _, name := range sumsAlgorithms { + if find(name).digits == digits { + return name, sum, path, "", true + } + } + // A length another algorithm has is named for it. Any other length is a + // checksum line that went wrong - a digit lost - and that is a line that + // is not a checksum, which sha256sum calls "improperly formatted". + if other, known := elsewhere[digits]; known { + return "", "", "", other, false + } + return "", "", "", "", false +} + +// elsewhere are the lengths of the algorithms coreutils has and this tool +// does not, so a line written with one is named for what it is. +var elsewhere = map[int]string{56: "sha224", 96: "sha384"} + +// taggedEntry is "SHA256 (path) = hex". The path may hold ") = " itself, so +// the checksum is what follows the last one. +func taggedEntry(text string) (algorithm, sum, path, unknown string, ok bool) { + open := strings.Index(text, " (") + closing := strings.LastIndex(text, ") = ") + if open <= 0 || closing < open+2 { + return "", "", "", "", false + } + tag, path, sum := text[:open], text[open+2:closing], text[closing+4:] + if path == "" || hexPrefix(sum) != len(sum) || sum == "" || !isTag(tag) { + return "", "", "", "", false + } + algorithm = strings.ToLower(tag) + a := find(algorithm) + if a == nil || algorithm == "crc32" { + return "", "", "", tag, false + } + if a.digits != len(sum) { + return "", "", "", "", false + } + return algorithm, sum, path, "", true +} + +// isTag is whether a word can be the name of an algorithm in a tagged line: +// letters, digits, and the dash and slash of names like SHA3-256 and +// SHA512/256. +func isTag(word string) bool { + for _, r := range word { + if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '/') { + return false + } + } + return word != "" +} + +// hexPrefix is how many characters at the start of a text are hexadecimal. +func hexPrefix(text string) int { + for i := 0; i < len(text); i++ { + c := text[i] + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return i + } + } + return len(text) +} + +// unescaped undoes the escaping of a name: a backslash, a line break and a +// carriage return, each written as two characters. Anything else after a +// backslash is not a name coreutils wrote, and both versions refuse it. +func unescaped(name string) (string, bool) { + var out strings.Builder + for i := 0; i < len(name); i++ { + if name[i] != '\\' { + out.WriteByte(name[i]) + continue + } + if i+1 == len(name) { + return "", false + } + i++ + switch name[i] { + case '\\': + out.WriteByte('\\') + case 'n': + out.WriteByte('\n') + case 'r': + out.WriteByte('\r') + default: + return "", false + } + } + return out.String(), true +} + +// readSums opens a checksum file without waiting on it, refuses one larger +// than a checksum file is allowed to be, and reads it. What was opened is +// handed back too, so the file can be told apart from the files it lists. +func readSums(path string) (Sums, os.FileInfo, error) { + f, info, err := openRegular(path) + if err != nil { + return Sums{}, nil, err + } + defer func() { _ = f.Close() }() + if info.Size() > sumsMostBytes { + return Sums{}, nil, &SumsTooLargeError{Path: path, Bytes: info.Size()} + } + // Bounded again while reading, since a file can grow after it was asked. + bounded := &io.LimitedReader{R: f, N: sumsMostBytes + 1} + parsed, err := ParseSums(bounded) + if err != nil { + return Sums{}, nil, err + } + if bounded.N == 0 { + return Sums{}, nil, &SumsTooLargeError{Path: path, Bytes: sumsMostBytes + 1} + } + if len(parsed.Listed) == 0 { + return Sums{}, nil, &NoSumsError{Path: path} + } + return parsed, info, nil +} diff --git a/internal/tool/checksum/write.go b/internal/tool/checksum/write.go new file mode 100644 index 00000000..a28291ed --- /dev/null +++ b/internal/tool/checksum/write.go @@ -0,0 +1,282 @@ +package checksum + +import ( + "context" + "errors" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/audit" + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// WriteID is the tool that writes the checksum file of a folder. +// +// A tool of its own rather than a way of running checksum, because a tool is +// one question with one set of things it works on (docs/NARZEDZIA-SUMY- +// 2026-09-29.md §15.1, the owner's decision of 2026-09-30): this one works on +// a folder, writes a file, and answers with what it wrote. +const WriteID = "checksum-write" + +// InputFolder is the folder checksum-write lists. +const InputFolder = "folder" + +// The notes of checksum-write. +const ( + noteWritten = "written" + noteListed = "listed" + noteLinks = "links" + noteOthers = "others" + noteOurs = "ours" +) + +func init() { + tool.Register(tool.Descriptor{ + ID: WriteID, + Question: "How will I know later that nothing in this folder changed?", + Detail: "Writes the checksum of every file in a folder into one file beside them, SHA256SUMS for sha256. " + + "Check it later with tfg tool checksum-check, or with sha256sum -c in that folder.", + Inputs: []tool.Input{{ + Name: InputFolder, Kind: tool.Folder, + Detail: "The folder to list. Every file under it is read, and the checksum file is written into it.", + }}, + Settings: []format.Property{{ + Name: SettingAlgorithm, Kind: format.PropertyChoice, Choices: sumsAlgorithms, Default: "sha256", + Detail: "Which checksum to write. sha256 is the one sha256sum and most release pages use.", + }}, + Notes: []tool.Note{ + {ID: noteWritten, Says: "Written:"}, + {ID: noteListed, Says: "Files in it:"}, + {ID: noteLinks, Says: "Left out, because they are links and a link is not followed:"}, + {ID: noteOthers, Says: "Left out, because they are not files - a pipe, a device, a socket or a junction - and reading one may never end:"}, + {ID: noteOurs, Says: "Left out, because a run of this program left them half written:"}, + }, + Run: runWrite, + }) +} + +// Written is what --json prints about a checksum file written. +type Written struct { + Folder string `json:"folder"` + File string `json:"file"` + Algorithm string `json:"algorithm"` + Files int `json:"files"` + LeftOut LeftOut `json:"left_out"` +} + +// LeftOut are the names under the folder that are not in the checksum file, +// by why. +type LeftOut struct { + Links []string `json:"links"` + NotFiles []string `json:"not_files"` + Unfinished []string `json:"unfinished"` +} + +// runWrite lists a folder, works out the checksum of every file in it and +// writes them down. +// +// The file is written last, after every checksum is known, and under a name +// of its own first (core.WriteNew) - so a run stopped anywhere leaves either +// no checksum file or a whole one, and never writes over one somebody has. +func runWrite(ctx context.Context, in tool.Request, progress tool.Progress) (tool.Result, error) { + dir := in.Inputs[InputFolder] + algorithm := in.Values[SettingAlgorithm] + target := filepath.Join(dir, sumsName(algorithm)) + if err := mustBeFolder(dir); err != nil { + return tool.Result{}, err + } + // Asked before a byte is read, so a folder of gigabytes is not read to be + // refused at the end. Asked again, by the system, when the file is given + // its name - somebody may write one in the meantime. + if _, err := os.Lstat(target); err == nil { + return tool.Result{}, &SumsExistError{Path: target} + } + + found, err := audit.Walk(ctx, dir) + if err != nil { + return tool.Result{}, err + } + plan := planWrite(found.Entries) + // A directory that could not be listed means files nobody can see, and a + // checksum file promises every file. Refused before anything is read - the + // owner's decision of 2026-09-29 - naming every one. + if len(found.Unreadable) > 0 { + return tool.Result{}, &FolderUnreadableError{Folder: dir, Problems: problemsOf(found.Unreadable)} + } + if len(plan.files) == 0 { + return tool.Result{}, &NothingToListError{Folder: dir, LeftOut: plan.leftOut.count()} + } + + sums, err := hashAll(ctx, dir, plan.files, algorithm, progress) + if err != nil { + return tool.Result{}, err + } + content := sumsContent(plan.files, sums) + if err := roomFor(dir, target, len(content)); err != nil { + return tool.Result{}, err + } + if _, err := core.WriteNew(target, []byte(content), 0o644); err != nil { + if errors.Is(err, fs.ErrExist) { + return tool.Result{}, &SumsExistError{Path: target} + } + return tool.Result{}, err + } + return writeResult(dir, target, algorithm, plan), nil +} + +// writePlan is a folder sorted into what goes in the checksum file and what is +// left out, and why. +type writePlan struct { + files []audit.Entry + leftOut LeftOut +} + +func (l LeftOut) count() int { return len(l.Links) + len(l.NotFiles) + len(l.Unfinished) } + +// planWrite sorts the entries of a folder. Files are in byte order of their +// paths, so one folder gives one checksum file whichever system listed it - +// the walk lists each directory in order, and "a/x" came before "a.b" in it. +func planWrite(entries []audit.Entry) writePlan { + var plan writePlan + for _, e := range entries { + switch { + case e.Kind == audit.Link: + plan.leftOut.Links = append(plan.leftOut.Links, e.Path) + case e.Kind != audit.Regular: + plan.leftOut.NotFiles = append(plan.leftOut.NotFiles, e.Path) + case unfinished(path.Base(e.Path)): + plan.leftOut.Unfinished = append(plan.leftOut.Unfinished, e.Path) + default: + plan.files = append(plan.files, e) + } + } + sort.Slice(plan.files, func(i, j int) bool { return plan.files[i].Path < plan.files[j].Path }) + return plan +} + +// unfinished is a name this program writes under while a file is not whole +// yet. One left in a folder is half a file, and its checksum would be the +// checksum of nothing anybody asked for. +func unfinished(name string) bool { + return core.IsPartialName(name) || core.IsWritingName(name) || core.IsRunLockName(name) +} + +// hashed is what one file of a folder came to. +type hashed struct { + sum string + err error +} + +// hashAll works out the checksum of every file, several at once, and refuses +// the whole folder if any of them could not be read - naming all of them. +func hashAll(ctx context.Context, dir string, files []audit.Entry, algorithm string, progress tool.Progress) ([]string, error) { + var total int64 + for _, f := range files { + if info, err := f.Info(); err == nil { + total += info.Size() + } + } + tally := audit.NewTally(func(done int64) { progress(done, total) }) + chosen := map[string]bool{algorithm: true} + answers, stopped := audit.InOrder(ctx, len(files), func(i int, scratch []byte) hashed { + full := filepath.Join(dir, filepath.FromSlash(files[i].Path)) + sums, _, err := digest(ctx, full, chosen, scratch, func(read, _ int64) { tally.Add(read) }) + return hashed{sum: sums[algorithm], err: err} + }) + if stopped != nil { + return nil, stopped + } + sums := make([]string, len(answers)) + var problems []string + for i, a := range answers { + if a.err != nil { + problems = append(problems, files[i].Path+" - "+a.err.Error()) + } + sums[i] = a.sum + } + if len(problems) > 0 { + return nil, &FolderUnreadableError{Folder: dir, Problems: problems} + } + return sums, nil +} + +// problemsOf is each error as a line of a refusal. +func problemsOf(errs []error) []string { + out := make([]string, 0, len(errs)) + for _, err := range errs { + out = append(out, err.Error()) + } + return out +} + +// sumsContent is the checksum file: one line a file, in the order given. +func sumsContent(files []audit.Entry, sums []string) string { + var b strings.Builder + for i, f := range files { + b.WriteString(SumsLine(sums[i], f.Path)) + } + return b.String() +} + +// roomFor refuses a checksum file the disk has no room for, before a byte of +// it is written - the frozen table has a code of its own for a full disk. +// A disk whose free space cannot be read is not refused: the write that +// follows answers the question instead. +func roomFor(dir, target string, need int) error { + have, err := core.AvailableBytes(dir) + if err != nil || have >= int64(need) { + return nil + } + return &NoRoomError{Path: target, Need: int64(need), Have: have} +} + +// writeResult is what a checksum file written comes to, as notes and as data. +func writeResult(dir, target, algorithm string, plan writePlan) tool.Result { + // Empty lists rather than null, so a script can count them without + // asking first whether they are there. + left := LeftOut{Links: orEmpty(plan.leftOut.Links), NotFiles: orEmpty(plan.leftOut.NotFiles), Unfinished: orEmpty(plan.leftOut.Unfinished)} + data := &Written{Folder: dir, File: target, Algorithm: algorithm, Files: len(plan.files), LeftOut: left} + notes := []tool.Noted{ + {ID: noteWritten, Items: []string{target}}, + {ID: noteListed, Items: []string{strconv.Itoa(len(plan.files))}}, + } + notes = appendNote(notes, noteLinks, plan.leftOut.Links) + notes = appendNote(notes, noteOthers, plan.leftOut.NotFiles) + notes = appendNote(notes, noteOurs, plan.leftOut.Unfinished) + return tool.Result{Notes: notes, Data: data} +} + +// appendNote adds a note when it has something to say. +func appendNote(notes []tool.Noted, id string, items []string) []tool.Noted { + if len(items) == 0 { + return notes + } + return append(notes, tool.Noted{ID: id, Items: items}) +} + +// orEmpty is a list, never nil. +func orEmpty(items []string) []string { + if items == nil { + return []string{} + } + return items +} + +// mustBeFolder refuses a path that is not a folder, in words about folders. +func mustBeFolder(dir string) error { + info, err := os.Stat(dir) + if err != nil { + return err + } + if !info.IsDir() { + return &NotAFolderError{Path: dir} + } + return nil +} diff --git a/internal/tool/refusals.go b/internal/tool/refusals.go index eb706a17..4129f340 100644 --- a/internal/tool/refusals.go +++ b/internal/tool/refusals.go @@ -1,6 +1,7 @@ package tool import ( + "errors" "fmt" "strings" ) @@ -19,13 +20,17 @@ func Sentence(what, why, instead string) string { // tomorrow refuses with the right code without anybody editing a list. type Class int -// The two kinds a tool refuses with. +// The kinds a tool refuses with. const ( // Asked is a request that cannot be run as written - a wrong setting, a // missing file name, a directory where a file goes. Asked Class = iota + 1 // Reading is a request that was fine and a disk that did not cooperate. Reading + // Room is a file a tool would write and a disk without the space for it - + // its own class because the frozen table gives it its own code, and CI + // has to tell "give it a bigger disk" from "fix the permissions". + Room ) // Classified is a refusal that says what kind it is. @@ -63,6 +68,36 @@ func (e *UnknownError) Error() string { return Sentence(e.What(), e.Why(), e.Ins // Class says this is a mistake in the request. func (e *UnknownError) Class() Class { return Asked } +// SettingError is a setting of a tool its declaration refuses: the refusal the +// registry words for every declared setting, classed as a mistake in the +// request. +// +// Wrapped because on its own that refusal ends a run with FORMAT, the code for +// a file a format cannot make, and a tool makes no file of any format. A wrong +// algorithm chosen for checksum-write ended with 4 until 2026-09-30 - the day +// the first tool declared a closed set of values, which is when +// docs/NARZEDZIA-SUMY-2026-09-29.md §13.3 said this would become reachable. +type SettingError struct { + Err error +} + +func (e *SettingError) Error() string { return e.Err.Error() } + +// Unwrap is the registry's own refusal, for a caller asking what it was. +func (e *SettingError) Unwrap() error { return e.Err } + +// AboutSetting is the setting refused, so a form marks its box. +func (e *SettingError) AboutSetting() string { + var about interface{ AboutSetting() string } + if errors.As(e.Err, &about) { + return about.AboutSetting() + } + return "" +} + +// Class says this is a mistake in the request. +func (e *SettingError) Class() Class { return Asked } + // MissingInputError is a request without something the tool works on. type MissingInputError struct { Tool string diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 8d37bf15..d44eb9fa 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -32,8 +32,14 @@ import ( // InputKind is what an input names on the disk. type InputKind string -// File is one existing file, read and never written. -const File InputKind = "file" +// The kinds of input. +const ( + // File is one existing file, read and never written. + File InputKind = "file" + // Folder is one existing folder. Every file under it is read, and a tool + // that writes adds one new file to it - never over one that is there. + Folder InputKind = "folder" +) // Input is what a tool works on, as opposed to how it works - a path, given on // the command line without a flag in front of it and chosen in the window with @@ -63,30 +69,68 @@ const ( // Verdict is the comparison a tool made, if it made one. type Verdict struct { Outcome Outcome - // About is what was compared - the name of an algorithm, for checksums. - // A key rather than a word, so it is never translated (G8). + // About is what was compared - the name of an algorithm, for checksums, + // or the name of the list every file was compared with. Data rather than + // a word, so it is never translated (G8). About string // Wanted is what the request said it should be, and Got what it is. Both // are data and shown as they are, so a mismatch can be read without the // table beside it. Wanted string Got string + // Listed is a comparison of many files with a list that names them - a + // checksum file - rather than of one value with another. Its answer is + // said about the list, and what did not match is in the notes. + Listed bool } // Said is the verdict in the words of the command line, which are English // (D9). Empty when nothing was compared. The window says the same in its own // language, and a guard holds its English to this sentence. +// +// No count is said, on purpose: "3 of 1 files" is wrong in English and every +// count reads differently in Polish. The counts are notes, each a name and a +// number (Note), which read the same whatever the number is. func (v Verdict) Said() string { - switch v.Outcome { - case Match: + switch { + case v.Outcome == Match && v.Listed: + return fmt.Sprintf("Matches: every file %s lists is what it says.", v.About) + case v.Outcome == Mismatch && v.Listed: + return fmt.Sprintf("Does not match: %s lists files that are not what it says.", v.About) + case v.Outcome == Match: return fmt.Sprintf("Matches: the %s is %s, as expected.", v.About, v.Got) - case Mismatch: + case v.Outcome == Mismatch: return fmt.Sprintf("Does not match: the %s is %s and %s was expected.", v.About, v.Got, v.Wanted) - case Unasked: } return "" } +// Note is one thing a tool may say besides its table, declared once: a file +// written, how many files a list held, the names left out and why. +// +// Declared rather than composed in the tool, for the reason the settings are: +// both surfaces word it from the declaration, and a window says it in its own +// language through the registry's catalogue - so a tool that learns to say +// something new is heard in both without a line of surface code. The sentence +// holds no number, since a number is an item of the note and reads the same +// in every language. +type Note struct { + // ID is the key of the note, in a result and in the words of every + // language. Never shown: the command line prints Says, and --json carries + // the same facts under keys of its own. + ID string + // Says is the sentence, in English, ending in a colon: the items follow. + Says string +} + +// Noted is one note a run of a tool made, with what it is about. +type Noted struct { + ID string + // Items are data - paths, line numbers, a count - shown as they are in + // every language. + Items []string +} + // Request is one run of a tool: the paths it works on and the settings it was // given. Values left out take the declared default before the tool sees them. type Request struct { @@ -102,6 +146,9 @@ type Result struct { Rows [][]string // Verdict is the comparison, when the request asked for one. Verdict Verdict + // Notes are what the run has to say besides the table, in the order it + // says them. Each is one the tool declared. + Notes []Noted // Data is the same answer typed, for --json. A script reading a digest // asks for checksums.sha256 rather than for the second cell of a row. Data any @@ -109,6 +156,11 @@ type Result struct { // Progress is told how much of the work is done, in bytes. The total is what // the tool expects to read and may be zero when it does not know. +// +// Called from one goroutine at a time, whatever the tool runs beside itself - +// a window's bar is not written for two callers at once, and neither should +// any listener have to be. A tool reading several files at once counts through +// audit.Tally, which keeps that promise. type Progress func(done, total int64) // Descriptor is one tool. @@ -125,8 +177,12 @@ type Descriptor struct { // are - so the window draws them with DeclaredFields and the command line // describes them with Allowed. Settings []format.Property - // Columns are the headings of the table in a result. + // Columns are the headings of the table in a result. A tool whose answer + // is a list of files too long for a table - every file of a folder - has + // none, and says what it found in notes. Columns []string + // Notes are everything the tool may say besides the table. + Notes []Note // Run does the work, on a request that has been checked and defaulted. Run func(ctx context.Context, in Request, progress Progress) (Result, error) } @@ -170,12 +226,40 @@ func unusable(d Descriptor) string { return "has nothing to run" case d.Question == "" || d.Detail == "": return "has no question or no sentence describing it" - case len(d.Columns) == 0: - return "declares no columns for its result" + case len(d.Columns) == 0 && len(d.Notes) == 0: + return "declares neither columns nor notes, so a result would have nothing to say" + } + if problem := unusableNote(d.Notes); problem != "" { + return problem } return clashingName(d) } +// unusableNote is what is wrong with the notes a tool declares, or nothing. +func unusableNote(notes []Note) string { + seen := map[string]bool{} + for _, n := range notes { + switch { + case n.ID == "" || n.Says == "": + return "declares a note without a key or without a sentence" + case seen[n.ID]: + return "declares the note " + n.ID + " twice" + } + seen[n.ID] = true + } + return "" +} + +// NoteSays is the English of a note this tool declares, or nothing. +func (d Descriptor) NoteSays(id string) string { + for _, n := range d.Notes { + if n.ID == id { + return n.Says + } + } + return "" +} + // clashingName is a name declared twice across inputs and settings, which would // make a request ambiguous and two flags one. func clashingName(d Descriptor) string { @@ -253,7 +337,10 @@ func (d Descriptor) CheckEach(in Request) []error { bad = append(bad, &MissingInputError{Tool: d.ID, Input: want}) } } - return append(bad, format.CheckStated(d.ID, d.Settings, in.Values)...) + for _, err := range format.CheckStated(d.ID, d.Settings, in.Values) { + bad = append(bad, &SettingError{Err: err}) + } + return bad } // Start checks a request, fills in the defaults and runs the tool. @@ -275,5 +362,16 @@ func (d Descriptor) Start(ctx context.Context, in Request, progress Progress) (R if progress == nil { progress = func(int64, int64) {} } - return d.Run(ctx, Request{Inputs: in.Inputs, Values: values}, progress) + result, err := d.Run(ctx, Request{Inputs: in.Inputs, Values: values}, progress) + if err != nil { + return result, err + } + // A note nobody declared has no words in any language, so a window would + // show a key. Refused loudly, as a fault in the build, rather than shown. + for _, n := range result.Notes { + if d.NoteSays(n.ID) == "" { + return Result{}, fmt.Errorf("the tool %s said a note it never declared (%s). This is a fault in the build", d.ID, n.ID) + } + } + return result, nil }