diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2ba8fde..129f0115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1436,8 +1436,8 @@ jobs: # searching. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - # Four targets at five minutes each, plus the build and the baseline pass. - timeout-minutes: 45 + # Seven targets at five minutes each, plus the build and the baseline pass. + timeout-minutes: 55 env: CGO_ENABLED: "0" steps: @@ -1461,7 +1461,7 @@ jobs: # next search starts from nothing. run: | set -euo pipefail - for target in FuzzParseSize FuzzParseRecipe FuzzNameTemplate FuzzCanonicalRecipe FuzzPresetExpansion FuzzChecksumFile; do + for target in FuzzParseSize FuzzParseRecipe FuzzNameTemplate FuzzCanonicalRecipe FuzzPresetExpansion FuzzChecksumFile FuzzArchiveScan; do echo "=== $target ===" go test -tags "$(cat .github/build-tags)" ./internal/guard/ -run "^$" -fuzz "^${target}$" -fuzztime 5m done diff --git a/CHANGELOG.md b/CHANGELOG.md index 1130a5e7..d1a90fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,15 @@ because it turns other people's test suites red. ### Added +- **Is this archive safe to unpack?** `tfg tool archive-scan `, and + the same on the Tools tab, reads a ZIP, TAR, TAR.GZ or gzip file and + everything in it without writing anything, and says what unpacking it + would do: entries written outside the folder, links, devices, names that + land on one file on Windows or macOS, names a system cannot write, + archives built to explode, contents that are not what the archive says. + `--unpacked_limit` reports an archive that unpacks to more than you allow. + Safe ends with code 0 and anything found with 7, the whole report on the + error channel. 7z and RAR are recognised and not read yet. - **Documents: the `rtf` format.** A Rich Text Format document at the exact size you ask for, read by LibreOffice, pandoc, the RichEdit control of Windows, striprtf and Apache Tika. It holds headings, paragraphs with bold diff --git a/README.md b/README.md index 64950636..5cd0aacb 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,7 @@ 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] +tfg tool archive-scan [--unpacked_limit ] [--json] ``` Small things to do with files you already have, beside the generator. Every tool @@ -420,6 +421,13 @@ 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. +`archive-scan` looks into a ZIP, TAR, TAR.GZ or gzip file without unpacking it +and says whether unpacking it is safe: no entry written outside the folder, no +link, no device, no two names landing on one file, no name a system cannot +write, nothing built to explode and nothing damaged. `--unpacked_limit 500mb` +also reports an archive that unpacks to more than that. Safe ends with code 0, +anything found with 7. + ### `tfg damage` ``` diff --git a/internal/cli/toolcmd.go b/internal/cli/toolcmd.go index eb2be61d..e6acd993 100644 --- a/internal/cli/toolcmd.go +++ b/internal/cli/toolcmd.go @@ -297,11 +297,11 @@ func parseAround(fs *flag.FlagSet, args []string) ([]string, error) { // renderToolResult prints what a tool found. // -// A mismatch is a failure with its own code, and a failed run writes nothing -// to standard output - the same as verify - so the whole answer goes to the -// error channel, JSON included. +// A mismatch, or a conclusion the tool declares failing, is a failure with its +// own code, and a failed run writes nothing to standard output - the same as +// verify - so the whole answer goes to the error channel, JSON included. func renderToolResult(d tool.Descriptor, r tool.Result, asJSON bool, out, errOut io.Writer) int { - failed := r.Verdict.Outcome == tool.Mismatch + failed := d.Failed(r) w := out if failed { w = errOut @@ -318,6 +318,9 @@ func renderToolResult(d tool.Descriptor, r tool.Result, asJSON bool, out, errOut if said := r.Verdict.Said(); said != "" { fmt.Fprintf(w, "\n%s\n", said) } + if c, ok := d.Conclusion(r.Concluded); ok { + fmt.Fprintf(w, "\n%s\n", c.Says) + } } if failed { return ExitVerify diff --git a/internal/engine/filename.go b/internal/engine/filename.go index 92cfc06d..ba8ed3af 100644 --- a/internal/engine/filename.go +++ b/internal/engine/filename.go @@ -203,3 +203,15 @@ func describeForbidden(r rune) core.Said { } return core.Says("engine.Character", "the character %q", core.A("Character", string(r))) } + +// Unwritable says whether the generator would refuse to write a file under +// this name - for a tool asking about names somebody else made, each part of a +// path inside an archive. The same rules rather than a copy of them, so a name +// this program writes and a name it warns about are judged by one list. +// +// A part, not a path: a separator is refused here, so a caller splits first, +// and "." and ".." are refused as names of directories, which a caller asking +// about where an entry lands answers before it gets here. +func Unwritable(name string) bool { + return checkFileName("", core.Said{}, name) != nil +} diff --git a/internal/guard/archivescan_test.go b/internal/guard/archivescan_test.go new file mode 100644 index 00000000..abe27cf3 --- /dev/null +++ b/internal/guard/archivescan_test.go @@ -0,0 +1,342 @@ +package guard + +import ( + "archive/tar" + "archive/zip" + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "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/archivescan" +) + +// The archive scanner, tfg tool archive-scan: what it finds in each kind of +// trap, what it ends with, that its own reader of a ZIP lists what archive/zip +// lists, and that the window says what the command line says. +// docs/SKANER-ARCHIWUM-2026-10-19.md. + +// archiveFile writes bytes to a file of this name in a folder of the test's. +func archiveFile(t *testing.T, name string, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// scanArchive runs the tool on an archive and hands back what --json would print +// and the result both surfaces are drawn from. +func scanArchive(t *testing.T, path string, values map[string]string) (*archivescan.Report, tool.Result) { + t.Helper() + d, err := tool.Get(archivescan.ID) + if err != nil { + t.Fatal(err) + } + r, err := d.Start(context.Background(), tool.Request{Inputs: map[string]string{archivescan.InputArchive: path}, Values: values}, nil) + if err != nil { + t.Fatalf("%s was refused: %v", filepath.Base(path), err) + } + report, ok := r.Data.(*archivescan.Report) + if !ok { + t.Fatalf("the result of %s carries %T, not the report --json prints", filepath.Base(path), r.Data) + } + return report, r +} + +// trap is one archive and what the scanner has to find in it. +type trap struct { + what string + file string + data []byte + values map[string]string + // found is every finding the report has to hold, and no other. + found []string + // names are entries one finding has to name. + names map[string][]string + safe bool + // kind is what the archive has to be read as, where that is the point: + // a gzip of zeros begins as an empty TAR would, and is not one. + kind string + // entries is how many entries the archive has to be counted as holding, + // where that is the point: a header for the entries after it is not one. + entries int +} + +// traps is an archive for each kind of trap, and the clean ones beside them. +func traps(t *testing.T) []trap { + t.Helper() + e, accent := string(rune(0xe9)), string(rune(0x301)) + clean := zipOf(t, zipPart{name: "a.txt", body: "hello"}, zipPart{name: "dir/b.txt", body: strings.Repeat("world", 100)}) + return []trap{ + {what: "a clean ZIP", file: "clean.zip", data: clean, safe: true}, + {what: "names Windows stores perfectly well, measured in engine/filename.go", file: "fine.zip", safe: true, + data: zipOf(t, zipPart{name: "con.txt"}, zipPart{name: "nul.txt"}, zipPart{name: "aux"}, zipPart{name: "./a/./b.txt"})}, + {what: "entries leaving the folder", file: "leaves.zip", found: []string{"leaves_folder"}, + data: zipOf(t, zipPart{name: "../up.txt"}, zipPart{name: "/abs.txt"}, zipPart{name: "C:/drive.txt"}, + zipPart{name: "C:rel.txt"}, zipPart{name: "a/../../x"}, zipPart{name: `..\back.txt`}, zipPart{name: ".. /dots.txt"}, zipPart{name: "ok.txt"}), + names: map[string][]string{"leaves_folder": {"../up.txt", "/abs.txt", "C:/drive.txt", "C:rel.txt", "a/../../x", `..\back.txt`, ".. /dots.txt"}}}, + {what: "a link in a ZIP", file: "link.zip", found: []string{"links"}, + data: zipOf(t, zipPart{name: "link", body: "../../etc/passwd", mode: os.ModeSymlink | 0o777}), + names: map[string][]string{"links": {"link"}}}, + {what: "entries landing on one file", file: "collide.zip", found: []string{"collides"}, + data: zipOf(t, zipPart{name: "Readme.txt"}, zipPart{name: "README.TXT"}, zipPart{name: "a"}, zipPart{name: "a/b"}, + zipPart{name: "caf" + e}, zipPart{name: "cafe" + accent}), + names: map[string][]string{"collides": {"README.TXT", "a/b", "cafe" + accent}}}, + {what: "a name Windows strips and lands on another", file: "trail.zip", found: []string{"collides", "unwritable"}, + data: zipOf(t, zipPart{name: "trail"}, zipPart{name: "trail."})}, + {what: "names no system writes as they are", file: "names.zip", found: []string{"unwritable"}, + data: zipOf(t, zipPart{name: "nul"}, zipPart{name: "q?.txt"}, zipPart{name: strings.Repeat("x", 256)}), + names: map[string][]string{"unwritable": {"nul", "q?.txt"}}}, + {what: "a name at the entry other than the one listed", file: "twonames.zip", found: []string{"two_names"}, + data: handZip("good.txt", "evil.txt", false)}, + {what: "two entries sharing their bytes", file: "overlap.zip", found: []string{"overlapping", "two_names"}, + data: handZip("first", "first", true), names: map[string][]string{"overlapping": {"first", "second"}}}, + {what: "a ZIP built to explode, by its list", file: "bomb.zip", found: []string{"bomb"}, + data: zipOf(t, zipPart{raw: &zip.FileHeader{Name: "zeros", Method: zip.Deflate, UncompressedSize64: 2 << 30, + CompressedSize64: uint64(len(deflated(t, "x")))}, body: deflated(t, "x")})}, + {what: "an entry holding more than its size", file: "longer.zip", found: []string{"longer_than_said"}, + data: zipOf(t, zipPart{raw: &zip.FileHeader{Name: "more", Method: zip.Deflate, UncompressedSize64: 10, + CompressedSize64: uint64(len(deflated(t, strings.Repeat("y", 1000))))}, body: deflated(t, strings.Repeat("y", 1000))})}, + {what: "contents that do not match their checksum", file: "crc.zip", found: []string{"damaged"}, + data: zipOf(t, zipPart{raw: &zip.FileHeader{Name: "stored", Method: zip.Store, CRC32: 1, UncompressedSize64: 4, CompressedSize64: 4}, body: "abcd"})}, + {what: "entries that cannot be checked", file: "unchecked.zip", found: []string{"unchecked"}, + data: zipOf(t, zipPart{raw: &zip.FileHeader{Name: "locked", Method: zip.Store, Flags: 1, UncompressedSize64: 4, CompressedSize64: 4}, body: "abcd"}, + zipPart{raw: &zip.FileHeader{Name: "lzma", Method: 14, UncompressedSize64: 4, CompressedSize64: 4}, body: "abcd"}), + names: map[string][]string{"unchecked": {"locked", "lzma"}}}, + {what: "an archive inside, reported and harmless", file: "nested.zip", found: []string{"nested"}, safe: true, + data: zipOf(t, zipPart{name: "inner.zip", body: string(clean)})}, + {what: "a ZIP without the record at its end", file: "cut.zip", found: []string{"damaged"}, data: clean[:len(clean)-5]}, + {what: "a TAR.GZ with every kind of entry that is not a file", file: "bad.tar.gz", found: []string{"leaves_folder", "links", "special"}, + data: tarOf(t, true, tarPart{name: "../up.txt", body: "abc"}, tarPart{name: "lnk", typ: '2', link: "/etc/shadow"}, + tarPart{name: "hard", typ: '1', link: "../outside"}, tarPart{name: "fifo", typ: '6'}, tarPart{name: "dev", typ: '3'}, + tarPart{name: "suid", body: "x", mode: 0o4755}), + names: map[string][]string{"links": {"lnk", "hard"}, "special": {"fifo", "dev", "suid"}}}, + {what: "a clean TAR", file: "clean.tar", data: tarOf(t, false, tarPart{name: "x.txt", body: "abc"}), safe: true}, + // git archive puts the commit in a header for every entry after it, and + // GitHub's source archives are made by it. + {what: "a TAR as git archive writes it", file: "git.tar", safe: true, entries: 1, + data: tarOf(t, false, tarPart{name: "pax_global_header", typ: tar.TypeXGlobalHeader, records: map[string]string{"comment": strings.Repeat("ab12", 10)}}, + tarPart{name: "a.txt", body: "abc"})}, + {what: "a header giving every entry after it a name", file: "global.tar", found: []string{"special"}, + data: tarOf(t, false, tarPart{name: "pax_global_header", typ: tar.TypeXGlobalHeader, records: map[string]string{"comment": "x", "path": "../up.txt"}}, + tarPart{name: "a.txt", body: "abc"})}, + // The bits that make a program run as its owner say, on a folder, whose + // the files made in it are - a folder a group shares carries one. + {what: "a TAR of a folder a group shares", file: "group.tar", safe: true, + data: tarOf(t, false, tarPart{name: "shared/", typ: tar.TypeDir, mode: 0o2775}, tarPart{name: "shared/a.txt", body: "abc"})}, + {what: "a ZIP of a folder a group shares", file: "group.zip", safe: true, + data: zipOf(t, zipPart{name: "shared/", mode: os.ModeDir | os.ModeSetgid | 0o775}, zipPart{name: "shared/a.txt", body: "abc"})}, + {what: "a ZIP entry that runs as its owner", file: "suid.zip", found: []string{"special"}, + data: zipOf(t, zipPart{name: "run", body: "x", mode: os.ModeSetuid | 0o755}), names: map[string][]string{"special": {"run"}}}, + {what: "an empty TAR", file: "empty.tar", data: make([]byte, 1024), safe: true, kind: "tar"}, + {what: "a gzip of one file", file: "one.gz", data: gzipOf(t, []byte("hello\n")), safe: true, kind: "gzip"}, + {what: "a gzip of zeros built to explode", file: "bomb.gz", found: []string{"bomb", "stopped"}, data: zerosMembers(t, nil), kind: "gzip"}, + {what: "a TAR.GZ built to explode", file: "bomb.tar.gz", found: []string{"bomb", "stopped"}, data: tarGzBomb(t), kind: "tar.gz"}, + // Zeros after the end of a small TAR: no TAR program reads them, and + // gunzip writes every one - only the count below the TAR sees them. + {what: "a TAR.GZ exploding after the end of its TAR", file: "after.tar.gz", found: []string{"bomb", "stopped"}, kind: "tar.gz", + data: zerosMembers(t, gzipOf(t, tarOf(t, false, tarPart{name: "a", body: "abc"})))}, + {what: "a TAR.GZ cut short", file: "cut.tar.gz", found: []string{"damaged"}, data: tarOf(t, true, tarPart{name: "a", body: strings.Repeat("z", 5000)})[:40]}, + {what: "more than the limit given", file: "limit.zip", data: clean, values: map[string]string{archivescan.SettingLimit: "100"}, + found: []string{"over_limit"}}, + {what: "within the limit given", file: "within.zip", data: clean, values: map[string]string{archivescan.SettingLimit: "1kb"}, safe: true}, + } +} + +// TestTheArchiveScanFindsEveryTrapItNames scans an archive for each kind of +// trap and holds the report to exactly what is in it, and the conclusion to +// whether any of that makes it unsafe. +func TestTheArchiveScanFindsEveryTrapItNames(t *testing.T) { + for _, c := range traps(t) { + report, r := scanArchive(t, archiveFile(t, c.file, c.data), c.values) + found := make([]string, 0, len(report.Found)) + for k := range report.Found { + found = append(found, k) + } + sort.Strings(found) + want := append([]string{}, c.found...) + sort.Strings(want) + if !slices.Equal(found, want) { + t.Errorf("%s (%s): found %v and holds %v", c.what, c.file, found, want) + } + for key, names := range c.names { + for _, name := range names { + if !slices.Contains(report.Found[key], name) { + t.Errorf("%s (%s): %s names %q and not %q", c.what, c.file, key, report.Found[key], name) + } + } + } + if c.kind != "" && report.Kind != c.kind { + t.Errorf("%s (%s): read as %s, and it is %s", c.what, c.file, report.Kind, c.kind) + } + if c.entries != 0 && report.Entries != c.entries { + t.Errorf("%s (%s): counted %d entries, and it holds %d", c.what, c.file, report.Entries, c.entries) + } + if report.Safe != c.safe || (r.Concluded == "safe") != c.safe { + t.Errorf("%s (%s): safe is %v in the report and the conclusion is %q, and it is %v", c.what, c.file, report.Safe, r.Concluded, c.safe) + } + } +} + +// TestTheArchiveScanReadsTheListArchiveZipReads holds the scanner's own +// reader of a ZIP to archive/zip, a reader nobody here wrote: as many entries, +// as many bytes once unpacked - with ZIP64, a comment after the end record, +// entries whose sizes follow their contents, and names outside ASCII. +func TestTheArchiveScanReadsTheListArchiveZipReads(t *testing.T) { + var many []zipPart + for i := range 70000 { + many = append(many, zipPart{name: fmt.Sprintf("d/%06d", i)}) + } + var commented bytes.Buffer + w := zip.NewWriter(&commented) + for _, name := range []string{"zolty-" + string(rune(0x17c)) + ".txt", "dir/", "dir/b.bin"} { + out, err := w.Create(name) + if err == nil && !strings.HasSuffix(name, "/") { + _, err = out.Write(bytes.Repeat([]byte(name), 300)) + } + if err != nil { + t.Fatal(err) + } + } + if err := w.SetComment(strings.Repeat("PK comment ", 100)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + for name, data := range map[string][]byte{"many.zip": zipOf(t, many...), "commented.zip": commented.Bytes()} { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + var unpacked uint64 + for _, f := range r.File { + unpacked += f.UncompressedSize64 + } + report, _ := scanArchive(t, archiveFile(t, name, data), nil) + if report.Entries != len(r.File) || report.Unpacked != unpacked || !report.Safe { + t.Errorf("%s: the scan read %d entries and %d bytes unpacked, safe %v, and archive/zip reads %d and %d", + name, report.Entries, report.Unpacked, report.Safe, len(r.File), unpacked) + } + } +} + +// TestTheArchiveScanEndsWithTheCodeOfWhatHappened holds tfg tool archive-scan +// to the frozen table: safe is OK, a trap is VERIFY with the whole report on +// the error channel, a mistake in the request USAGE, a file that is not there +// IO, a stop INTERRUPTED. +func TestTheArchiveScanEndsWithTheCodeOfWhatHappened(t *testing.T) { + clean := archiveFile(t, "clean.zip", zipOf(t, zipPart{name: "a.txt", body: "a"})) + leaves := archiveFile(t, "leaves.zip", zipOf(t, zipPart{name: "../a.txt"})) + sevenZip := archiveFile(t, "x.7z", append([]byte{'7', 'z', 0xBC, 0xAF, 0x27, 0x1C}, make([]byte, 32)...)) + text := archiveFile(t, "notes.txt", []byte("not an archive")) + stopped, stop := context.WithCancel(context.Background()) + stop() + for _, c := range []struct { + name string + ctx context.Context + args []string + want int + }{ + {"a clean archive", context.Background(), []string{clean}, cli.ExitOK}, + {"an archive with a trap", context.Background(), []string{leaves}, cli.ExitVerify}, + {"more than the limit", context.Background(), []string{clean, "--unpacked_limit", "0"}, cli.ExitVerify}, + {"a limit that is not a size", context.Background(), []string{clean, "--unpacked_limit", "10xb"}, cli.ExitUsage}, + {"a file that is not an archive", context.Background(), []string{text}, cli.ExitUsage}, + {"an archive this tool does not read", context.Background(), []string{sevenZip}, cli.ExitUsage}, + {"a directory", context.Background(), []string{filepath.Dir(clean)}, cli.ExitUsage}, + {"no file", context.Background(), nil, cli.ExitUsage}, + {"two files", context.Background(), []string{clean, clean}, cli.ExitUsage}, + {"a file that is not there", context.Background(), []string{clean + ".missing"}, cli.ExitIO}, + {"a stop", stopped, []string{clean}, cli.ExitInterrupted}, + } { + var out, errOut bytes.Buffer + code := cli.Run(c.ctx, append([]string{"tool", archivescan.ID}, c.args...), &out, &errOut) + 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.String(), errOut.String()) + } + if code != cli.ExitOK && out.Len() > 0 { + t.Errorf("%s: failed and still wrote to standard output: %s", c.name, out.String()) + } + } +} + +// TestTheToolsTabLooksIntoAnArchive runs the scanner from the screen, with a +// limit typed into its box, and holds what the screen says to what the +// command line says: the finding, the entry it names and the conclusion. +func TestTheToolsTabLooksIntoAnArchive(t *testing.T) { + path := archiveFile(t, "leaves.zip", zipOf(t, zipPart{name: "../escape.txt", body: "a"})) + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{"tool", archivescan.ID, path, "--unpacked_limit", "0"}, &out, &errOut); code != cli.ExitVerify { + t.Fatalf("the command line ended with %d: %s", code, errOut.String()) + } + d, err := tool.Get(archivescan.ID) + if err != nil { + t.Fatal(err) + } + var said []string + for _, c := range d.Conclusions { + if c.Fails { + said = append(said, c.Says) + } + } + said = append(said, d.NoteSays("leaves_folder"), d.NoteSays("over_limit"), "../escape.txt") + host := newFakeHost(t) + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + chooseTool(t, screen, archivescan.ID) + fillField(t, screen, text.SettingLabel(archivescan.InputArchive), path) + fillField(t, screen, text.SettingLabel(archivescan.SettingLimit), "0") + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + words := allText(screen) + for _, s := range said { + if s == "" || !strings.Contains(errOut.String(), s) || !strings.Contains(words, s) { + t.Errorf("%q is not on both: the command line said\n%s\nand the screen\n%s", s, errOut.String(), words) + } + } +} + +// FuzzArchiveScan hands the scanner whatever bytes the fuzzer makes, as an +// archive somebody was sent. It must answer - refuse or conclude - without +// failing in itself, and a conclusion has to agree with the report. +func FuzzArchiveScan(f *testing.F) { + t := &testing.T{} + f.Add(zipOf(t, zipPart{name: "a.txt", body: "abc"}, zipPart{name: "../b"})) + f.Add(tarOf(t, true, tarPart{name: "a", body: "abc"}, tarPart{name: "l", typ: '2', link: "/x"})) + f.Add(tarOf(t, false, tarPart{name: "a", body: "abc"})) + f.Add(handZip("first", "first", true)) + d, err := tool.Get(archivescan.ID) + if err != nil { + f.Fatal(err) + } + dir := f.TempDir() + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1<<20 { + return + } + path := filepath.Join(dir, "fuzzed") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + r, err := d.Start(context.Background(), tool.Request{Inputs: map[string]string{archivescan.InputArchive: path}}, nil) + if err != nil { + return + } + report, ok := r.Data.(*archivescan.Report) + if !ok || report.Safe != (r.Concluded == "safe") || r.Concluded == "" { + t.Fatalf("a scan concluded %q with a report saying safe is %v", r.Concluded, ok && report.Safe) + } + }) +} diff --git a/internal/guard/archivescanbuild_test.go b/internal/guard/archivescanbuild_test.go new file mode 100644 index 00000000..0da1c9bb --- /dev/null +++ b/internal/guard/archivescanbuild_test.go @@ -0,0 +1,208 @@ +package guard + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/flate" + "compress/gzip" + "encoding/binary" + "hash/crc32" + "io" + "os" + "testing" +) + +// The archives the guards of the archive scanner look into, built here rather +// than kept as files: each is the smallest that holds one trap, and a builder +// says what it holds where a file of bytes would not. + +// zipPart is one entry of a ZIP built with archive/zip. +type zipPart struct { + name string + body string + // mode is the Unix mode written with the entry, when there is one - a + // link is a ZIP entry whose mode says so and whose contents are its target. + mode os.FileMode + // raw is an entry written as it is, header and all, for the entries + // archive/zip would not write: a size that lies, a checksum that is wrong. + raw *zip.FileHeader +} + +// zipOf is a ZIP holding these entries. +func zipOf(t *testing.T, parts ...zipPart) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, p := range parts { + writeZipPart(t, w, p) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// writeZipPart writes one entry of zipOf. +func writeZipPart(t *testing.T, w *zip.Writer, p zipPart) { + t.Helper() + if p.raw != nil { + out, err := w.CreateRaw(p.raw) + if err == nil { + _, err = io.WriteString(out, p.body) + } + if err != nil { + t.Fatal(err) + } + return + } + h := &zip.FileHeader{Name: p.name, Method: zip.Deflate} + if p.mode != 0 { + h.SetMode(p.mode) + } + out, err := w.CreateHeader(h) + if err == nil { + _, err = io.WriteString(out, p.body) + } + if err != nil { + t.Fatal(err) + } +} + +// deflated is text through deflate, for an entry written raw. +func deflated(t *testing.T, text string) string { + t.Helper() + var buf bytes.Buffer + w, err := flate.NewWriter(&buf, flate.BestCompression) + if err == nil { + _, err = io.WriteString(w, text) + } + if err == nil { + err = w.Close() + } + if err != nil { + t.Fatal(err) + } + return buf.String() +} + +// tarPart is one entry of a TAR. +type tarPart struct { + name string + typ byte + link string + body string + mode int64 + // records are the PAX values a header carries - for a global one, the + // values it gives every entry after it. + records map[string]string +} + +// tarOf is a TAR holding these entries, through gzip when asked. +func tarOf(t *testing.T, gzipped bool, parts ...tarPart) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, p := range parts { + h := &tar.Header{Name: p.name, Typeflag: p.typ, Linkname: p.link, Size: int64(len(p.body)), Mode: 0o644, PAXRecords: p.records} + if p.mode != 0 { + h.Mode = p.mode + } + if p.typ == 0 { + h.Typeflag = tar.TypeReg + } + if p.typ == tar.TypeXGlobalHeader { + // archive/tar writes a global header of its name and values alone. + h = &tar.Header{Name: p.name, Typeflag: p.typ, PAXRecords: p.records} + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(tw, p.body); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if !gzipped { + return buf.Bytes() + } + return gzipOf(t, buf.Bytes()) +} + +// gzipOf is bytes through gzip. +func gzipOf(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err == nil { + _, err = w.Write(data) + } + if err == nil { + err = w.Close() + } + if err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// zerosMembers is gzip that unpacks to a little over a gigabyte of zeros from +// about a megabyte: one member of 64 MiB, repeated, which gzip reads as one +// stream. Built in a fraction of a second where compressing a gigabyte would +// take several. +func zerosMembers(t *testing.T, in []byte) []byte { + t.Helper() + member := gzipOf(t, make([]byte, 64<<20)) + out := append([]byte(nil), in...) + for range 17 { + out = append(out, member...) + } + return out +} + +// tarGzBomb is a TAR.GZ whose one entry says it holds 2 GiB of zeros, and +// does, through members of zeros after its header. +func tarGzBomb(t *testing.T) []byte { + t.Helper() + var head bytes.Buffer + if err := tar.NewWriter(&head).WriteHeader(&tar.Header{Name: "big", Typeflag: tar.TypeReg, Size: 2 << 30, Mode: 0o644}); err != nil { + t.Fatal(err) + } + return zerosMembers(t, gzipOf(t, head.Bytes())) +} + +// handZip is a ZIP of one stored entry written byte by byte, which lists the +// entry under listed and writes it under local at the entry itself - and, +// when twice, lists a second entry starting at the same header, so two +// entries share their bytes. +func handZip(listed, local string, twice bool) []byte { + data := []byte("shared bytes") + sum := crc32.ChecksumIEEE(data) + var out bytes.Buffer + le := func(vs ...any) { + for _, v := range vs { + _ = binary.Write(&out, binary.LittleEndian, v) + } + } + le(uint32(0x04034b50), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), + sum, uint32(len(data)), uint32(len(data)), uint16(len(local)), uint16(0)) + out.WriteString(local) + out.Write(data) + listAt := out.Len() + names := []string{listed} + if twice { + names = append(names, "second") + } + for _, name := range names { + le(uint32(0x02014b50), uint16(20), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), + sum, uint32(len(data)), uint32(len(data)), uint16(len(name)), uint16(0), uint16(0), + uint16(0), uint16(0), uint32(0), uint32(0)) + out.WriteString(name) + } + listSize := out.Len() - listAt + le(uint32(0x06054b50), uint16(0), uint16(0), uint16(len(names)), uint16(len(names)), + uint32(listSize), uint32(listAt), uint16(0)) + return out.Bytes() +} diff --git a/internal/guard/keyboard_test.go b/internal/guard/keyboard_test.go index 560a6e90..4147967d 100644 --- a/internal/guard/keyboard_test.go +++ b/internal/guard/keyboard_test.go @@ -131,6 +131,7 @@ func TestTheKeyboardRunsAToolFromInsideItsBox(t *testing.T) { entryUnder(t, first, text.FieldTargetID()).SetText("keys") screen := selectTab(t, content, text.TabTools()) + chooseTool(t, screen, checksum.ID) box := entryUnder(t, screen, text.SettingLabel(checksum.InputFile)) box.SetText(path) pressInABox(t, box, fyne.KeyReturn, fyne.KeyModifierControl) diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 781ee2f3..91bb59d2 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -98,9 +98,10 @@ var layer = map[string]int{ "internal/engine": 3, "internal/audit": 3, - "internal/tool": 4, - "internal/tool/checksum": 4, - "internal/tool/all": 4, + "internal/tool": 4, + "internal/tool/checksum": 4, + "internal/tool/archivescan": 4, + "internal/tool/all": 4, "internal/cli": 5, "internal/gui": 5, @@ -203,8 +204,9 @@ var sameLayerAllowed = map[string][]string{ // A tool registers itself with the registry beside it, and the package that // pulls every tool in reaches each of them - the arrangement of the formats. - "internal/tool/checksum": {"internal/tool"}, - "internal/tool/all": {"internal/tool/checksum"}, + "internal/tool/checksum": {"internal/tool"}, + "internal/tool/archivescan": {"internal/tool"}, + "internal/tool/all": {"internal/tool/checksum", "internal/tool/archivescan"}, // A recipe checks the damages it names against the registry that holds // them, the same way it checks a format against the format registry. It diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py index 795754f2..4ec7e9f3 100644 --- a/internal/guard/mutation/entries.py +++ b/internal/guard/mutation/entries.py @@ -2276,7 +2276,7 @@ # entry beside this one already proves the too-narrow direction. "a box for a number goes back to running the width of the form", "internal/gui/parts/property.go", - " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control)", + " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p, ofATool))), control)", " return Sized(ColumnWidth, control)", "TestABoxForANumberIsNotAsWideAsTheForm", ), @@ -2286,7 +2286,7 @@ # mid-word. "a box for a number is too narrow to show its own placeholder", "internal/gui/parts/property.go", - " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control)", + " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p, ofATool))), control)", " return Numeric(control)", "TestABoxIsWideEnoughForItsOwnPlaceholder", ), @@ -2314,8 +2314,8 @@ # is the only spelling that works in a recipe. "the recipe key of a setting is nowhere on the screen", "internal/gui/parts/property.go", - " tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control))", - " NoDetail, ShapedFor(p, f.Control))", + " tips.Say(text.WrittenAs(owner, p.Name)), shapedFor(p, f.Control, text.IsTool(owner)))", + " NoDetail, shapedFor(p, f.Control, text.IsTool(owner)))", "TestTheRecipeKeyOfASettingIsStillReachable", ), ( @@ -10071,7 +10071,7 @@ "internal/gui/parts/property.go", # Repointed 2026-09-23: unsized, a box takes one column of the grid, # so the defect is a width as wide as the form. - "\tcase p.Kind == format.PropertyText:\n\t\treturn Sized(fyne.Max(TextWidth, roomFor(leftAlone(p))), control)\n", + "\tcase p.Kind == format.PropertyText:\n\t\treturn Sized(fyne.Max(TextWidth, roomFor(leftAlone(p, ofATool))), control)\n", "\tcase p.Kind == format.PropertyText:\n\t\treturn Sized(ColumnWidth, control)\n", "TestOnlyAPathTakesTheWholeRow", ), @@ -12911,8 +12911,8 @@ ( "an empty text box says its value is worked out from the size again", "internal/gui/parts/property.go", - "\tcase p.Kind == format.PropertyText:\n\t\treturn text.PlaceholderNotSet()\n", - "\tcase false && p.Kind == format.PropertyText:\n\t\treturn text.PlaceholderNotSet()\n", + "\tcase p.Kind == format.PropertyText || ofATool:\n\t\treturn text.PlaceholderNotSet()\n", + "\tcase false && p.Kind == format.PropertyText || ofATool:\n\t\treturn text.PlaceholderNotSet()\n", "TestEveryScreenStillDrawsItsStoredPicture", ), ( @@ -13223,7 +13223,7 @@ # through - read from the source, so a file built only off Windows # is held on the Windows machine too. "the checksum tool opens its file for writing as well, and the one claim does not notice", - "internal/tool/checksum/open_unix.go", + "internal/tool/open_unix.go", "os.O_RDONLY|syscall.O_NONBLOCK, 0)", "os.O_RDWR|syscall.O_NONBLOCK, 0)", "TestEveryFileThisToolWritesIsCreatedThroughOneClaim", @@ -15025,4 +15025,214 @@ "\"zero-heads\":", "TestEveryLanguageDescribesEveryDamage", ), + ( + "archive-scan takes only a part of two dots for the folder above, not one Windows trims to it", + "internal/tool/archivescan/names.go", + "return strings.Trim(part, \". \") == \"\" && strings.Count(part, \".\") >= 2", + "return part == \"..\"", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan takes a backslash for part of a name, as Linux does", + "internal/tool/archivescan/names.go", + "func isSeparator(r rune) bool { return r == '/' || r == '\\\\' }", + "func isSeparator(r rune) bool { return r == '/' }", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan lets a drive letter stay in the folder", + "internal/tool/archivescan/names.go", + "return len(name) >= 2 && name[1] == ':' && isLetter(name[0])", + "return false", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan tells names apart by case, as Linux does and Windows does not", + "internal/tool/archivescan/names.go", + "return strings.ToLower(norm.NFC.String(part))", + "return norm.NFC.String(part)", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan compares accents as they are written, not as macOS stores them", + "internal/tool/archivescan/names.go", + "return strings.ToLower(norm.NFC.String(part))", + "return strings.ToLower(part) + norm.NFC.String(\"\")", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan misses a file standing where another entry needs a folder", + "internal/tool/archivescan/names.go", + "\t\tif b.file[id] || file {\n", + "\t\tif file {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan never sees two entries sharing their bytes", + "internal/tool/archivescan/zip.go", + "\t\tif i > 0 && sp.from < reach {\n", + "\t\tif i > 0 && sp.from < reach && false {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan never compares the name at an entry with the listed one", + "internal/tool/archivescan/zip.go", + "\tif !bytes.Equal(local, []byte(e.name)) {\n", + "\tif false && !bytes.Equal(local, []byte(e.name)) {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan calls a ZIP built to explode only at four gigabytes and more", + "internal/tool/archivescan/findings.go", + "if !s.exploded && s.unpacked >= BombBytes && s.unpacked > uint64(s.size)*BombRatio {", + "if !s.exploded && s.unpacked >= 4*BombBytes && s.unpacked > uint64(s.size)*BombRatio {", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan counts only the contents of a TAR.GZ, so zeros after its TAR are unpacked unseen", + "internal/tool/archivescan/tar.go", + "\tin := bufio.NewReaderSize(&inflating{r: z, s: s}, 64<<10)\n", + "\tin := bufio.NewReaderSize(z, 64<<10)\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan reads a gzip of zeros as an empty TAR", + "internal/tool/archivescan/tar.go", + "\tif len(head) < 257+len(tarMagic) || !bytes.Equal(head[257:257+len(tarMagic)], tarMagic) {\n", + "\tif !isTar(head) && !bytes.Equal(head, nil) {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan never holds contents to their checksum", + "internal/tool/archivescan/zip.go", + "\tcase sum.Sum32() != e.crc:\n", + "\tcase false:\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan misses an entry holding more than the list says", + "internal/tool/archivescan/zip.go", + "\tcase got > e.unpacked:\n\t\tz.note(noteLonger, e.name)\n", + "\tcase false:\n\t\tz.note(noteLonger, e.name)\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan reads an encrypted entry as if it were not", + "internal/tool/archivescan/zip.go", + "\tcase e.flags&1 != 0 || e.method == methodAES:\n", + "\tcase e.method == methodAES:\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan takes a hard link in a TAR for something else than a link", + "internal/tool/archivescan/tar.go", + "\tcase tar.TypeSymlink, tar.TypeLink:\n", + "\tcase tar.TypeSymlink:\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan misses a TAR entry that runs with its owner's rights", + "internal/tool/archivescan/tar.go", + "\tif h.Typeflag != tar.TypeDir && h.Mode&(modeSetUser|modeSetGroup) != 0 {\n", + "\tif false && h.Mode&(modeSetUser|modeSetGroup) != 0 {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan calls an archive unsafe for holding another archive", + "internal/tool/archivescan/findings.go", + "{tool.Note{ID: noteNested, Says: \"Archives inside it, which were not opened:\"}, true},", + "{tool.Note{ID: noteNested, Says: \"Archives inside it, which were not opened:\"}, false},", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan never compares what an archive unpacks to with the limit given", + "internal/tool/archivescan/findings.go", + "if s.limit >= 0 && s.unpacked > uint64(s.limit) {", + "if s.limit < 0 && s.unpacked > uint64(s.limit) {", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan reads a ZIP of more than 65 535 entries without its ZIP64 record", + "internal/tool/archivescan/ziplist.go", + "\t\tif e.entries == 0xFFFF || e.listSize == 0xFFFFFFFF || e.listAt == 0xFFFFFFFF {\n", + "\t\tif false {\n", + "TestTheArchiveScanReadsTheListArchiveZipReads", + ), + ( + "a conclusion a tool declares failing ends the command line with success", + "internal/tool/tool.go", + "\treturn ok && c.Fails\n", + "\treturn false && ok && c.Fails\n", + "TestTheArchiveScanEndsWithTheCodeOfWhatHappened", + ), + ( + "the Tools tab leaves out the conclusion a tool came to", + "internal/gui/window/toolsresult.go", + "\tif said, ok := conclusionSaid(d, *r); ok {\n", + "\tif said, ok := conclusionSaid(d, *r); ok && false {\n", + "TestTheToolsTabLooksIntoAnArchive", + ), + ( + "the conclusions a tool declares never reach the window's catalogue", + "internal/gui/text/registrywords.go", + "\tfor _, c := range d.Conclusions {\n", + "\tfor _, c := range d.Conclusions[:0] {\n", + "TestTheRegistryCatalogueSaysWhatTheRegistriesSay", + ), + ( + "an empty limit of a tool says it is worked out from the size, as a format's does", + "internal/gui/parts/property.go", + "\tcase p.Kind == format.PropertyText || ofATool:\n", + "\tcase p.Kind == format.PropertyText:\n", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + "archive-scan counts the header git archive writes for every entry after it as an entry of an unknown type", + "internal/tool/archivescan/tar.go", + "\t\tif h.Typeflag == tar.TypeXGlobalHeader {\n", + "\t\tif false && h.Typeflag == tar.TypeXGlobalHeader {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan passes over a header that gives every entry after it a name", + "internal/tool/archivescan/tar.go", + "\t\tif !globalHarmless[key] && (first == \"\" || key < first) {\n", + "\t\tif false && (first == \"\" || key < first) {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan takes the commit git archive writes for something that changes the entries", + "internal/tool/archivescan/tar.go", + "\t\"comment\": true, ", + "\t\"comment\": false, ", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan says a TAR folder a group shares runs with its owner's rights", + "internal/tool/archivescan/tar.go", + "\tif h.Typeflag != tar.TypeDir && h.Mode&(modeSetUser|modeSetGroup) != 0 {\n", + "\tif h.Mode&(modeSetUser|modeSetGroup) != 0 {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan says a ZIP folder a group shares runs with its owner's rights", + "internal/tool/archivescan/tar.go", + "\tif mode&0xF000 != modeFolder && mode&(modeSetUser|modeSetGroup) != 0 {\n", + "\tif mode&(modeSetUser|modeSetGroup) != 0 {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan misses a ZIP entry that runs with its owner's rights", + "internal/tool/archivescan/tar.go", + "\tif mode&0xF000 != modeFolder && mode&(modeSetUser|modeSetGroup) != 0 {\n", + "\tif false && mode&(modeSetUser|modeSetGroup) != 0 {\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), + ( + "archive-scan reads every deflated ZIP entry after the first from where the first one ended", + "internal/tool/archivescan/zip.go", + "\t_ = z.inflate.(flate.Resetter).Reset(src, nil)\n", + "\t_ = src\n", + "TestTheArchiveScanFindsEveryTrapItNames", + ), ] diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index cfceab0a..f7ecae88 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -37,7 +37,7 @@ var notProvenByMutation = map[string]bool{ // - the substitution would be applied and the test would not exist to // catch it. It runs on every Linux and macOS runner of the CI. The two // breaks it is for - asking IsDir where IsRegular stands in - // checksum.openRegular, and an open without O_NONBLOCK in open_unix.go. + // tool.OpenRegular, and an open without O_NONBLOCK in tool/open_unix.go. // The first was read, not run (2026-09-29). The second was run by hand in // a Linux container on 2026-09-30, through the tool rather than through // this test: without the flag a pipe was still waited on after five diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 4a977df3..beecd8e1 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -360,6 +360,11 @@ var reachableFromTheWindow = []string{ "tool:checksum-write", "tool:checksum-write.algorithm", "tool:checksum-write.folder", + // Held by TestTheToolsTabLooksIntoAnArchive, which runs it from the + // screen with the limit set and holds what it says to the command line. + "tool:archive-scan", + "tool:archive-scan.archive", + "tool:archive-scan.unpacked_limit", } // notYetReachable is everything the engine can do that the window cannot. diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index d9f5df17..4843c6d9 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -22,6 +22,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/archivescan" "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" ) @@ -332,6 +333,7 @@ func screenScenes() []screenScene { pressNamed(t, s.tab, text.ButtonRunTool()) }}, {name: "tools-result", tab: text.TabTools(), set: func(t *testing.T, s scene) { + chooseToolIn(checksum.ID)(t, s) fillField(t, s.tab, text.SettingLabel("file"), "testdata/checksum-sample.txt") fillField(t, s.tab, text.SettingLabel("expected"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") pressNamed(t, s.tab, text.ButtonRunTool()) @@ -341,12 +343,20 @@ func screenScenes() []screenScene { // its section keeping the height of the tool before - so the picture // is of the change, not of a screen that opened with it. And a check // that matches, its verdict in the colour of a match. - {name: "tools-write", tab: text.TabTools(), after: chooseToolIn(checksum.WriteID)}, + {name: "tools-write", tab: text.TabTools(), set: chooseToolIn(checksum.ID), after: chooseToolIn(checksum.WriteID)}, {name: "tools-check-result", tab: text.TabTools(), set: func(t *testing.T, s scene) { chooseToolIn(checksum.CheckID)(t, s) fillField(t, s.tab, text.SettingLabel(checksum.InputChecksumFile), "testdata/checksum-sample.SHA256SUMS") pressNamed(t, s.tab, text.ButtonRunTool()) }}, + // The archive scanner's answer, since 2026-10-19: what it found and a + // conclusion in the colour of a mismatch - the first answer a tool + // declares rather than compares. + {name: "tools-archive-result", tab: text.TabTools(), set: func(t *testing.T, s scene) { + chooseToolIn(archivescan.ID)(t, s) + fillField(t, s.tab, text.SettingLabel(archivescan.InputArchive), "testdata/archive-sample.zip") + pressNamed(t, s.tab, text.ButtonRunTool()) + }}, {name: "generate", tab: text.TabOneTarget()}, {name: "generate-empty", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { fillField(t, s.tab, text.FieldCount(), "0") diff --git a/internal/guard/testdata/archive-sample.zip b/internal/guard/testdata/archive-sample.zip new file mode 100644 index 00000000..1c95a2f0 Binary files /dev/null and b/internal/guard/testdata/archive-sample.zip differ diff --git a/internal/guard/testdata/screens/tools-archive-result.png b/internal/guard/testdata/screens/tools-archive-result.png new file mode 100644 index 00000000..d81278fe Binary files /dev/null and b/internal/guard/testdata/screens/tools-archive-result.png differ diff --git a/internal/guard/testdata/screens/tools-archive-result.xml b/internal/guard/testdata/screens/tools-archive-result.xml new file mode 100644 index 00000000..8eb6b4ac --- /dev/null +++ b/internal/guard/testdata/screens/tools-archive-result.xml @@ -0,0 +1,389 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + archive-scan - Is this archive safe to unpack? + + + + + + + + + + + + + + + Reads a ZIP, TAR, TAR.GZ or gzip file and everything in it without unpacking anything, and says whether unpacking it + could write outside its folder, overwrite a file or fill the disk. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + Archive + + + + + + + + + + + + + + + testdata/archive-sample.zip + + + + + + + + Choose... + + + + + + + + + + + Unpacked limit + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + Archive: zip + + + + + + + Entries: 3 + + + + + + + Size: 335 B + + + + + + + Unpacked: 13 B + + + + + + + Entries that would be written outside the folder: ../escape.txt + + + + + + + Entries that land on the same file as another on Windows or macOS: README.TXT = Readme.txt + + + + + + + Not safe to unpack: the lines above say why. + + + + + + + + + Copy all + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + Open folder + + + + + + + + + + + + + + + + + + + Donate + + + + + GitHub + + + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/tools-refused.png b/internal/guard/testdata/screens/tools-refused.png index 4eb9e09c..705590a1 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 24c79931..b7e5cbf0 100644 --- a/internal/guard/testdata/screens/tools-refused.xml +++ b/internal/guard/testdata/screens/tools-refused.xml @@ -105,7 +105,7 @@ - checksum - Is this file the one it claims to be? + archive-scan - Is this archive safe to unpack? @@ -120,8 +120,8 @@ - Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or - a colleague. + Reads a ZIP, TAR, TAR.GZ or gzip file and everything in it without unpacking anything, and says whether unpacking it + could write outside its folder, overwrite a file or fill the disk. @@ -130,10 +130,10 @@ - - - - + + + + @@ -156,14 +156,14 @@ - - + + - File - + Archive + @@ -205,11 +205,11 @@ - - + + - Algorithm - + Unpacked limit + @@ -223,7 +223,7 @@ - default: sha256 + not set @@ -234,37 +234,13 @@ - - - - - - - Expected - - - - + + + + - - - - - - - - not set - - - - - - - - - - + @@ -272,7 +248,7 @@ - + diff --git a/internal/guard/testdata/screens/tools.png b/internal/guard/testdata/screens/tools.png index 2c51fcfc..2b6bd46e 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 7e57fbc3..2687311e 100644 --- a/internal/guard/testdata/screens/tools.xml +++ b/internal/guard/testdata/screens/tools.xml @@ -105,7 +105,7 @@ - checksum - Is this file the one it claims to be? + archive-scan - Is this archive safe to unpack? @@ -120,8 +120,8 @@ - Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or - a colleague. + Reads a ZIP, TAR, TAR.GZ or gzip file and everything in it without unpacking anything, and says whether unpacking it + could write outside its folder, overwrite a file or fill the disk. @@ -130,10 +130,10 @@ - - - - + + + + @@ -156,14 +156,14 @@ - - + + - File - + Archive + @@ -196,11 +196,11 @@ - - + + - Algorithm - + Unpacked limit + @@ -214,7 +214,7 @@ - default: sha256 + not set @@ -225,37 +225,13 @@ - - - - - - - Expected - - - - + + + + - - - - - - - - not set - - - - - - - - - - + @@ -263,7 +239,7 @@ - + diff --git a/internal/guard/tools_test.go b/internal/guard/tools_test.go index 8fc02403..e9cd47d1 100644 --- a/internal/guard/tools_test.go +++ b/internal/guard/tools_test.go @@ -223,6 +223,7 @@ func TestTheWindowRunsAToolAndSaysWhatTheCommandLineSays(t *testing.T) { host := newFakeHost(t) window.Open(host) screen := selectTab(t, host.content, text.TabTools()) + chooseTool(t, screen, checksum.ID) fillField(t, screen, text.SettingLabel(checksum.InputFile), path) pressNamed(t, screen, text.ButtonRunTool()) host.waitForWork() @@ -321,6 +322,7 @@ func TestTheToolsScreenTakesAChosenFileAndCopiesTheChecksum(t *testing.T) { host.pickedFile = path window.Open(host) screen := selectTab(t, host.content, text.TabTools()) + chooseTool(t, screen, checksum.ID) pressNamed(t, screen, text.ButtonChoose()) if host.askedFile == 0 { diff --git a/internal/guard/tools_unix_test.go b/internal/guard/tools_unix_test.go index 0b10e1c3..f32fe65a 100644 --- a/internal/guard/tools_unix_test.go +++ b/internal/guard/tools_unix_test.go @@ -11,10 +11,9 @@ import ( "time" "github.com/donislawdev/TestingFilesGenerator/internal/tool" - "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" ) -// TestAToolNeverWaitsOnAPipe names a pipe as the file to work a checksum out of. +// TestAToolNeverWaitsOnAPipe names a pipe as the file every tool reads. // Opening one for reading waits until something writes to it, which may be // never - so the open must not wait, and what was opened has to be refused // before a byte of it is read. An open that waits hangs here until the @@ -22,29 +21,46 @@ import ( // of nothing instead of the refusal. // // Since 2026-09-30 this is asked of the open file rather than of the name -// (checksum.openRegular), which is what closed the moment between the two -// looks that a review of #157 found - so this one guard holds both halves. +// (tool.OpenRegular), which is what closed the moment between the two looks +// that a review of #157 found - so this one guard holds both halves. +// +// Every tool that reads a file, walked from the registry since 2026-10-19, +// when a second one arrived: a tool added tomorrow is held the day it is. func TestAToolNeverWaitsOnAPipe(t *testing.T) { pipe := filepath.Join(t.TempDir(), "pipe") if err := syscall.Mkfifo(pipe, 0o600); err != nil { t.Fatalf("making a pipe to name: %v", err) } - d, err := tool.Get(checksum.ID) - if err != nil { - t.Fatal(err) + asked := 0 + for _, d := range tool.All() { + if len(d.Inputs) == 0 || d.Inputs[0].Kind != tool.File { + continue + } + asked++ + refusesThePipe(t, d, pipe) + } + // checksum, checksum-check and archive-scan read a file. Fewer is a walk + // that stopped reaching the tools it is for. + if asked < 3 { + t.Fatalf("%d tool(s) reading a file were asked about a pipe, and at least three read one", asked) } +} + +// refusesThePipe holds one tool to turning a pipe away without waiting on it. +func refusesThePipe(t *testing.T, d tool.Descriptor, pipe string) { + t.Helper() answered := make(chan error, 1) go func() { - _, err := d.Start(context.Background(), tool.Request{Inputs: map[string]string{checksum.InputFile: pipe}}, nil) + _, err := d.Start(context.Background(), tool.Request{Inputs: map[string]string{d.Inputs[0].Name: pipe}}, nil) answered <- err }() select { case err := <-answered: - var notAFile *checksum.NotAFileError + var notAFile *tool.NotRegularError if !errors.As(err, ¬AFile) { - t.Errorf("a pipe was named and the answer was %v, not the refusal saying it is not a file", err) + t.Errorf("a pipe was named to %s and the answer was %v, not the refusal saying it is not a file", d.ID, err) } case <-time.After(10 * time.Second): - t.Fatal("a pipe was named and the tool is still waiting on it after ten seconds - it opened the pipe") + t.Fatalf("a pipe was named to %s and it is still waiting on it after ten seconds - it opened the pipe", d.ID) } } diff --git a/internal/guard/toolsscreen_test.go b/internal/guard/toolsscreen_test.go index a4048d0e..daed6b3d 100644 --- a/internal/guard/toolsscreen_test.go +++ b/internal/guard/toolsscreen_test.go @@ -19,6 +19,20 @@ import ( // The Tools tab as somebody uses it - the owner's list of 2026-10-05 after // looking at the window with the folder tools in it. +// chooseTool picks the tool with this ID in the menu at the head of the Tools +// tab. A test about one tool chooses it rather than counting on the tool the +// tab opens with: that is the first by name, and it changed the day a tool +// whose name sorts first arrived (archive-scan, 2026-10-19) - five tests about +// checksum found the box of another tool where theirs had been. +func chooseTool(t *testing.T, screen fyne.CanvasObject, id string) { + t.Helper() + d, err := tool.Get(id) + if err != nil { + t.Fatal(err) + } + chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolChoice(d.ID, d.Question)) +} + // chooseToolIn is a step of a scene: the tool with this ID chosen in the menu // at the head of the Tools tab, the way a person picks it. func chooseToolIn(id string) func(t *testing.T, s scene) { @@ -92,11 +106,7 @@ func TestTheMenuOfToolsNamesEachToolAsTheCommandLineDoes(t *testing.T) { // names, presses Run and waits for the answer. func runOnToolsTab(t *testing.T, host *fakeHost, screen fyne.CanvasObject, id string, boxes map[string]string) { t.Helper() - d, err := tool.Get(id) - if err != nil { - t.Fatal(err) - } - chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolChoice(d.ID, d.Question)) + chooseTool(t, screen, id) for name, value := range boxes { fillField(t, screen, text.SettingLabel(name), value) } diff --git a/internal/guard/writeescape_test.go b/internal/guard/writeescape_test.go index 887ec86c..018710a7 100644 --- a/internal/guard/writeescape_test.go +++ b/internal/guard/writeescape_test.go @@ -133,7 +133,7 @@ func TestEveryFileThisToolWritesIsCreatedThroughOneClaim(t *testing.T) { // readingFlags are the flags of an open that only reads - os.Open with // something os.Open cannot ask for. O_NONBLOCK is why this exists: the -// checksum tool opens without waiting on a pipe (checksum.openRegular), which +// tools open without waiting on a pipe (tool.OpenRegular), which // takes os.OpenFile, and it creates nothing and writes nothing. var readingFlags = map[string]bool{ "O_RDONLY": true, "O_NONBLOCK": true, "O_CLOEXEC": true, "O_NOFOLLOW": true, "O_NOCTTY": true, diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 0aab5144..a703cc98 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -59,14 +59,17 @@ type PropertyField struct { // An empty control sends nothing rather than sending emptiness. The registry // reads an empty value as "not stated", which is what leaving a field alone // means, so a format that works its own answer out still gets to. -func FromProperty(p format.Property) PropertyField { +func FromProperty(p format.Property) PropertyField { return fromProperty(p, false) } + +// fromProperty is FromProperty for a setting of a tool, or of anything else. +func fromProperty(p format.Property, ofATool bool) PropertyField { switch p.Kind { case format.PropertyChoice: - return choiceField(p) + return choiceField(p, ofATool) case format.PropertyBool: return boolField(p) default: - return textField(p) + return textField(p, ofATool) } } @@ -115,10 +118,10 @@ func FromProperty(p format.Property) PropertyField { // Boxes somebody types in are NOT this case and were left alone: a preset's // limit and spread are parameters, so an empty box there does reach the // manifest as defaulted. Measured in the same run. -func choiceField(p format.Property) PropertyField { +func choiceField(p format.Property, ofATool bool) PropertyField { sel := NewChooser(p.Choices, nil) sel.ShownAs = func(v string) string { return text.ChoiceName(p.Name, v) } - sel.PlaceHolder = leftAlone(p) + sel.PlaceHolder = leftAlone(p, ofATool) if p.Default != "" { sel.SetSelected(p.Default) } @@ -153,9 +156,9 @@ func boolField(p format.Property) PropertyField { // textField covers a number, a size and free text alike, because all three are // a box somebody types into and the difference between them is what the engine // accepts rather than what the box does. -func textField(p format.Property) PropertyField { +func textField(p format.Property, ofATool bool) PropertyField { entry := NewEntry() - entry.SetPlaceHolder(leftAlone(p)) + entry.SetPlaceHolder(leftAlone(p, ofATool)) return PropertyField{ Name: p.Name, Control: entry, @@ -173,14 +176,18 @@ func textField(p format.Property) PropertyField { // worked out there - an empty password is no password - and the same would // have been said of the author of a PDF. What leaving it empty gives is the // format's to say, in the sentence beside the box. -func leftAlone(p format.Property) string { +// +// Nor is anything worked out for a tool, which makes no file of any size: the +// limit of archive-scan read "worked out from the size" on the day it arrived +// (2026-10-19), and an empty limit is no limit. +func leftAlone(p format.Property, ofATool bool) string { switch { case p.Default != "" && len(p.Choices) > 0: // A value of a list is said the way the list says it. return text.PlaceholderLeftEmpty(text.ChoiceName(p.Name, p.Default)) case p.Default != "": return text.PlaceholderLeftEmpty(p.Default) - case p.Kind == format.PropertyText: + case p.Kind == format.PropertyText || ofATool: return text.PlaceholderNotSet() default: return text.PlaceholderWorkedOut() @@ -255,7 +262,7 @@ func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, if p.Group != "" && (i == 0 || declared[i-1].Group != p.Group) { objects = append(objects, Subheading(text.SettingGroup(owner, p.Group))) } - f := FromProperty(p) + f := fromProperty(p, text.IsTool(owner)) fields = append(fields, f) // A setting the format itself calls a size gets its count of bytes. // Read off the declaration rather than off the name, so a format that @@ -274,7 +281,7 @@ func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, // 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. field := into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p), - tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control)) + tips.Say(text.WrittenAs(owner, p.Name)), shapedFor(p, f.Control, text.IsTool(owner))) if longText(p) { field = Wide(field) } @@ -311,13 +318,19 @@ func longText(p format.Property) bool { // 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 { + return shapedFor(p, control, false) +} + +// shapedFor is ShapedFor for a setting of a tool, whose box shows another +// placeholder - see leftAlone. +func shapedFor(p format.Property, control fyne.CanvasObject, ofATool bool) fyne.CanvasObject { switch { case longText(p): return control case narrowOnAScreen(p): - return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control) + return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p, ofATool))), control) case p.Kind == format.PropertyText: - return Sized(fyne.Max(TextWidth, roomFor(leftAlone(p))), control) + return Sized(fyne.Max(TextWidth, roomFor(leftAlone(p, ofATool))), control) } return control } diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 7b804291..9547f5f0 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -1939,6 +1939,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/archive-scan.unpacked_limit": { + "description": "The sentence under the unpacked_limit setting of the archive-scan tool, after what it takes.", + "hash": "sha256-db62592b44cd", + "other": "The most the archive may unpack to, all its files together - for a server or a disk that takes no more. Left empty, only an archive built to explode is reported: 1 GB or more, and more than 100 times its own size." + }, "Detail.tool/checksum-write.algorithm": { "description": "The sentence under the algorithm setting of the checksum-write tool, after what it takes.", "hash": "sha256-bc3d55ed0c3c", @@ -2134,6 +2139,11 @@ "hash": "sha256-1652b4354ef8", "other": "Pictures in the document" }, + "Input.archive-scan.archive": { + "description": "The sentence under the archive box of the archive-scan tool.", + "hash": "sha256-4ad898a798a7", + "other": "The archive to look into: ZIP (DOCX, XLSX and JAR are ZIP inside), TAR, TAR.GZ or gzip. It is only read." + }, "Input.checksum-check.checksum_file": { "description": "The sentence under the checksum_file box of the checksum-check tool.", "hash": "sha256-90d7baf0d228", @@ -2219,6 +2229,11 @@ "hash": "sha256-cd0da1397b61", "other": "Antimeridian" }, + "Label.archive": { + "description": "The name beside the box of what the archive-scan tool works on.", + "hash": "sha256-66f4804ee23d", + "other": "Archive" + }, "Label.attachment_format": { "description": "The name beside the box of a setting a recipe writes as attachment_format.", "hash": "sha256-29f25b6c026f", @@ -2684,6 +2699,11 @@ "hash": "sha256-9d9b671e4837", "other": "Unlocated" }, + "Label.unpacked_limit": { + "description": "The name beside the box of a setting a recipe writes as unpacked_limit.", + "hash": "sha256-ea378e1bd7aa", + "other": "Unpacked limit" + }, "Label.vertices": { "description": "The name beside the box of a setting a recipe writes as vertices.", "hash": "sha256-f5af129ea336", @@ -2849,6 +2869,116 @@ "hash": "sha256-ba4463cda83e", "other": "Upload validation" }, + "Tool.archive-scan.Conclusion.safe": { + "description": "The answer a run of the archive-scan tool comes to, shown under the lines of its result.", + "hash": "sha256-e08326f1d03f", + "other": "Safe to unpack: none of the traps this tool looks for is in it." + }, + "Tool.archive-scan.Conclusion.unsafe": { + "description": "The answer a run of the archive-scan tool comes to, shown under the lines of its result.", + "hash": "sha256-39cb8195e90a", + "other": "Not safe to unpack: the lines above say why." + }, + "Tool.archive-scan.Detail": { + "description": "One sentence under the question of the archive-scan tool, saying what it does.", + "hash": "sha256-526e67a2b432", + "other": "Reads a ZIP, TAR, TAR.GZ or gzip file and everything in it without unpacking anything, and says whether unpacking it could write outside its folder, overwrite a file or fill the disk." + }, + "Tool.archive-scan.Note.bomb": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-ad7c0631bd1c", + "other": "Unpacks to far more than its own size, the way an archive built to explode does:" + }, + "Tool.archive-scan.Note.collides": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-d5cea6f318c4", + "other": "Entries that land on the same file as another on Windows or macOS:" + }, + "Tool.archive-scan.Note.damaged": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-6faaa2b49d18", + "other": "Damaged, so programs that unpack it may disagree on what it holds:" + }, + "Tool.archive-scan.Note.entries": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-89a7fe8936b2", + "other": "Entries:" + }, + "Tool.archive-scan.Note.kind": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-bf476a924b4e", + "other": "Archive:" + }, + "Tool.archive-scan.Note.leaves_folder": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-5d69160ea44c", + "other": "Entries that would be written outside the folder:" + }, + "Tool.archive-scan.Note.links": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-ec9bee24c930", + "other": "Links, which a later entry can be written through:" + }, + "Tool.archive-scan.Note.longer_than_said": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-f68f6d40c1ff", + "other": "Entries holding more than the archive says they do:" + }, + "Tool.archive-scan.Note.nested": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-d256c3caa62f", + "other": "Archives inside it, which were not opened:" + }, + "Tool.archive-scan.Note.over_limit": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-6179368adab1", + "other": "Unpacks to more than the limit given:" + }, + "Tool.archive-scan.Note.overlapping": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-cd5cedc1af84", + "other": "Entries that share their bytes with another, which only an archive built to explode does:" + }, + "Tool.archive-scan.Note.size": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-c987f668d963", + "other": "Size:" + }, + "Tool.archive-scan.Note.special": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-077308fc7873", + "other": "Entries that are not plain files or folders:" + }, + "Tool.archive-scan.Note.stopped": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-90de24758d21", + "other": "Reading stopped before the end:" + }, + "Tool.archive-scan.Note.two_names": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-672e79c37984", + "other": "Entries named one way in the list of contents and another at the entry itself:" + }, + "Tool.archive-scan.Note.unchecked": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-94238136c3ba", + "other": "Entries whose contents could not be checked:" + }, + "Tool.archive-scan.Note.unpacked": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-75d57d9929a3", + "other": "Unpacked:" + }, + "Tool.archive-scan.Note.unwritable": { + "description": "A line of the result of the archive-scan tool, ending in a colon - what follows it is a list of names or one number.", + "hash": "sha256-40ba7750552a", + "other": "Names that Windows, macOS or Linux cannot write as they are:" + }, + "Tool.archive-scan.Question": { + "description": "The question the archive-scan tool answers - its name in the list of tools and the title over it.", + "hash": "sha256-d91795de08fb", + "other": "Is this archive safe to unpack?" + }, "Tool.checksum-check.Detail": { "description": "One sentence under the question of the checksum-check tool, saying what it does.", "hash": "sha256-3480ee8eb9ae", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index a297d603..eb8f02c7 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -387,6 +387,7 @@ "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/archive-scan.unpacked_limit": { "hash": "sha256-db62592b44cd", "other": "Najwięcej, ile archiwum może zająć po rozpakowaniu, wszystkie pliki razem - dla serwera albo dysku, który więcej nie przyjmie. Puste zgłasza tylko archiwum zbudowane, żeby eksplodować: 1 GB albo więcej i ponad 100 razy więcej niż ono samo." }, "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." }, @@ -426,6 +427,7 @@ "Group.format/html.Pictures in the page": { "hash": "sha256-af608b8ed33a", "other": "Obrazki w stronie" }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, "Group.format/rtf.Pictures in the document": { "hash": "sha256-1652b4354ef8", "other": "Obrazki w dokumencie" }, + "Input.archive-scan.archive": { "hash": "sha256-4ad898a798a7", "other": "Archiwum do sprawdzenia: ZIP (DOCX, XLSX i JAR to w środku ZIP), TAR, TAR.GZ albo gzip. Jest tylko czytane." }, "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." }, @@ -443,6 +445,7 @@ "Label.allow": { "hash": "sha256-e213c161d5ce", "other": "Dozwolone" }, "Label.altitude": { "hash": "sha256-cbc2e75740ca", "other": "Wysokość" }, "Label.antimeridian": { "hash": "sha256-cd0da1397b61", "other": "Antypołudnik" }, + "Label.archive": { "hash": "sha256-66f4804ee23d", "other": "Archiwum" }, "Label.attachment_format": { "hash": "sha256-29f25b6c026f", "other": "Format załączników" }, "Label.attachment_size": { "hash": "sha256-151d41e8a53c", "other": "Rozmiar załączników" }, "Label.attachments": { "hash": "sha256-634de1147750", "other": "Liczba załączników" }, @@ -536,6 +539,7 @@ "Label.timestamps": { "hash": "sha256-755f099dbf49", "other": "Znaczniki czasu" }, "Label.title": { "hash": "sha256-7e8cd2056da7", "other": "Tytuł" }, "Label.unlocated": { "hash": "sha256-9d9b671e4837", "other": "Bez położenia" }, + "Label.unpacked_limit": { "hash": "sha256-ea378e1bd7aa", "other": "Limit po rozpakowaniu" }, "Label.vertices": { "hash": "sha256-f5af129ea336", "other": "Punkty" }, "Label.width": { "hash": "sha256-3019947b6daf", "other": "Szerokość" }, "Label.winding": { "hash": "sha256-a886eddec337", "other": "Kierunek obiegu" }, @@ -569,6 +573,28 @@ "Title.tabular-import": { "hash": "sha256-a08572cdb93c", "other": "Import tabel" }, "Title.text-encoding": { "hash": "sha256-f6726ca0b9fe", "other": "Kodowanie tekstu" }, "Title.upload-validation": { "hash": "sha256-ba4463cda83e", "other": "Walidacja uploadu" }, + "Tool.archive-scan.Conclusion.safe": { "hash": "sha256-e08326f1d03f", "other": "Można bezpiecznie rozpakować: nie ma w nim żadnej z pułapek, których szuka to narzędzie." }, + "Tool.archive-scan.Conclusion.unsafe": { "hash": "sha256-39cb8195e90a", "other": "Nie można bezpiecznie rozpakować: powody są w liniach powyżej." }, + "Tool.archive-scan.Detail": { "hash": "sha256-526e67a2b432", "other": "Czyta plik ZIP, TAR, TAR.GZ albo gzip i wszystko, co w nim jest, niczego nie rozpakowując, i mówi, czy rozpakowanie może zapisać coś poza jego folderem, nadpisać plik albo zapełnić dysk." }, + "Tool.archive-scan.Note.bomb": { "hash": "sha256-ad7c0631bd1c", "other": "Rozpakowuje się do wielokrotności własnego rozmiaru, jak archiwum zbudowane, żeby eksplodować:" }, + "Tool.archive-scan.Note.collides": { "hash": "sha256-d5cea6f318c4", "other": "Wpisy, które na Windows albo macOS trafiają do tego samego pliku co inny:" }, + "Tool.archive-scan.Note.damaged": { "hash": "sha256-6faaa2b49d18", "other": "Uszkodzone, więc programy rozpakowujące mogą różnie odczytać, co zawiera:" }, + "Tool.archive-scan.Note.entries": { "hash": "sha256-89a7fe8936b2", "other": "Wpisy:" }, + "Tool.archive-scan.Note.kind": { "hash": "sha256-bf476a924b4e", "other": "Archiwum:" }, + "Tool.archive-scan.Note.leaves_folder": { "hash": "sha256-5d69160ea44c", "other": "Wpisy, które zostałyby zapisane poza folderem:" }, + "Tool.archive-scan.Note.links": { "hash": "sha256-ec9bee24c930", "other": "Dowiązania, przez które może zostać zapisany późniejszy wpis:" }, + "Tool.archive-scan.Note.longer_than_said": { "hash": "sha256-f68f6d40c1ff", "other": "Wpisy zawierające więcej, niż podaje archiwum:" }, + "Tool.archive-scan.Note.nested": { "hash": "sha256-d256c3caa62f", "other": "Archiwa w środku, których nie otwierano:" }, + "Tool.archive-scan.Note.over_limit": { "hash": "sha256-6179368adab1", "other": "Po rozpakowaniu więcej niż podany limit:" }, + "Tool.archive-scan.Note.overlapping": { "hash": "sha256-cd5cedc1af84", "other": "Wpisy dzielące bajty z innym, co robi tylko archiwum zbudowane, żeby eksplodować:" }, + "Tool.archive-scan.Note.size": { "hash": "sha256-c987f668d963", "other": "Rozmiar:" }, + "Tool.archive-scan.Note.special": { "hash": "sha256-077308fc7873", "other": "Wpisy, które nie są zwykłymi plikami ani folderami:" }, + "Tool.archive-scan.Note.stopped": { "hash": "sha256-90de24758d21", "other": "Czytanie zatrzymane przed końcem:" }, + "Tool.archive-scan.Note.two_names": { "hash": "sha256-672e79c37984", "other": "Wpisy nazwane inaczej w spisie zawartości, a inaczej przy samym wpisie:" }, + "Tool.archive-scan.Note.unchecked": { "hash": "sha256-94238136c3ba", "other": "Wpisy, których zawartości nie dało się sprawdzić:" }, + "Tool.archive-scan.Note.unpacked": { "hash": "sha256-75d57d9929a3", "other": "Po rozpakowaniu:" }, + "Tool.archive-scan.Note.unwritable": { "hash": "sha256-40ba7750552a", "other": "Nazwy, których Windows, macOS albo Linux nie zapisze w tej postaci:" }, + "Tool.archive-scan.Question": { "hash": "sha256-d91795de08fb", "other": "Czy to archiwum można bezpiecznie rozpakować?" }, "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:" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index 4a06ce18..8721d2e4 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -49,6 +49,241 @@ "hash": "sha256-213afa915dd9", "other": "Write {{.Key}}: true or {{.Again}}: false." }, + "archivescan.ADirectoryIsUnpacked": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-625960667a52", + "other": "a directory is what an archive unpacks into, not an archive" + }, + "archivescan.AnswerAboutNoVersion": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-a9d2669e6062", + "other": "an answer about a file being written is an answer about no version of it" + }, + "archivescan.Bomb": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Unpacked}}, {{.Size}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-1b4a0ab80849", + "other": "{{.Unpacked}} from {{.Size}}" + }, + "archivescan.Bytes": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Size}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-a7df568dcb25", + "other": "{{.Size}}" + }, + "archivescan.Changed": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Path}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-7b323b86f3c7", + "other": "{{.Path}} changed while it was being read" + }, + "archivescan.Checksum": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-046f31c01563", + "other": "its contents do not match their checksum" + }, + "archivescan.Counted": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Counted}}, {{.Listed}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-7ec127709e04", + "other": "the record at its end gives the number of entries as {{.Counted}} and the list holds {{.Listed}}" + }, + "archivescan.Device": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-679cb400e806", + "other": "it is a device" + }, + "archivescan.Encrypted": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-f93c3b3dbeb0", + "other": "it is encrypted" + }, + "archivescan.EndsEarly": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-d7126b8d60eb", + "other": "its contents end before the size the list gives them" + }, + "archivescan.EntryWhy": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Name}}, {{.Why}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-13b3c18bff10", + "other": "{{.Name}}: {{.Why}}" + }, + "archivescan.Global": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Key}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-2464a8a64571", + "other": "a header sets {{.Key}} for every entry after it, which this tool does not follow" + }, + "archivescan.GzipHeader": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-7a245481ab58", + "other": "its gzip header is broken" + }, + "archivescan.IsADirectory": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Path}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-99f387f3e133", + "other": "{{.Path}} is a directory and this looks into one archive" + }, + "archivescan.IsNotAFile": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Path}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-28957d41213f", + "other": "{{.Path}} is not a file but a device, a pipe or a socket" + }, + "archivescan.IsNotAnArchive": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Path}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-4ab90d477d74", + "other": "{{.Path}} is not a ZIP, TAR, TAR.GZ or gzip file" + }, + "archivescan.ItDoesNotBegin": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-fc6fa1f8683f", + "other": "it does not begin the way any of them does, and the name of a file does not decide what it is" + }, + "archivescan.ItsOwnFormat": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-13b59184ca3a", + "other": "its list of contents is written in a format of its own" + }, + "archivescan.Link": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Name}}, {{.Target}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-f259a6b86f03", + "other": "{{.Name}} -> {{.Target}}" + }, + "archivescan.ListElsewhere": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-f2723b1851e0", + "other": "its list of contents is not where the record at its end says" + }, + "archivescan.ListEndsEarly": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-13e077022aaf", + "other": "its list of contents ends in the middle of an entry" + }, + "archivescan.ListHoldsOther": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-294d9d38d2bf", + "other": "its list of contents holds something that is not an entry" + }, + "archivescan.Method": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Method}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-909cb4c9c048", + "other": "it is packed with method {{.Method}}, which this tool does not read" + }, + "archivescan.Name": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Name}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-1a0545a47535", + "other": "{{.Name}}" + }, + "archivescan.NameAZipOrTar": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-17713db18b11", + "other": "name a ZIP, TAR, TAR.GZ or gzip file" + }, + "archivescan.NameOneArchive": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-b3119045be6e", + "other": "name one archive file" + }, + "archivescan.NoEnd": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-638b1e0460e7", + "other": "the record at its end, which says where its list of contents is, is missing" + }, + "archivescan.NotReadYet": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Path}}, {{.Kind}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-bfe67470dde3", + "other": "{{.Path}} is a {{.Kind}} archive, which this tool does not read yet" + }, + "archivescan.NotWhereListed": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-8e9d4c7346e8", + "other": "it does not start where the list of contents says" + }, + "archivescan.OverLimit": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Unpacked}}, {{.Limit}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-31b786bac0bd", + "other": "{{.Unpacked}} against a limit of {{.Limit}}" + }, + "archivescan.PackAsZip": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-97b44719e0c2", + "other": "pack the same files as ZIP or TAR, and look into that" + }, + "archivescan.Pipe": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-357c8d55b7cd", + "other": "it is a pipe" + }, + "archivescan.ReadingMayNeverEnd": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-9055c322986c", + "other": "reading one may never end, so it is not started" + }, + "archivescan.Rights": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-0f9eff296b16", + "other": "it runs with the rights of its owner" + }, + "archivescan.RunsIntoList": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-a7a637c4c573", + "other": "its contents run into the list of contents" + }, + "archivescan.Same": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Name}}, {{.First}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-8c7055ead538", + "other": "{{.Name}} = {{.First}}" + }, + "archivescan.Size": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Size}}, {{.Exactly}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-8deb8dd02c4a", + "other": "{{.Size}} ({{.Exactly}})" + }, + "archivescan.Socket": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-a0d8c46d99ca", + "other": "it is a socket" + }, + "archivescan.Split": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-66a4b1365362", + "other": "it says it is one part of an archive split across several files" + }, + "archivescan.StoppedBomb": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Ratio}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-a9e09bc34953", + "other": "it unpacks to more than {{.Ratio}} times what was read of it, and no more is read" + }, + "archivescan.StoppedEntries": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-f587645b5f7b", + "other": "it lists more than {{.Most}} entries, and no more are read" + }, + "archivescan.StoppedNames": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-21ea12750101", + "other": "its names take more room than this tool sets aside to compare them, and no more are read" + }, + "archivescan.StreamBroken": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-afe268a16a8f", + "other": "it ends early, or its contents do not unpack" + }, + "archivescan.TarHeader": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-0350e128590c", + "other": "a header is broken, or it ends in the middle of one" + }, + "archivescan.TwoNames": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Listed}}, {{.Local}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-8816d7bc260e", + "other": "{{.Listed}} in the list, {{.Local}} at the entry" + }, + "archivescan.Type": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Type}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-ea85c62d6ca3", + "other": "it is an entry of type {{.Type}}, which most programs do not unpack" + }, + "archivescan.WaitAndRunAgain": { + "description": "Said by internal/tool/archivescan - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-161d36039e9c", + "other": "wait until whatever is writing it has finished, and run this again" + }, "avif.MinimumReason": { "description": "Said by internal/format/avif - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Coded}}, {{.Box}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-97b2a4a72e3a", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index 2338a61b..01fc9836 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -9,6 +9,53 @@ "archive.LockedWithoutPassword": { "hash": "sha256-b5d56de9a9b9", "other": "archiwum ma być zamknięte metodą {{.Method}}, a nie ma hasła, którym można by je zamknąć" }, "archive.SetEncryption": { "hash": "sha256-0b2a90b45006", "other": "Ustaw szyfrowanie na {{.Method}}, żeby zamknąć archiwum, albo usuń hasło, żeby zostało otwarte." }, "archive.WriteTrueOrFalse": { "hash": "sha256-213afa915dd9", "other": "Wpisz {{.Key}}: true albo {{.Again}}: false." }, + "archivescan.ADirectoryIsUnpacked": { "hash": "sha256-625960667a52", "other": "katalog to miejsce, do którego rozpakowuje się archiwum, a nie archiwum" }, + "archivescan.AnswerAboutNoVersion": { "hash": "sha256-a9d2669e6062", "other": "odpowiedź o pliku, który właśnie jest zapisywany, nie dotyczy żadnej jego wersji" }, + "archivescan.Bomb": { "hash": "sha256-1b4a0ab80849", "other": "{{.Unpacked}} z {{.Size}}" }, + "archivescan.Bytes": { "hash": "sha256-a7df568dcb25", "other": "{{.Size}}" }, + "archivescan.Changed": { "hash": "sha256-7b323b86f3c7", "other": "{{.Path}} zmienił się w trakcie czytania" }, + "archivescan.Checksum": { "hash": "sha256-046f31c01563", "other": "jego zawartość nie zgadza się ze swoją sumą kontrolną" }, + "archivescan.Counted": { "hash": "sha256-7ec127709e04", "other": "rekord na jego końcu podaje liczbę wpisów {{.Counted}}, a spis zawiera ich {{.Listed}}" }, + "archivescan.Device": { "hash": "sha256-679cb400e806", "other": "to urządzenie" }, + "archivescan.Encrypted": { "hash": "sha256-f93c3b3dbeb0", "other": "jest zaszyfrowany" }, + "archivescan.EndsEarly": { "hash": "sha256-d7126b8d60eb", "other": "jego zawartość kończy się przed rozmiarem podanym w spisie" }, + "archivescan.EntryWhy": { "hash": "sha256-13b3c18bff10", "other": "{{.Name}}: {{.Why}}" }, + "archivescan.Global": { "hash": "sha256-2464a8a64571", "other": "nagłówek ustawia {{.Key}} każdemu następnemu wpisowi, czego to narzędzie nie uwzględnia" }, + "archivescan.GzipHeader": { "hash": "sha256-7a245481ab58", "other": "jego nagłówek gzip jest uszkodzony" }, + "archivescan.IsADirectory": { "hash": "sha256-99f387f3e133", "other": "{{.Path}} to katalog, a to narzędzie sprawdza jedno archiwum" }, + "archivescan.IsNotAFile": { "hash": "sha256-28957d41213f", "other": "{{.Path}} nie jest plikiem, tylko urządzeniem, potokiem albo gniazdem" }, + "archivescan.IsNotAnArchive": { "hash": "sha256-4ab90d477d74", "other": "{{.Path}} nie jest plikiem ZIP, TAR, TAR.GZ ani gzip" }, + "archivescan.ItDoesNotBegin": { "hash": "sha256-fc6fa1f8683f", "other": "nie zaczyna się tak jak żadne z nich, a o tym, czym jest plik, nie decyduje jego nazwa" }, + "archivescan.ItsOwnFormat": { "hash": "sha256-13b59184ca3a", "other": "jego spis zawartości jest zapisany we własnym formacie" }, + "archivescan.Link": { "hash": "sha256-f259a6b86f03", "other": "{{.Name}} -> {{.Target}}" }, + "archivescan.ListElsewhere": { "hash": "sha256-f2723b1851e0", "other": "jego spisu zawartości nie ma tam, gdzie wskazuje rekord na jego końcu" }, + "archivescan.ListEndsEarly": { "hash": "sha256-13e077022aaf", "other": "jego spis zawartości urywa się w środku wpisu" }, + "archivescan.ListHoldsOther": { "hash": "sha256-294d9d38d2bf", "other": "jego spis zawartości zawiera coś, co nie jest wpisem" }, + "archivescan.Method": { "hash": "sha256-909cb4c9c048", "other": "jest spakowany metodą {{.Method}}, której to narzędzie nie czyta" }, + "archivescan.Name": { "hash": "sha256-1a0545a47535", "other": "{{.Name}}" }, + "archivescan.NameAZipOrTar": { "hash": "sha256-17713db18b11", "other": "wskaż plik ZIP, TAR, TAR.GZ albo gzip" }, + "archivescan.NameOneArchive": { "hash": "sha256-b3119045be6e", "other": "wskaż jeden plik archiwum" }, + "archivescan.NoEnd": { "hash": "sha256-638b1e0460e7", "other": "brakuje rekordu na jego końcu, który mówi, gdzie jest spis zawartości" }, + "archivescan.NotReadYet": { "hash": "sha256-bfe67470dde3", "other": "{{.Path}} to archiwum {{.Kind}}, którego to narzędzie jeszcze nie czyta" }, + "archivescan.NotWhereListed": { "hash": "sha256-8e9d4c7346e8", "other": "nie zaczyna się tam, gdzie podaje spis zawartości" }, + "archivescan.OverLimit": { "hash": "sha256-31b786bac0bd", "other": "{{.Unpacked}} przy limicie {{.Limit}}" }, + "archivescan.PackAsZip": { "hash": "sha256-97b44719e0c2", "other": "spakuj te same pliki jako ZIP albo TAR i sprawdź to archiwum" }, + "archivescan.Pipe": { "hash": "sha256-357c8d55b7cd", "other": "to potok" }, + "archivescan.ReadingMayNeverEnd": { "hash": "sha256-9055c322986c", "other": "jego czytanie może się nigdy nie skończyć, więc nie jest zaczynane" }, + "archivescan.Rights": { "hash": "sha256-0f9eff296b16", "other": "uruchamia się z uprawnieniami swojego właściciela" }, + "archivescan.RunsIntoList": { "hash": "sha256-a7a637c4c573", "other": "jego zawartość zachodzi na spis zawartości" }, + "archivescan.Same": { "hash": "sha256-8c7055ead538", "other": "{{.Name}} = {{.First}}" }, + "archivescan.Size": { "hash": "sha256-8deb8dd02c4a", "other": "{{.Size}} ({{.Exactly}})" }, + "archivescan.Socket": { "hash": "sha256-a0d8c46d99ca", "other": "to gniazdo" }, + "archivescan.Split": { "hash": "sha256-66a4b1365362", "other": "podaje, że jest jedną częścią archiwum podzielonego na kilka plików" }, + "archivescan.StoppedBomb": { "hash": "sha256-a9e09bc34953", "other": "rozpakowuje się do ponad {{.Ratio}} razy więcej, niż z niego przeczytano, więc dalej nie jest czytane" }, + "archivescan.StoppedEntries": { "hash": "sha256-f587645b5f7b", "other": "wymienia ponad {{.Most}} wpisów, więc dalsze nie są czytane" }, + "archivescan.StoppedNames": { "hash": "sha256-21ea12750101", "other": "jego nazwy zajmują więcej miejsca, niż to narzędzie przeznacza na ich porównanie, więc dalsze nie są czytane" }, + "archivescan.StreamBroken": { "hash": "sha256-afe268a16a8f", "other": "kończy się przedwcześnie albo jego zawartość się nie rozpakowuje" }, + "archivescan.TarHeader": { "hash": "sha256-0350e128590c", "other": "nagłówek jest uszkodzony albo archiwum urywa się w jego środku" }, + "archivescan.TwoNames": { "hash": "sha256-8816d7bc260e", "other": "{{.Listed}} w spisie, {{.Local}} przy wpisie" }, + "archivescan.Type": { "hash": "sha256-ea85c62d6ca3", "other": "to wpis typu {{.Type}}, którego większość programów nie rozpakowuje" }, + "archivescan.WaitAndRunAgain": { "hash": "sha256-161d36039e9c", "other": "poczekaj, aż to, co go zapisuje, skończy, i uruchom to jeszcze raz" }, "avif.MinimumReason": { "hash": "sha256-97b2a4a72e3a", "other": "najmniejszy obrazek, jaki rysuje ten format, koduje się w najgorszym razie do {{.Coded}} B, a plik zawsze niesie wolny blok, który kosztuje {{.Box}} B nawet pusty" }, "avif.MinimumReasonOwn": { "hash": "sha256-ff69f6fa9b64", "other": "obrazek {{.Width}}x{{.Height}} w jakości {{.Quality}} koduje się do {{.Coded}} B, a plik zawsze niesie wolny blok, który kosztuje {{.Box}} B nawet pusty" }, "checksum.AChecksumDigitsLongIsNot": { "hash": "sha256-0dcdc03bd2f7", "other": "suma kontrolna o długości {{.Digits}} cyfr nie jest taką, jaką to narzędzie wylicza" }, diff --git a/internal/gui/text/registry.go b/internal/gui/text/registry.go index 02825fe6..357fbd65 100644 --- a/internal/gui/text/registry.go +++ b/internal/gui/text/registry.go @@ -55,13 +55,16 @@ func ToolOwner(id string) Owner { return Owner(toolPrefix + id) } const toolPrefix = "tool/" +// IsTool says whether a declaration is a tool's. +func IsTool(o Owner) bool { return strings.HasPrefix(string(o), toolPrefix) } + // WrittenAs is what the button beside a declared setting says about writing // it down: the key a recipe writes it under, or for a tool - which no recipe // runs - the flag of tfg tool. A sentence about a recipe beside a setting // nobody can put in one would send somebody looking for a file that cannot // hold it. func WrittenAs(o Owner, key string) string { - if strings.HasPrefix(string(o), toolPrefix) { + if IsTool(o) { return ToolFlag(key) } return SettingKey(key) @@ -92,6 +95,9 @@ 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 } +func ToolConclusionKey(id, conclusion string) string { + return "Tool." + id + ".Conclusion." + conclusion +} // 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 @@ -201,6 +207,11 @@ func ToolInput(id, name, english string) string { return lookup(InputKey(id, nam // 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) } +// ToolConclusion is the answer a run of a tool came to. +func ToolConclusion(id, conclusion, english string) string { + return lookup(ToolConclusionKey(id, conclusion), 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 c3504630..848cbad6 100644 --- a/internal/gui/text/registrywords.go +++ b/internal/gui/text/registrywords.go @@ -110,6 +110,9 @@ func (w registryWords) tool(d tool.Descriptor) { 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.") } + for _, c := range d.Conclusions { + w.add(ToolConclusionKey(d.ID, c.ID), c.Says, "The answer a run of "+whose+" comes to, shown under the lines of its result.") + } w.settings(ToolOwner(d.ID), whose, d.Settings) } diff --git a/internal/gui/window/toolsresult.go b/internal/gui/window/toolsresult.go index 993cf49a..d0f875fa 100644 --- a/internal/gui/window/toolsresult.go +++ b/internal/gui/window/toolsresult.go @@ -74,6 +74,13 @@ func (t *Tools) resultObjects(d tool.Descriptor, r *tool.Result) []fyne.CanvasOb out = append(out, parts.Verdict(parts.Disagrees, verdictSaid(r.Verdict))) case tool.Unasked: } + if said, ok := conclusionSaid(d, *r); ok { + tone := parts.Agrees + if d.Failed(*r) { + tone = parts.Disagrees + } + out = append(out, parts.Verdict(tone, said)) + } return append(out, t.copyAll(d, *r)) } diff --git a/internal/gui/window/toolstext.go b/internal/gui/window/toolstext.go index 85778d7f..c5d603b3 100644 --- a/internal/gui/window/toolstext.go +++ b/internal/gui/window/toolstext.go @@ -86,9 +86,22 @@ func resultText(d tool.Descriptor, r tool.Result) string { if r.Verdict.Outcome != tool.Unasked { lines = append(lines, verdictSaid(r.Verdict)) } + if said, ok := conclusionSaid(d, r); ok { + lines = append(lines, said) + } return strings.Join(lines, "\n") } +// conclusionSaid is the conclusion a run came to, in the window's language, +// or nothing when it came to none. +func conclusionSaid(d tool.Descriptor, r tool.Result) (string, bool) { + c, ok := d.Conclusion(r.Concluded) + if !ok { + return "", false + } + return text.ToolConclusion(d.ID, c.ID, c.Says), true +} + // ranIn is the folder a run of a tool worked in, for Open folder: the folder // it was given, or the folder of the file it was given - the first thing the // tool works on, which is the one its question is about. Empty when it was diff --git a/internal/tool/all/all.go b/internal/tool/all/all.go index 0b479c61..19946848 100644 --- a/internal/tool/all/all.go +++ b/internal/tool/all/all.go @@ -7,5 +7,6 @@ package all import ( // Each tool registers itself when its package is loaded. + _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/archivescan" _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" ) diff --git a/internal/tool/archivescan/archivescan.go b/internal/tool/archivescan/archivescan.go new file mode 100644 index 00000000..78b1c3a4 --- /dev/null +++ b/internal/tool/archivescan/archivescan.go @@ -0,0 +1,141 @@ +// Package archivescan is the tool that looks into an archive and says whether +// unpacking it is safe - without unpacking it. +// +// What it looks for are the ways an archive hurts whoever unpacks it: an entry +// written outside the folder, a link written through, two entries landing on +// one file, a name a system cannot hold, an archive built to explode, contents +// that are not what the list of contents says. Everything is read, the list +// and the contents, and nothing is written - the contents go through a +// checksum and are thrown away. +// +// The analysis, the measurements behind the limits and what was left out: +// docs/SKANER-ARCHIWUM-2026-10-19.md. +package archivescan + +import ( + "context" + "errors" + "io" + "os" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// ID is the name of the tool, after "tfg tool". +const ID = "archive-scan" + +// The names of what the tool declares, used by the request and by both +// surfaces. +const ( + InputArchive = "archive" + SettingLimit = "unpacked_limit" +) + +// The limits, fixed. The one a person sets is SettingLimit. +const ( + // MostEntries is where reading stops. A million names held to find two + // that land on one file is a few hundred megabytes, and an archive with + // more is a trap of its own for a file system. + MostEntries = 1_000_000 + // MostPlaces and MostNameBytes bound what is held to compare names: the + // files and folders the entries unpack to, and the bytes of their names. + // An entry with a name of 64 KiB, a million times, would otherwise be + // 64 GB held in memory to answer a question about a file. + MostPlaces = 2 * MostEntries + MostNameBytes = 256 << 20 + // BombBytes and BombRatio are an archive built to explode: at least this + // much when unpacked, and more than this many times its own size. Both, + // because either alone is innocent - a file of zeros packs a thousand to + // one, and a big archive unpacks big. Measured on 906 real archives + // (docs/SKANER-ARCHIWUM-2026-10-19.md §2.2): 99 in 100 unpack to 7 times + // their size or less, the largest to 73. + BombBytes = 1 << 30 + BombRatio = 100 +) + +// The keys of the conclusions. +const ( + concludedSafe = "safe" + concludedUnsafe = "unsafe" +) + +func init() { + tool.Register(tool.Descriptor{ + ID: ID, + Question: "Is this archive safe to unpack?", + Detail: "Reads a ZIP, TAR, TAR.GZ or gzip file and everything in it without unpacking anything, " + + "and says whether unpacking it could write outside its folder, overwrite a file or fill the disk.", + Inputs: []tool.Input{{ + Name: InputArchive, Kind: tool.File, + Detail: "The archive to look into: ZIP (DOCX, XLSX and JAR are ZIP inside), TAR, TAR.GZ or gzip. It is only read.", + }}, + Settings: []format.Property{{ + Name: SettingLimit, Kind: format.PropertySize, + Detail: "The most the archive may unpack to, all its files together - for a server or a disk that takes no more. " + + "Left empty, only an archive built to explode is reported: 1 GB or more, and more than 100 times its own size.", + }}, + Notes: declaredNotes(), + Conclusions: []tool.Conclusion{ + {ID: concludedSafe, Says: "Safe to unpack: none of the traps this tool looks for is in it."}, + {ID: concludedUnsafe, Says: "Not safe to unpack: the lines above say why.", Fails: true}, + }, + Run: run, + }) +} + +// run is the tool, on a request tool.Start has checked and defaulted. +func run(ctx context.Context, in tool.Request, progress tool.Progress) (tool.Result, error) { + limit := int64(-1) + if raw := in.Values[SettingLimit]; raw != "" { + // Checked by the registry already, which is why the error is not. + limit, _ = core.ParseSize(raw) + } + path := in.Inputs[InputArchive] + f, before, err := tool.OpenRegular(path) + var not *tool.NotRegularError + if errors.As(err, ¬) { + return tool.Result{}, &NotAFileError{Path: path, Directory: not.Directory} + } + if err != nil { + return tool.Result{}, err + } + defer func() { _ = f.Close() }() + + s := newScan(ctx, path, before.Size(), limit, progress) + if err := s.look(f); err != nil { + return tool.Result{}, err + } + after, err := f.Stat() + if err != nil { + return tool.Result{}, err + } + if after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return tool.Result{}, &ChangedError{Path: path} + } + return s.result(), nil +} + +// look tells what the file is and reads it as that. +func (s *scan) look(f *os.File) error { + head := make([]byte, headLength) + n, err := f.ReadAt(head, 0) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + head = head[:n] + switch k, other := sniff(head); { + case k == kindZip: + s.kind = kindZip + return s.zip(f) + case k == kindTar: + s.kind = kindTar + return s.tar(io.NewSectionReader(f, 0, s.size)) + case k == kindGzip: + return s.gzip(io.NewSectionReader(f, 0, s.size)) + case other != "": + return &NotReadYetError{Path: s.path, Kind: other} + } + return &NotAnArchiveError{Path: s.path} +} diff --git a/internal/tool/archivescan/findings.go b/internal/tool/archivescan/findings.go new file mode 100644 index 00000000..1a42d59b --- /dev/null +++ b/internal/tool/archivescan/findings.go @@ -0,0 +1,285 @@ +package archivescan + +import ( + "context" + "math" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// What a scan found, kept as notes from the first entry to the result. + +// The keys of the notes. Also the keys of what --json reports as found, so +// each is a public name (untouchable rule 10). +const ( + noteKind = "kind" + noteEntries = "entries" + notePacked = "size" + noteUnpacked = "unpacked" + + noteLeaves = "leaves_folder" + noteLinks = "links" + noteSpecial = "special" + noteCollides = "collides" + noteUnwritable = "unwritable" + noteTwoNames = "two_names" + noteOverlapping = "overlapping" + noteBomb = "bomb" + noteLonger = "longer_than_said" + noteOverLimit = "over_limit" + noteDamaged = "damaged" + noteUnchecked = "unchecked" + noteStopped = "stopped" + + noteNested = "nested" +) + +// declared is every note, in the order a result says them: what the archive +// is, then everything that makes it unsafe, then what is worth knowing and is +// harmless on its own. +var declared = []struct { + tool.Note + // harmless is a note that does not make an archive unsafe. + harmless bool +}{ + {tool.Note{ID: noteKind, Says: "Archive:"}, true}, + {tool.Note{ID: noteEntries, Says: "Entries:"}, true}, + {tool.Note{ID: notePacked, Says: "Size:"}, true}, + {tool.Note{ID: noteUnpacked, Says: "Unpacked:"}, true}, + {tool.Note{ID: noteLeaves, Says: "Entries that would be written outside the folder:"}, false}, + {tool.Note{ID: noteLinks, Says: "Links, which a later entry can be written through:"}, false}, + {tool.Note{ID: noteSpecial, Says: "Entries that are not plain files or folders:"}, false}, + {tool.Note{ID: noteCollides, Says: "Entries that land on the same file as another on Windows or macOS:"}, false}, + {tool.Note{ID: noteUnwritable, Says: "Names that Windows, macOS or Linux cannot write as they are:"}, false}, + {tool.Note{ID: noteTwoNames, Says: "Entries named one way in the list of contents and another at the entry itself:"}, false}, + {tool.Note{ID: noteOverlapping, Says: "Entries that share their bytes with another, which only an archive built to explode does:"}, false}, + {tool.Note{ID: noteBomb, Says: "Unpacks to far more than its own size, the way an archive built to explode does:"}, false}, + {tool.Note{ID: noteLonger, Says: "Entries holding more than the archive says they do:"}, false}, + {tool.Note{ID: noteOverLimit, Says: "Unpacks to more than the limit given:"}, false}, + {tool.Note{ID: noteDamaged, Says: "Damaged, so programs that unpack it may disagree on what it holds:"}, false}, + {tool.Note{ID: noteUnchecked, Says: "Entries whose contents could not be checked:"}, false}, + {tool.Note{ID: noteStopped, Says: "Reading stopped before the end:"}, false}, + {tool.Note{ID: noteNested, Says: "Archives inside it, which were not opened:"}, true}, +} + +// declaredNotes is what the registry is told. +func declaredNotes() []tool.Note { + out := make([]tool.Note, len(declared)) + for i, d := range declared { + out[i] = d.Note + } + return out +} + +// found is one thing a scan found: the entry it is about, as data, and the +// words for it when the entry alone does not say it. +type found struct { + name string + words core.Said +} + +// Report is what --json prints. Names are the names in the archive, byte for +// byte, so a script compares them. The window and the text show them escaped. +type Report struct { + File string `json:"file"` + Kind string `json:"kind"` + Bytes int64 `json:"bytes"` + Entries int `json:"entries"` + Unpacked uint64 `json:"unpacked_bytes"` + Safe bool `json:"safe"` + // Found is every finding by its key, each with the entries it is about. + // A finding about the archive as a whole has no entry and an empty list. + Found map[string][]string `json:"found"` +} + +// scan is one run of the tool over one archive. +type scan struct { + ctx context.Context + path string + size int64 + limit int64 + progress tool.Progress + read int64 + + kind kind + entries int + unpacked uint64 + names *book + nameRoom int + stopped bool + // exploded is an archive found built to explode, noted once. + exploded bool + + found map[string][]found + // buf is what every entry's contents are read into, one for the scan. + buf []byte +} + +func newScan(ctx context.Context, path string, size, limit int64, progress tool.Progress) *scan { + return &scan{ + ctx: ctx, path: path, size: size, limit: limit, progress: progress, + names: newBook(), nameRoom: MostNameBytes, found: map[string][]found{}, + buf: make([]byte, 64<<10), + } +} + +// note records a finding about an entry, shown as the entry's name. +func (s *scan) note(id, name string) { + s.found[id] = append(s.found[id], found{name: name}) +} + +// noteSaid records a finding with words of its own. +func (s *scan) noteSaid(id, name string, words core.Said) { + s.found[id] = append(s.found[id], found{name: name, words: words}) +} + +// counted tells the progress how much more of the archive has been read. +func (s *scan) counted(n int64) { + s.read += n + s.progress(min(s.read, s.size), s.size) +} + +// stop ends reading, saying why. Everything after this point is unseen, so +// the archive cannot be called safe (MF5). +func (s *scan) stop(why core.Said) { + if s.stopped { + return + } + s.stopped = true + s.noteSaid(noteStopped, "", why) +} + +// entry is what every kind of archive does for each entry it lists: count it, +// judge its name, and record where it lands. False when reading has to stop. +func (s *scan) entry(name string, folder bool) bool { + if s.entries >= MostEntries { + s.stop(core.Says("archivescan.StoppedEntries", "it lists more than %s entries, and no more are read", core.A("Most", core.Exactly(MostEntries)))) + return false + } + s.nameRoom -= len(name) + if s.nameRoom < 0 || s.names.full() { + s.stop(core.Says("archivescan.StoppedNames", "its names take more room than this tool sets aside to compare them, and no more are read")) + return false + } + s.entries++ + if leaves(name) { + s.note(noteLeaves, name) + } + if unwritable(name) { + s.note(noteUnwritable, name) + } + if first, ok := s.names.add(name, folder); ok { + s.noteSaid(noteCollides, name, core.Says("archivescan.Same", "%s = %s", core.A("Name", core.Shown(name)), core.A("First", core.Shown(first)))) + } + return true +} + +// weigh is the size of what an archive unpacks to, against the archive and +// against the limit given. +func (s *scan) weigh() { + if !s.exploded && s.unpacked >= BombBytes && s.unpacked > uint64(s.size)*BombRatio { + s.exploded = true + s.noteSaid(noteBomb, "", core.Says("archivescan.Bomb", "%s from %s", core.A("Unpacked", bytesOf(s.unpacked)), core.A("Size", core.Bytes(s.size)))) + } + if s.limit >= 0 && s.unpacked > uint64(s.limit) { + s.noteSaid(noteOverLimit, "", core.Says("archivescan.OverLimit", "%s against a limit of %s", core.A("Unpacked", bytesOf(s.unpacked)), core.A("Limit", core.Bytes(s.limit)))) + } +} + +// bytesOf is a count of bytes for a sentence. An archive can claim more than +// a signed number holds, and says the most one holds then. +func bytesOf(n uint64) core.Bytes { + if n > math.MaxInt64 { + return core.Bytes(math.MaxInt64) + } + return core.Bytes(n) +} + +// result is what the scan found, as the registry hands it to both surfaces. +func (s *scan) result() tool.Result { + report := Report{ + File: s.path, Kind: string(s.kind), Bytes: s.size, Entries: s.entries, + Unpacked: s.unpacked, Safe: true, Found: map[string][]string{}, + } + summary := map[string]tool.Noted{ + noteKind: {ID: noteKind, Items: []string{string(s.kind)}}, + noteEntries: {ID: noteEntries, Items: []string{core.Exactly(int64(s.entries))}}, + notePacked: sized(notePacked, uint64(s.size)), + noteUnpacked: sized(noteUnpacked, s.unpacked), + } + r := tool.Result{Data: &report, Concluded: concludedSafe} + for _, d := range declared { + if n, ok := summary[d.ID]; ok { + r.Notes = append(r.Notes, n) + continue + } + list := s.found[d.ID] + if len(list) == 0 { + continue + } + r.Notes = append(r.Notes, noted(d.ID, list)) + report.Found[d.ID] = namesOf(list) + if !d.harmless { + report.Safe = false + r.Concluded = concludedUnsafe + } + } + return r +} + +// namesOf is the entries a list of findings is about, for --json: a finding +// about the archive as a whole names none. +func namesOf(list []found) []string { + names := make([]string, 0, len(list)) + for _, f := range list { + if f.name != "" { + names = append(names, f.name) + } + } + return names +} + +// special notes an entry that is not a plain file or folder, and why. +func (s *scan) special(name string, why core.Said) { + s.noteSaid(noteSpecial, name, entryWhy(name, why)) +} + +// entryWhy is an entry and what is wrong with it, as one item of a note. +func entryWhy(name string, why core.Said) core.Said { + return core.Says("archivescan.EntryWhy", "%s: %s", core.A("Name", core.Shown(name)), core.A("Why", why)) +} + +// sized is a note of one size, said the way a person reads it with the exact +// count beside it. Below a kilobyte the two are one number, said once. +func sized(id string, n uint64) tool.Noted { + words := core.Says("archivescan.Size", "%s (%s)", core.A("Size", bytesOf(n)), core.A("Exactly", core.ExactBytes(int64(bytesOf(n))))) + if n < 1024 { + words = core.Says("archivescan.Bytes", "%s", core.A("Size", bytesOf(n))) + } + return tool.Noted{ID: id, Items: []string{words.String()}, Words: []core.Said{words}} +} + +// noted is a note as the registry carries it: each finding as data, and in +// words where any of them has words - then every one, since a window shows +// the words of all or of none. +func noted(id string, list []found) tool.Noted { + n := tool.Noted{ID: id} + worded := false + for _, f := range list { + worded = worded || !f.words.IsZero() + } + for _, f := range list { + words := f.words + if words.IsZero() { + words = core.Says("archivescan.Name", "%s", core.A("Name", core.Shown(f.name))) + } + if worded { + n.Items = append(n.Items, words.String()) + n.Words = append(n.Words, words) + continue + } + n.Items = append(n.Items, f.name) + } + return n +} diff --git a/internal/tool/archivescan/names.go b/internal/tool/archivescan/names.go new file mode 100644 index 00000000..f0262d41 --- /dev/null +++ b/internal/tool/archivescan/names.go @@ -0,0 +1,168 @@ +package archivescan + +import ( + "strings" + + "golang.org/x/text/unicode/norm" + + "github.com/donislawdev/TestingFilesGenerator/internal/engine" +) + +// Where the entries of an archive land, judged the same way on every system: +// an archive travels between machines, and one judged safe on Linux is +// unpacked on Windows next. So both separators are separators everywhere, the +// way the generator refuses both in a name (engine/filename.go). + +// isSeparator is either separator, on every system. +func isSeparator(r rune) bool { return r == '/' || r == '\\' } + +// parts is a path inside an archive split at both separators, with the empty +// parts two separators in a row leave out. +func parts(name string) []string { + return strings.FieldsFunc(name, isSeparator) +} + +// leaves says whether unpacking an entry under this name writes outside the +// folder it is unpacked into: a path from the root, from a drive, or one that +// climbs out. +// +// Any part that climbs is reported, even "a/../b", which stays inside. The +// generator refuses ".." in a name for the same reason, and a name that only +// stays inside after a program works it out is one that some program will not. +// A part of dots and spaces alone counts as climbing too, because Windows +// drops dots and spaces at the end of a name, so ".. " is ".." there. +func leaves(name string) bool { + if name == "" { + return false + } + if name[0] == '/' || name[0] == '\\' { + return true + } + if drive(name) { + return true + } + for _, p := range parts(name) { + if climbs(p) { + return true + } + } + return false +} + +// drive says whether a name starts with a drive letter, as "C:" and "C:x" do - +// the second is a path on that drive's current folder on Windows. +func drive(name string) bool { + return len(name) >= 2 && name[1] == ':' && isLetter(name[0]) +} + +// climbs says whether a part of a path names the folder above, on any system. +func climbs(part string) bool { + return strings.Trim(part, ". ") == "" && strings.Count(part, ".") >= 2 +} + +func isLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// unwritable says whether some part of a name is one a system cannot hold as +// it is - judged by the rules the generator writes its own names by, part by +// part. An entry with no name at all is one too. +func unwritable(name string) bool { + ps := parts(name) + if len(ps) == 0 { + return name == "" + } + // A drive is not a name, and an entry that starts with one is reported as + // leaving the folder already. + if drive(name) { + ps = ps[1:] + } + for _, p := range ps { + if p == "." || climbs(p) { + continue + } + if engine.Unwritable(p) { + return true + } + } + return false +} + +// landing is the name a part of a path lands under where names are told apart +// least: case ignored as Windows and macOS do, accents composed as macOS does, +// and dots and spaces at the end dropped as Windows does. Two names with one +// landing may be one file once unpacked - "may", because the case rules of the +// file systems are tables of their own and this is the part they share. +func landing(part string) string { + if kept := strings.TrimRight(part, ". "); kept != "" { + part = kept + } + return strings.ToLower(norm.NFC.String(part)) +} + +// place is one file or folder an archive unpacks to: the folder it is in, and +// its name as it lands. +type place struct { + in int32 + name string +} + +// book is every place the entries seen so far unpack to, as a tree, to find +// the second entry that lands on a file already written, or a file where a +// folder has to be. +// +// A tree rather than whole paths, so the work for an entry is the length of +// its name: looking up every folder above an entry by its whole path costs the +// square of its depth, and an archive of deep, different paths would turn +// this tool into the thing it warns about. +type book struct { + at map[place]int32 + file []bool + named []string +} + +// root is the folder an archive is unpacked into. +const root = -1 + +func newBook() *book { + return &book{at: map[place]int32{}} +} + +// full says the book holds as many places as it is allowed to. +func (b *book) full() bool { return len(b.named) >= MostPlaces } + +// add records an entry, and says which entry seen before it lands on the same +// place - or put a file where this one needs a folder - if one did. +func (b *book) add(name string, folder bool) (string, bool) { + in := int32(root) + ps := parts(name) + for i, p := range ps { + if p == "." { + continue + } + file := i == len(ps)-1 && !folder + here := place{in: in, name: landing(p)} + id, seen := b.at[here] + if !seen && b.full() { + return "", false + } + if !seen { + in = b.put(here, name, file) + continue + } + if b.file[id] || file { + return b.named[id], true + } + in = id + } + return "", false +} + +// put records a place first reached by the entry with this name. +func (b *book) put(here place, name string, file bool) int32 { + id := int32(len(b.named)) + b.at[here] = id + b.file = append(b.file, file) + b.named = append(b.named, name) + return id +} diff --git a/internal/tool/archivescan/refusals.go b/internal/tool/archivescan/refusals.go new file mode 100644 index 00000000..bb5c9024 --- /dev/null +++ b/internal/tool/archivescan/refusals.go @@ -0,0 +1,133 @@ +package archivescan + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// NotAFileError is a path naming something that is not a file. +type NotAFileError struct { + Path string + Directory bool +} + +// AboutSetting is the input this is about. +func (e *NotAFileError) AboutSetting() string { return InputArchive } + +func (e *NotAFileError) what() core.Said { + if e.Directory { + return core.Says("archivescan.IsADirectory", "%s is a directory and this looks into one archive", core.A("Path", e.Path)) + } + return core.Says("archivescan.IsNotAFile", "%s is not a file but a device, a pipe or a socket", core.A("Path", e.Path)) +} + +func (e *NotAFileError) why() core.Said { + if e.Directory { + return core.Says("archivescan.ADirectoryIsUnpacked", "a directory is what an archive unpacks into, not an archive") + } + return core.Says("archivescan.ReadingMayNeverEnd", "reading one may never end, so it is not started") +} + +func (e *NotAFileError) instead() core.Said { + return core.Says("archivescan.NameOneArchive", "name one archive file") +} + +func (e *NotAFileError) Error() string { return e.Said().String() } + +// Unwrap is the refusal every tool shares. +func (e *NotAFileError) Unwrap() error { + return &tool.NotRegularError{Path: e.Path, Directory: e.Directory} +} + +// Said is the whole refusal, for a window that says it in its own language. +func (e *NotAFileError) Said() core.Said { return tool.Said(e.what(), e.why(), e.instead()) } + +// Class says this is a mistake in the request. +func (e *NotAFileError) Class() tool.Class { return tool.Asked } + +// NotAnArchiveError is a file that begins neither as a ZIP nor as a TAR nor as +// gzip. +type NotAnArchiveError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *NotAnArchiveError) AboutSetting() string { return InputArchive } + +func (e *NotAnArchiveError) what() core.Said { + return core.Says("archivescan.IsNotAnArchive", "%s is not a ZIP, TAR, TAR.GZ or gzip file", core.A("Path", e.Path)) +} + +func (e *NotAnArchiveError) why() core.Said { + return core.Says("archivescan.ItDoesNotBegin", "it does not begin the way any of them does, and the name of a file does not decide what it is") +} + +func (e *NotAnArchiveError) instead() core.Said { + return core.Says("archivescan.NameAZipOrTar", "name a ZIP, TAR, TAR.GZ or gzip file") +} + +func (e *NotAnArchiveError) Error() string { return e.Said().String() } + +// Said is the whole refusal, for a window that says it in its own language. +func (e *NotAnArchiveError) Said() core.Said { return tool.Said(e.what(), e.why(), e.instead()) } + +// Class says this is a mistake in the request. +func (e *NotAnArchiveError) Class() tool.Class { return tool.Asked } + +// NotReadYetError is an archive of a kind this tool recognises and does not +// read. +type NotReadYetError struct { + Path string + Kind string +} + +// AboutSetting is the input this is about. +func (e *NotReadYetError) AboutSetting() string { return InputArchive } + +func (e *NotReadYetError) what() core.Said { + return core.Says("archivescan.NotReadYet", "%s is a %s archive, which this tool does not read yet", core.A("Path", e.Path), core.A("Kind", e.Kind)) +} + +func (e *NotReadYetError) why() core.Said { + return core.Says("archivescan.ItsOwnFormat", "its list of contents is written in a format of its own") +} + +func (e *NotReadYetError) instead() core.Said { + return core.Says("archivescan.PackAsZip", "pack the same files as ZIP or TAR, and look into that") +} + +func (e *NotReadYetError) Error() string { return e.Said().String() } + +// Said is the whole refusal, for a window that says it in its own language. +func (e *NotReadYetError) Said() core.Said { return tool.Said(e.what(), e.why(), e.instead()) } + +// Class says this is a mistake in the request. +func (e *NotReadYetError) Class() tool.Class { return tool.Asked } + +// ChangedError is an archive that changed while it was being read. +type ChangedError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *ChangedError) AboutSetting() string { return InputArchive } + +func (e *ChangedError) what() core.Said { + return core.Says("archivescan.Changed", "%s changed while it was being read", core.A("Path", e.Path)) +} + +func (e *ChangedError) why() core.Said { + return core.Says("archivescan.AnswerAboutNoVersion", "an answer about a file being written is an answer about no version of it") +} + +func (e *ChangedError) instead() core.Said { + return core.Says("archivescan.WaitAndRunAgain", "wait until whatever is writing it has finished, and run this again") +} + +func (e *ChangedError) Error() string { return e.Said().String() } + +// Said is the whole refusal, for a window that says it in its own language. +func (e *ChangedError) Said() core.Said { return tool.Said(e.what(), e.why(), e.instead()) } + +// Class says the disk did not cooperate rather than the request being wrong. +func (e *ChangedError) Class() tool.Class { return tool.Reading } diff --git a/internal/tool/archivescan/sniff.go b/internal/tool/archivescan/sniff.go new file mode 100644 index 00000000..b5b29e9c --- /dev/null +++ b/internal/tool/archivescan/sniff.go @@ -0,0 +1,92 @@ +package archivescan + +import "bytes" + +// Telling what a file is from the bytes it begins with, never from its name - +// a name is the first thing that lies about a file somebody was sent. + +// kind is an archive this tool reads. +type kind string + +const ( + kindZip kind = "zip" + kindTar kind = "tar" + kindTarGz kind = "tar.gz" + kindGzip kind = "gzip" +) + +// headLength is how much of a file is read to tell what it is: a TAR header +// is one block of 512 bytes with its magic at 257. +const headLength = 512 + +var ( + zipLocal = []byte("PK\x03\x04") + zipEmpty = []byte("PK\x05\x06") + gzipMagic = []byte{0x1f, 0x8b} + tarMagic = []byte("ustar") +) + +// others are archives recognised and not read, by the bytes they begin with - +// named in the refusal, so a person learns what they gave rather than that it +// is nothing. +var others = []struct { + name string + magic []byte +}{ + {"7z", []byte{'7', 'z', 0xBC, 0xAF, 0x27, 0x1C}}, + {"RAR", []byte("Rar!\x1a\x07")}, + {"xz", []byte{0xFD, '7', 'z', 'X', 'Z', 0x00}}, + {"bzip2", []byte("BZh")}, + {"zstd", []byte{0x28, 0xB5, 0x2F, 0xFD}}, + {"CAB", []byte("MSCF")}, +} + +// sniff is the kind of an archive from its first bytes, or the name of one +// this tool does not read, or neither. +func sniff(head []byte) (kind, string) { + switch { + case bytes.HasPrefix(head, zipLocal), bytes.HasPrefix(head, zipEmpty): + return kindZip, "" + case bytes.HasPrefix(head, gzipMagic): + return kindGzip, "" + case isTar(head): + return kindTar, "" + } + for _, o := range others { + if bytes.HasPrefix(head, o.magic) { + return "", o.name + } + } + return "", "" +} + +// isTar says whether a block is a TAR header, or the block of zeros an empty +// TAR is. POSIX and GNU both write "ustar" at 257. The old V7 layout has no +// magic at all and is not told apart from any other file. +func isTar(head []byte) bool { + if len(head) < headLength { + return false + } + return bytes.Equal(head[257:257+len(tarMagic)], tarMagic) || allZero(head[:headLength]) +} + +// archiveIn names the archive a piece of contents begins as, or nothing - for +// an entry holding another archive, which is reported and never opened. +func archiveIn(head []byte) string { + if k, other := sniff(head); other != "" { + return other + } else if k != "" && !(k == kindTar && allZero(head)) { + return string(k) + } + return "" +} + +// allZero says whether every byte is zero. +func allZero(b []byte) bool { + for _, c := range b { + if c != 0 { + return false + } + } + return true +} diff --git a/internal/tool/archivescan/tar.go b/internal/tool/archivescan/tar.go new file mode 100644 index 00000000..0f97deee --- /dev/null +++ b/internal/tool/archivescan/tar.go @@ -0,0 +1,346 @@ +package archivescan + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "os" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// Reading a TAR archive, bare or inside gzip, and a gzip file that holds one +// file. A TAR has no list of contents: each entry is a header and its bytes, +// one after another, so the only way to the next name is through the bytes +// before it. That is why an archive built to explode is caught while it is +// read rather than from a list, and reading stops there. + +// stream is the reading of one TAR or gzip archive, front to back. +type stream struct { + *scan +} + +// tar reads a TAR archive from its first byte. +func (s *scan) tar(r io.Reader) error { + if err := (stream{s}).walk(&counting{r: r, s: s}); err != nil { + return err + } + s.weigh() + return nil +} + +// gzip reads a gzip file, and a TAR in it when there is one. +// +// What the gzip stream unpacks to is counted on its own, below any TAR: that +// is what gunzip writes before anything looks inside, so an archive built to +// explode at that layer is one whatever the TAR in it says - and a stream of +// zeros, which a TAR reader takes for an empty TAR, is caught there. +func (s *scan) gzip(r io.Reader) error { + s.kind = kindGzip + z, err := gzip.NewReader(&counting{r: r, s: s}) + if err != nil { + if failedToRead(err) { + return err + } + s.noteSaid(noteDamaged, "", core.Says("archivescan.GzipHeader", "its gzip header is broken")) + return nil + } + in := bufio.NewReaderSize(&inflating{r: z, s: s}, 64<<10) + head, err := in.Peek(headLength) + if err != nil && failedToRead(err) { + return err + } + if err := (stream{s}).gzipped(in, head); err != nil { + return err + } + s.weigh() + return nil +} + +// gzipped reads what a gzip stream holds: a TAR, told by its magic and not by +// a block of zeros - inside gzip that is the start of any stream of zeros - +// or one file. +func (st stream) gzipped(in io.Reader, head []byte) error { + if len(head) < 257+len(tarMagic) || !bytes.Equal(head[257:257+len(tarMagic)], tarMagic) { + return st.one(in) + } + st.kind = kindTarGz + if err := st.walk(in); err != nil || st.stopped { + return err + } + // What follows the end of the TAR, which a TAR program never reads and + // gunzip writes all the same. + _, _, err := drain(st.ctx, st.buf, in, ^uint64(0), nil, nil) + return st.ended(err, "") +} + +// one reads a gzip file that holds one file. Its name is not judged: gunzip +// names what it unpacks after the archive, not after the name inside. +func (st stream) one(in io.Reader) error { + st.scan.entries = 1 + _, head, err := drain(st.ctx, st.buf, in, ^uint64(0), nil, st.unpacking) + if err := st.ended(err, ""); err != nil || st.stopped { + return err + } + if archiveIn([]byte(head)) != "" { + st.note(noteNested, "") + } + return nil +} + +// walk reads the entries of a TAR one after another. +func (st stream) walk(r io.Reader) error { + tr := tar.NewReader(r) + for !st.stopped { + h, err := st.next(tr) + if err != nil || h == nil { + return err + } + if h.Typeflag == tar.TypeXGlobalHeader { + st.global(h) + continue + } + if !st.entry(h.Name, h.Typeflag == tar.TypeDir) { + return nil + } + st.kindOf(h) + if h.Size <= 0 { + continue + } + _, head, err := drain(st.ctx, st.buf, tr, uint64(h.Size), nil, st.unpacking) + if err := st.ended(err, h.Name); err != nil { + return err + } + if archiveIn([]byte(head)) != "" { + st.note(noteNested, h.Name) + } + } + return nil +} + +// next is the header of the next entry, or nothing at the end of the archive +// or where a broken one ends the reading. +func (st stream) next(tr *tar.Reader) (*tar.Header, error) { + if err := st.ctx.Err(); err != nil { + return nil, err + } + h, err := tr.Next() + switch { + case err == nil: + return h, nil + case errors.Is(err, io.EOF): + return nil, nil + case st.exploded || failedToRead(err): + return nil, st.ended(err, "") + } + st.noteSaid(noteDamaged, "", core.Says("archivescan.TarHeader", "a header is broken, or it ends in the middle of one")) + return nil, nil +} + +// kindOf notes an entry that is not a plain file or folder. +func (st stream) kindOf(h *tar.Header) { + switch h.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + st.noteSaid(noteLinks, h.Name, linked(h.Name, h.Linkname)) + return + case tar.TypeChar, tar.TypeBlock: + st.special(h.Name, core.Says("archivescan.Device", "it is a device")) + return + case tar.TypeFifo: + st.special(h.Name, core.Says("archivescan.Pipe", "it is a pipe")) + return + case tar.TypeReg, tar.TypeDir, tar.TypeGNUSparse, tar.TypeCont, '\x00': + default: + st.special(h.Name, core.Says("archivescan.Type", "it is an entry of type %q, which most programs do not unpack", core.A("Type", string(h.Typeflag)))) + return + } + if h.Typeflag != tar.TypeDir && h.Mode&(modeSetUser|modeSetGroup) != 0 { + st.special(h.Name, core.Says("archivescan.Rights", "it runs with the rights of its owner")) + } +} + +// global looks at a PAX global header, which is not an entry: it gives values +// to every entry after it, and git archive writes one at the front of every +// TAR to hold the commit, as a comment. One setting anything that changes what +// an entry is - its name, its link, its size - changes entries in a way this +// tool does not follow, and is noted. Not under its name: archive/tar hands +// the header back named by the path it sets. +func (st stream) global(h *tar.Header) { + first := "" + for key := range h.PAXRecords { + if !globalHarmless[key] && (first == "" || key < first) { + first = key + } + } + if first != "" { + st.noteSaid(noteSpecial, "", core.Says("archivescan.Global", "a header sets %s for every entry after it, which this tool does not follow", + core.A("Key", core.Shown(first)))) + } +} + +// globalHarmless are the values a PAX global header may give the entries +// after it and leave what they are alone: words, times and owners. +var globalHarmless = map[string]bool{ + "comment": true, "charset": true, "hdrcharset": true, + "atime": true, "mtime": true, "ctime": true, + "uid": true, "gid": true, "uname": true, "gname": true, +} + +// unpacking is told every piece of contents read from a stream, and says +// whether to go on: not once what it unpacks to is an archive built to explode. +// Contents and not only a gzip layer, because a sparse entry in a bare TAR +// unpacks to its holes too. +func (st stream) unpacking(n int) bool { + st.unpacked = addCapped(st.unpacked, uint64(n)) + return !st.explodes(st.unpacked) +} + +// ended sorts the end of reading one entry of a stream: a stop and a disk that +// would not read are errors, and contents that end early or do not unpack are +// damage, after which nothing more is read. +func (st stream) ended(err error, name string) error { + switch { + case err == nil, st.exploded: + return nil + case st.ctx.Err() != nil: + return st.ctx.Err() + case failedToRead(err): + return err + } + words := core.Says("archivescan.StreamBroken", "it ends early, or its contents do not unpack") + if name != "" { + words = entryWhy(name, words) + } + st.noteSaid(noteDamaged, name, words) + st.stopped = true + return nil +} + +// explodes says whether having unpacked this much from what was read of the +// archive to get there is an archive built to explode - a gigabyte, and more +// than a hundred times what was read - and notes it the first time. Reading +// stops there: going on is what such an archive is built to make a program do. +func (s *scan) explodes(unpacked uint64) bool { + if s.exploded { + return true + } + if unpacked < BombBytes || unpacked <= uint64(s.read)*BombRatio { + return false + } + s.exploded = true + s.noteSaid(noteBomb, "", core.Says("archivescan.Bomb", "%s from %s", core.A("Unpacked", bytesOf(unpacked)), core.A("Size", core.Bytes(s.read)))) + s.stop(core.Says("archivescan.StoppedBomb", "it unpacks to more than %d times what was read of it, and no more is read", core.A("Ratio", BombRatio))) + return true +} + +// errExploded unwinds a reader once the archive has been found built to +// explode, from inside the gzip layer the TAR reader reads through. +var errExploded = core.Defect(errors.New("reading an archive built to explode did not stop where it was found")) + +// inflating counts what a gzip stream unpacks to. +type inflating struct { + r io.Reader + s *scan + out uint64 +} + +func (i *inflating) Read(p []byte) (int, error) { + n, err := i.r.Read(p) + i.out = addCapped(i.out, uint64(n)) + if i.s.explodes(i.out) { + return n, errExploded + } + return n, err +} + +// failedToRead says whether an error is the disk's rather than the archive's. +func failedToRead(err error) bool { + var path *os.PathError + return errors.As(err, &path) +} + +// The mode bits that make a program run with the rights of its owner or its +// group, in the octal a Unix mode is written in. On a folder they say whose +// the files made in it are, and run nothing. +const ( + modeSetUser = 0o4000 + modeSetGroup = 0o2000 +) + +// modeFolder is the type a Unix mode gives a folder. +const modeFolder = 0x4000 + +// kindOfMode is what a Unix mode says an entry is besides a file or a folder: +// a link, or the words for something else. Nothing for a plain file or folder. +func kindOfMode(mode uint32) (core.Said, bool) { + switch mode & 0xF000 { + case 0xA000: + return core.Said{}, true + case 0x1000: + return core.Says("archivescan.Pipe", "it is a pipe"), false + case 0x2000, 0x6000: + return core.Says("archivescan.Device", "it is a device"), false + case 0xC000: + return core.Says("archivescan.Socket", "it is a socket"), false + } + if mode&0xF000 != modeFolder && mode&(modeSetUser|modeSetGroup) != 0 { + return core.Says("archivescan.Rights", "it runs with the rights of its owner"), false + } + return core.Said{}, false +} + +// linked is a link in words: its name and where it points, when known. +func linked(name, target string) core.Said { + if target == "" { + return core.Says("archivescan.Name", "%s", core.A("Name", core.Shown(name))) + } + return core.Says("archivescan.Link", "%s -> %s", core.A("Name", core.Shown(name)), core.A("Target", core.Shown(target))) +} + +// keepHead is how much of an entry's contents is kept: enough to tell an +// archive inside it, and the whole target of a link, which a system keeps to +// 4096 bytes. +const keepHead = 4096 + +// drain reads r through sum, when there is one, until it ends or one byte past +// most - an entry holding more than it was said to is found without reading +// the rest of it - and hands back how much it read and the first bytes. After +// every piece it asks more, when there is one, whether to go on. It reads +// into buf, one for the whole scan: an archive can hold a million entries. +func drain(ctx context.Context, buf []byte, r io.Reader, most uint64, sum io.Writer, more func(int) bool) (uint64, string, error) { + var got uint64 + var head []byte + for { + if err := ctx.Err(); err != nil { + return got, string(head), err + } + n, err := r.Read(buf[:wanted(len(buf), most, got)]) + got += uint64(n) + head = append(head, buf[:min(n, keepHead-len(head))]...) + if sum != nil { + _, _ = sum.Write(buf[:n]) + } + if n > 0 && more != nil && !more(n) { + return got, string(head), nil + } + if got > most || errors.Is(err, io.EOF) { + return got, string(head), nil + } + if err != nil { + return got, string(head), err + } + } +} + +// wanted is how much to read next: a buffer, or what is left to one byte past +// most when that is less. Most of all bits is no bound. +func wanted(buffer int, most, got uint64) int { + if most == ^uint64(0) || most+1-got >= uint64(buffer) { + return buffer + } + return int(most + 1 - got) +} diff --git a/internal/tool/archivescan/zip.go b/internal/tool/archivescan/zip.go new file mode 100644 index 00000000..51a722a9 --- /dev/null +++ b/internal/tool/archivescan/zip.go @@ -0,0 +1,318 @@ +package archivescan + +import ( + "bufio" + "bytes" + "compress/flate" + "errors" + "hash/crc32" + "io" + "sort" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// Reading a ZIP archive: the record at its end, which says where the list of +// contents is, then the list, one record at a time, then each entry where the +// list says it starts, and what each entry holds. +// +// Read here rather than through archive/zip, which holds the whole list in +// memory before a caller can ask how long it is - 216 bytes an entry, measured +// (docs/SKANER-ARCHIWUM-2026-10-19.md §2.1) - and which never shows the name +// written at the entry itself, the one a program unpacking from the front +// reads instead of the list's. + +// zipScan is the reading of one ZIP: the scan it reports to, the archive and +// where its list is. +type zipScan struct { + *scan + r io.ReaderAt + end zipEnd + // data is where the contents of each entry start, by its place in the + // list - zero for an entry whose header is not where the list says. + data []uint64 + // shared are the entries whose bytes lie in another's. + shared map[int]bool + // open is an archive whose contents are read: not one already found + // built to explode, which is what reading them would do. + open bool + // inflate unpacks every deflated entry, one for the archive: a new one + // holds tens of kilobytes, and an archive can hold a million entries. + inflate io.ReadCloser +} + +// zip reads a ZIP archive. +func (s *scan) zip(r io.ReaderAt) error { + end, err := findEnd(r, s.size) + switch { + case errors.Is(err, errNoEnd): + s.noteSaid(noteDamaged, "", core.Says("archivescan.NoEnd", "the record at its end, which says where its list of contents is, is missing")) + return nil + case err != nil: + return err + case end.split: + s.noteSaid(noteDamaged, "", core.Says("archivescan.Split", "it says it is one part of an archive split across several files")) + return nil + case end.listAt > end.at || end.listSize > end.at-end.listAt: + s.noteSaid(noteDamaged, "", core.Says("archivescan.ListElsewhere", "its list of contents is not where the record at its end says")) + return nil + } + z := &zipScan{scan: s, r: r, end: end} + spans, err := z.list() + if err != nil { + return err + } + if !s.stopped && uint64(len(z.data)) != end.entries { + s.noteSaid(noteDamaged, "", core.Says("archivescan.Counted", "the record at its end gives the number of entries as %d and the list holds %d", + core.A("Counted", end.entries), core.A("Listed", len(z.data)))) + } + z.shared = overlapping(spans) + s.weigh() + z.open = !s.stopped && len(z.shared) == 0 && !s.exploded + return z.contents() +} + +// span is where one entry lies in the archive, from its header to the end of +// its contents. +type span struct { + from, to uint64 + entry int +} + +// list is the first look at every entry: its name, its place among the +// others, and its header where the list says it starts. It hands back where +// each entry lies. +func (z *zipScan) list() ([]span, error) { + var spans []span + err := z.each(true, func(e zipEntry) bool { + if !z.entry(e.name, isFolder(e)) { + return false + } + z.unpacked = addCapped(z.unpacked, e.unpacked) + at := z.header(e) + z.data = append(z.data, at) + if at != 0 { + spans = append(spans, span{from: e.at, to: at + e.packed, entry: len(z.data) - 1}) + } + return true + }) + return spans, err +} + +// header reads the header an entry begins with, and says where its contents +// start - or nothing, when the header is not where the list says or the +// contents run into the list. +func (z *zipScan) header(e zipEntry) uint64 { + end := z.end + if e.at > end.listAt || end.listAt-e.at < lenLocal { + return z.broken(e, core.Says("archivescan.NotWhereListed", "it does not start where the list of contents says")) + } + // One read for the header and a name as long as the listed one, which is + // what it nearly always is - a second only when it is not. + head := make([]byte, min(uint64(lenLocal+len(e.name)), end.listAt-e.at)) + if _, err := z.r.ReadAt(head, int64(e.at)); err != nil || le32(head) != sigLocal { + return z.broken(e, core.Says("archivescan.NotWhereListed", "it does not start where the list of contents says")) + } + nameLen, extraLen := uint64(le16(head[26:])), uint64(le16(head[28:])) + data := e.at + lenLocal + nameLen + extraLen + if data > end.listAt || e.packed > end.listAt-data { + return z.broken(e, core.Says("archivescan.RunsIntoList", "its contents run into the list of contents")) + } + local := head[lenLocal:] + if uint64(len(local)) != nameLen { + local = make([]byte, nameLen) + if _, err := z.r.ReadAt(local, int64(e.at+lenLocal)); err != nil { + return z.broken(e, core.Says("archivescan.NotWhereListed", "it does not start where the list of contents says")) + } + } + if !bytes.Equal(local, []byte(e.name)) { + z.noteSaid(noteTwoNames, e.name, core.Says("archivescan.TwoNames", "%s in the list, %s at the entry", + core.A("Listed", core.Shown(e.name)), core.A("Local", core.Shown(string(local))))) + } + return data +} + +// broken notes an entry that is not what the list says, and hands back the +// nothing its header's reader answers with then. +func (z *zipScan) broken(e zipEntry, why core.Said) uint64 { + z.noteSaid(noteDamaged, e.name, entryWhy(e.name, why)) + return 0 +} + +// overlapping is every entry whose bytes lie in another's - the way the +// archives that unpack to a million times their size are built, since one +// stretch of contents read as many files is the only way past what deflate +// can do once (docs/SKANER-ARCHIWUM-2026-10-19.md §3). +func overlapping(spans []span) map[int]bool { + order := make([]span, len(spans)) + copy(order, spans) + sort.Slice(order, func(i, j int) bool { return order[i].from < order[j].from }) + shared := map[int]bool{} + reach, owner := uint64(0), -1 + for i, sp := range order { + if i > 0 && sp.from < reach { + shared[sp.entry] = true + shared[owner] = true + } + if sp.to > reach { + reach, owner = sp.to, sp.entry + } + } + return shared +} + +// contents is the second look at every entry the first one counted: the kind +// of thing it is, whether its bytes are another's and what it holds. +func (z *zipScan) contents() error { + index := 0 + var failed error + err := z.each(false, func(e zipEntry) bool { + if index >= len(z.data) { + return false + } + failed = z.inside(e, index) + index++ + return failed == nil + }) + if failed != nil { + return failed + } + return err +} + +// inside is the second look at one entry. +func (z *zipScan) inside(e zipEntry, index int) error { + if z.shared[index] { + z.note(noteOverlapping, e.name) + } + special, link := unixKind(e) + if !special.IsZero() { + z.special(e.name, special) + return nil + } + at := z.data[index] + readable := z.open && at != 0 && !isFolder(e) + if link { + target := "" + var err error + if readable { + target, err = z.read(e, at) + } + z.noteSaid(noteLinks, e.name, linked(e.name, target)) + return err + } + if !readable { + return nil + } + head, err := z.read(e, at) + if err == nil && archiveIn([]byte(head)) != "" { + z.note(noteNested, e.name) + } + return err +} + +// read reads what an entry holds, through its checksum and no further than +// one byte past what the list says it holds, and hands back its first bytes. +// What is wrong with the contents is noted, and only a stop or a disk that +// fails to read is an error. +func (z *zipScan) read(e zipEntry, at uint64) (string, error) { + switch { + case e.flags&1 != 0 || e.method == methodAES: + z.noteSaid(noteUnchecked, e.name, entryWhy(e.name, core.Says("archivescan.Encrypted", "it is encrypted"))) + return "", nil + case e.method != methodStored && e.method != methodDeflated: + z.noteSaid(noteUnchecked, e.name, entryWhy(e.name, core.Says("archivescan.Method", + "it is packed with method %d, which this tool does not read", core.A("Method", int(e.method))))) + return "", nil + } + var src io.Reader = &counting{r: io.NewSectionReader(z.r, int64(at), int64(e.packed)), s: z.scan} + if e.method == methodDeflated { + src = z.inflated(src) + } + sum := crc32.NewIEEE() + got, head, err := drain(z.ctx, z.buf, src, e.unpacked, sum, nil) + var corrupt flate.CorruptInputError + switch { + case z.ctx.Err() != nil: + return "", z.ctx.Err() + case got > e.unpacked: + z.note(noteLonger, e.name) + case errors.As(err, &corrupt), errors.Is(err, io.ErrUnexpectedEOF), got < e.unpacked: + z.broken(e, core.Says("archivescan.EndsEarly", "its contents end before the size the list gives them")) + case err != nil: + return "", err + case sum.Sum32() != e.crc: + z.broken(e, core.Says("archivescan.Checksum", "its contents do not match their checksum")) + } + return head, nil +} + +// inflated is what deflated contents unpack to, through the archive's one +// reader of deflate. Its Reset starts it over on new contents and keeps its +// buffers, and never fails (compress/flate). +func (z *zipScan) inflated(src io.Reader) io.Reader { + if z.inflate == nil { + z.inflate = flate.NewReader(src) + return z.inflate + } + _ = z.inflate.(flate.Resetter).Reset(src, nil) + return z.inflate +} + +// each reads the list of contents one record at a time and hands each to see, +// until the list ends or see says to stop. A list that ends in the middle of a +// record, or holds something that is not one, is noted as damage on the first +// look and passed over on the second, which reads the same bytes again. +func (z *zipScan) each(first bool, see func(zipEntry) bool) error { + in := bufio.NewReaderSize(io.NewSectionReader(z.r, int64(z.end.listAt), int64(z.end.listSize)), 64<<10) + for { + if err := z.ctx.Err(); err != nil { + return err + } + e, size, problem, err := readListed(in) + switch { + case errors.Is(err, io.EOF): + return nil + case err != nil: + return err + case !problem.IsZero(): + return z.listBroken(first, problem) + } + if first { + z.counted(int64(size)) + } + if !see(e) { + return nil + } + } +} + +// listBroken notes a list of contents that is not one, on the first look. +func (z *zipScan) listBroken(first bool, problem core.Said) error { + if first { + z.noteSaid(noteDamaged, "", problem) + } + return nil +} + +// counting tells the scan how much of the archive is read through it. +type counting struct { + r io.Reader + s *scan +} + +func (c *counting) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.s.counted(int64(n)) + return n, err +} + +// addCapped adds two sizes, holding at the most a size can be. An archive +// whose entries claim more than that in total is reported as built to explode +// either way. +func addCapped(a, b uint64) uint64 { + if a+b < a { + return ^uint64(0) + } + return a + b +} diff --git a/internal/tool/archivescan/ziplist.go b/internal/tool/archivescan/ziplist.go new file mode 100644 index 00000000..77bdcbe8 --- /dev/null +++ b/internal/tool/archivescan/ziplist.go @@ -0,0 +1,223 @@ +package archivescan + +import ( + "bufio" + "encoding/binary" + "errors" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The records a ZIP is made of, read from their bytes: the one at the end +// that says where the list of contents is, and the records of the list. What +// is done with them is in zip.go. + +// The records of a ZIP, by the four bytes each begins with, and their lengths +// before the parts of variable length (APPNOTE 6.3.10, sections 4.3.7-4.3.16). +const ( + sigEnd = 0x06054b50 + sigEnd64 = 0x06064b50 + sigLocator64 = 0x07064b50 + sigListed = 0x02014b50 + sigLocal = 0x04034b50 + + lenEnd = 22 + lenLocator64 = 20 + lenEnd64 = 56 + lenListed = 46 + lenLocal = 30 + mostComment = 0xFFFF +) + +// The methods whose contents are read. +const ( + methodStored = 0 + methodDeflated = 8 + // methodAES is WinZip's encryption, which a ZIP marks with a method of + // its own rather than with the encryption flag. + methodAES = 99 +) + +// zipEnd is the record at the end of a ZIP that says where its list is. +type zipEnd struct { + // at is where the record starts. The list has to end before it. + at uint64 + entries uint64 + listAt uint64 + listSize uint64 + // split is an archive that says it is one part of several. + split bool +} + +// zipEntry is one record of the list of contents. +type zipEntry struct { + name string + flags uint16 + method uint16 + system byte + crc uint32 + packed uint64 + unpacked uint64 + at uint64 + external uint32 +} + +// errNoEnd is a file that begins as a ZIP and has no record at its end saying +// where its list of contents is. +var errNoEnd = core.Defect(errors.New("a ZIP without an end record was not noted as damaged")) + +func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) } +func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) } +func le64(b []byte) uint64 { return binary.LittleEndian.Uint64(b) } + +// findEnd reads the record at the end of a ZIP. The comment after it is up to +// 64 KiB long, so the record is searched for from the end, and taken only +// where its comment ends exactly where the file does - a record found inside +// somebody's comment would point at a list of their choosing. +func findEnd(r io.ReaderAt, size int64) (zipEnd, error) { + tail := min(size, lenEnd+mostComment) + buf := make([]byte, tail) + if _, err := r.ReadAt(buf, size-tail); err != nil && !errors.Is(err, io.EOF) { + return zipEnd{}, err + } + for i := len(buf) - lenEnd; i >= 0; i-- { + rec := buf[i:] + if le32(rec) != sigEnd || i+lenEnd+int(le16(rec[20:])) != len(buf) { + continue + } + e := zipEnd{ + at: uint64(size - tail + int64(i)), + entries: uint64(le16(rec[10:])), + listSize: uint64(le32(rec[12:])), + listAt: uint64(le32(rec[16:])), + split: le16(rec[4:]) != 0 || le16(rec[6:]) != 0, + } + if e.entries == 0xFFFF || e.listSize == 0xFFFFFFFF || e.listAt == 0xFFFFFFFF { + return findEnd64(r, e) + } + return e, nil + } + return zipEnd{}, errNoEnd +} + +// findEnd64 reads the larger record a ZIP of more than 65 535 entries or 4 GiB +// keeps before the ordinary one, through the locator just before that. +func findEnd64(r io.ReaderAt, e zipEnd) (zipEnd, error) { + if e.at < lenLocator64+lenEnd64 { + return zipEnd{}, errNoEnd + } + var loc [lenLocator64]byte + if _, err := r.ReadAt(loc[:], int64(e.at-lenLocator64)); err != nil { + return zipEnd{}, errNoEnd + } + at := le64(loc[8:]) + if le32(loc[:]) != sigLocator64 || at > e.at-lenLocator64-lenEnd64 { + return zipEnd{}, errNoEnd + } + var rec [lenEnd64]byte + if _, err := r.ReadAt(rec[:], int64(at)); err != nil || le32(rec[:]) != sigEnd64 { + return zipEnd{}, errNoEnd + } + return zipEnd{ + at: at, + entries: le64(rec[32:]), + listSize: le64(rec[40:]), + listAt: le64(rec[48:]), + split: e.split || le32(loc[16:]) > 1 || le32(rec[16:]) != 0 || le32(rec[20:]) != 0, + }, nil +} + +// readListed reads one record of the list of contents, and how long it was. +// io.EOF is a list that ended between records, and a problem one that did not. +func readListed(in *bufio.Reader) (zipEntry, int, core.Said, error) { + endsEarly := core.Says("archivescan.ListEndsEarly", "its list of contents ends in the middle of an entry") + var head [lenListed]byte + if _, err := io.ReadFull(in, head[:]); err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + return zipEntry{}, 0, endsEarly, nil + } + return zipEntry{}, 0, core.Said{}, err + } + if le32(head[:]) != sigListed { + return zipEntry{}, 0, core.Says("archivescan.ListHoldsOther", "its list of contents holds something that is not an entry"), nil + } + nameLen, comment := int(le16(head[28:])), int(le16(head[32:])) + rest := make([]byte, nameLen+int(le16(head[30:]))) + if _, err := io.ReadFull(in, rest); err != nil { + return zipEntry{}, 0, endsEarly, nil + } + if _, err := in.Discard(comment); err != nil { + return zipEntry{}, 0, endsEarly, nil + } + e := zipEntry{ + name: string(rest[:nameLen]), flags: le16(head[8:]), method: le16(head[10:]), + system: head[5], crc: le32(head[16:]), + packed: uint64(le32(head[20:])), unpacked: uint64(le32(head[24:])), + at: uint64(le32(head[42:])), external: le32(head[38:]), + } + larger(&e, rest[nameLen:]) + return e, lenListed + len(rest) + comment, core.Said{}, nil +} + +// larger takes the sizes and the place of an entry from its ZIP64 field, +// where the list writes the most a 32 bit number holds instead. +func larger(e *zipEntry, extra []byte) { + for len(extra) >= 4 { + id, size := le16(extra), int(le16(extra[2:])) + extra = extra[4:] + if size > len(extra) { + return + } + if id == 0x0001 { + zip64(e, extra[:size]) + } + extra = extra[size:] + } +} + +// zip64 reads the ZIP64 field (APPNOTE 4.5.3): each value is there only when +// its field in the record holds the most 32 bits do, and in this order. +func zip64(e *zipEntry, field []byte) { + for _, v := range []*uint64{&e.unpacked, &e.packed, &e.at} { + if *v != 0xFFFFFFFF { + continue + } + if len(field) < 8 { + return + } + *v = le64(field) + field = field[8:] + } +} + +// isFolder says whether a listed entry is a folder: a name that ends in a +// separator, or the folder bit of the system that wrote it. +func isFolder(e zipEntry) bool { + if n := len(e.name); n > 0 && (e.name[n-1] == '/' || e.name[n-1] == '\\') { + return true + } + mode, unix := unixMode(e) + return (unix && mode&0xF000 == 0x4000) || e.external&0x10 != 0 +} + +// unixMode is the Unix file mode of an entry written on a Unix system, which +// keeps it in the upper half of its external attributes. +func unixMode(e zipEntry) (uint32, bool) { + switch e.system { + case 3, 19: // Unix, and OS X - which writes the same + return e.external >> 16, true + } + return 0, false +} + +// unixKind says what an entry is besides a file or a folder, from the mode a +// Unix system wrote: a link, or something that is not a plain file, with the +// words for it. +func unixKind(e zipEntry) (core.Said, bool) { + mode, unix := unixMode(e) + if !unix { + return core.Said{}, false + } + return kindOfMode(mode) +} diff --git a/internal/tool/checksum/read.go b/internal/tool/checksum/read.go index 98f7c6dd..7a83df5c 100644 --- a/internal/tool/checksum/read.go +++ b/internal/tool/checksum/read.go @@ -3,9 +3,12 @@ package checksum import ( "context" "encoding/hex" + "errors" "hash" "io" "os" + + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // Reading files, for all three tools of this package. One way in, so a file @@ -64,36 +67,15 @@ func digest(ctx context.Context, path string, chosen map[string]bool, scratch [] 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. +// openRegular is tool.OpenRegular with its refusal in the words of these +// tools - a checksum of a directory, not just a directory. 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 + f, info, err := tool.OpenRegular(path) + var not *tool.NotRegularError + if errors.As(err, ¬) { + return nil, nil, &NotAFileError{Path: path, Directory: not.Directory} } - return f, info, nil + return f, info, err } // changed says whether the file moved under the read. A checksum of a file diff --git a/internal/tool/checksum/refusals.go b/internal/tool/checksum/refusals.go index 0c9eed44..a2de117b 100644 --- a/internal/tool/checksum/refusals.go +++ b/internal/tool/checksum/refusals.go @@ -123,6 +123,12 @@ func (e *NotAFileError) instead() core.Said { func (e *NotAFileError) Error() string { return e.Said().String() } +// Unwrap is the refusal every tool shares, so a check that asks any tool +// whether it turned a pipe away finds this one too. +func (e *NotAFileError) Unwrap() error { + return &tool.NotRegularError{Path: e.Path, Directory: e.Directory} +} + // Said is the whole refusal, for a window that says it in its own language. func (e *NotAFileError) Said() core.Said { return tool.Said(e.what(), e.why(), e.instead()) } diff --git a/internal/tool/open.go b/internal/tool/open.go new file mode 100644 index 00000000..1f5484f7 --- /dev/null +++ b/internal/tool/open.go @@ -0,0 +1,57 @@ +package tool + +import "os" + +// Opening a file a tool reads. One way in for every tool, so a file named on +// its own, the thousandth file of a folder and an archive somebody was sent +// are opened with the same care - the review of #157 found a pipe swapped in +// between two looks at a name, and the answer to that is here rather than in +// each tool (docs/NARZEDZIA-SUMY-2026-09-29.md §13.4). Moved out of the +// checksum tool on 2026-10-19, when a second tool came to read files. + +// NotRegularError is a path that names something other than a file. Each tool +// says it in its own words, wrapping this, so a check that every tool refuses +// a pipe asks for this one type. +type NotRegularError struct { + Path string + Directory bool +} + +func (e *NotRegularError) Error() string { + if e.Directory { + return e.Path + " is a directory" + } + return e.Path + " is not a file but a device, a pipe or a socket" +} + +// 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 = &NotRegularError{Path: path, Directory: info.IsDir()} + } + if err == nil { + err = waitAgain(f) + } + if err != nil { + _ = f.Close() + return nil, nil, err + } + return f, info, nil +} diff --git a/internal/tool/checksum/open_unix.go b/internal/tool/open_unix.go similarity index 87% rename from internal/tool/checksum/open_unix.go rename to internal/tool/open_unix.go index 3d256fab..a3b5e9bf 100644 --- a/internal/tool/checksum/open_unix.go +++ b/internal/tool/open_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package checksum +package tool import ( "os" @@ -12,7 +12,7 @@ import ( // // A pipe opened for reading waits until something writes to it, which may be // never. O_NONBLOCK makes the open return at once, so what was opened can be -// asked what it is before a byte is read - see openRegular. +// asked what it is before a byte is read - see OpenRegular. func openForLooking(path string) (*os.File, error) { return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) } @@ -22,7 +22,7 @@ func openForLooking(path string) (*os.File, error) { // open(2) says the flag has no effect on a regular file, and in the same // paragraph that a program should not depend on that staying so (man7.org, // open(2), O_NONBLOCK, read 2026-09-30). A read that came back "try again" -// would end the checksum with an error nobody could act on. +// would end the work of a tool with an error nobody could act on. func waitAgain(f *os.File) error { raw, err := f.SyscallConn() if err != nil { diff --git a/internal/tool/checksum/open_windows.go b/internal/tool/open_windows.go similarity index 93% rename from internal/tool/checksum/open_windows.go rename to internal/tool/open_windows.go index 2c574338..b8143393 100644 --- a/internal/tool/checksum/open_windows.go +++ b/internal/tool/open_windows.go @@ -1,6 +1,6 @@ //go:build windows -package checksum +package tool import "os" @@ -8,7 +8,7 @@ import "os" // flags it knows and O_NONBLOCK is not one of them (syscall_windows.go, Open, // read 2026-09-30), so asking for it would change nothing. What was opened is // still asked what it is before a byte is read, the same as everywhere else - -// see openRegular. +// see OpenRegular. func openForLooking(path string) (*os.File, error) { return os.Open(path) } diff --git a/internal/tool/tool.go b/internal/tool/tool.go index dc01930c..ed3f734d 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -125,6 +125,27 @@ type Note struct { Says string } +// Conclusion is one answer a tool may come to about what it read - "safe to +// unpack", "not safe to unpack" - declared once, the way a note is. +// +// Declared rather than added to Outcome, which holds the one comparison a +// checksum makes. Each tool has answers of its own, and an answer added to a +// switch in the registry, in the window and in the command line is three +// edits that a guard listing the cases by hand would never miss. Declared, +// the window says it through the registry's catalogue and the guards that +// walk the registry hold it in every language on the day it arrives. +type Conclusion struct { + // ID is the key of the conclusion, in a result and in the words of every + // language. + ID string + // Says is the sentence, in English. No number, for the reason a note has + // none: what is counted is in the notes above it. + Says string + // Fails is an answer the command line ends with VERIFY on - a check that + // found something - and the window shows in the colour of a mismatch. + Fails bool +} + // Noted is one note a run of a tool made, with what it is about. type Noted struct { ID string @@ -155,6 +176,9 @@ type Result struct { // 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 + // Concluded is the key of the conclusion the run came to, one the tool + // declared, or nothing. + Concluded string // 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 @@ -189,6 +213,8 @@ type Descriptor struct { Columns []string // Notes are everything the tool may say besides the table. Notes []Note + // Conclusions are every answer the tool may come to. + Conclusions []Conclusion // Run does the work, on a request that has been checked and defaulted. Run func(ctx context.Context, in Request, progress Progress) (Result, error) } @@ -238,9 +264,50 @@ func unusable(d Descriptor) string { if problem := unusableNote(d.Notes); problem != "" { return problem } + if problem := unusableConclusion(d.Conclusions); problem != "" { + return problem + } return clashingName(d) } +// unusableConclusion is what is wrong with the conclusions a tool declares, or +// nothing. +func unusableConclusion(conclusions []Conclusion) string { + seen := map[string]bool{} + for _, c := range conclusions { + switch { + case c.ID == "" || c.Says == "": + return "declares a conclusion without a key or without a sentence" + case seen[c.ID]: + return "declares the conclusion " + c.ID + " twice" + } + seen[c.ID] = true + } + return "" +} + +// Conclusion is the conclusion of this tool with that key, if it declares one. +func (d Descriptor) Conclusion(id string) (Conclusion, bool) { + for _, c := range d.Conclusions { + if c.ID == id { + return c, true + } + } + return Conclusion{}, false +} + +// Failed says whether a result is the answer of a check that found something: +// a comparison that did not match, or a conclusion the tool declared failing. +// One question for both surfaces - the command line ends with VERIFY on it and +// the window colours it - so they cannot disagree about which runs failed. +func (d Descriptor) Failed(r Result) bool { + if r.Verdict.Outcome == Mismatch { + return true + } + c, ok := d.Conclusion(r.Concluded) + return ok && c.Fails +} + // unusableNote is what is wrong with the notes a tool declares, or nothing. func unusableNote(notes []Note) string { seen := map[string]bool{} @@ -379,5 +446,10 @@ func (d Descriptor) Start(ctx context.Context, in Request, progress Progress) (R return Result{}, core.Defect(fmt.Errorf("the tool %s said a note it never declared (%s). This is a fault in the build", d.ID, n.ID)) } } + // The same for a conclusion: one nobody declared has no words and no + // answer to which exit code it is. + if _, ok := d.Conclusion(result.Concluded); result.Concluded != "" && !ok { + return Result{}, core.Defect(fmt.Errorf("the tool %s came to a conclusion it never declared (%s). This is a fault in the build", d.ID, result.Concluded)) + } return result, nil }