From fd85b67208aab2ae82daa6f790a2c71c9ac8beba Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 1 Sep 2026 08:08:12 +0200 Subject: [PATCH 1/2] format: a log can be six shapes, and it knows what time it is Adds entry_format to the log format, with apache-combined staying the default, and six more settings beside it: timestamps, rate, methods, status_mix, ip_version and line_ending. The text group had no settings at all until now, so this is the first of them and the shape the rest will copy. Every template came from a real file rather than from a specification recalled, and two of them would have been wrong otherwise. A real nginx writes one more quoted field than "combined" does, because its default log_format ends with $http_x_forwarded_for. Apache's own default is common, with no referrer and no agent at all. A third thing, less obvious: a syslog FILE carries no priority in angle brackets - that belongs on the wire - and no line in the RFC 3164 style exists on this machine at all, so the ISO form is what a tester actually sees. Sources and samples are in docs/MVP-FORMATS.md section 5.1a. The clock advances now, which moves the bytes of every log and is listed under Breaking. timestamps=fixed reproduces the old file to the byte, and there is a pinned hash for it rather than a promise - the value log_8kib carried before this change is now log_8kib_the_way_back. A setting that could do nothing for the chosen shape is refused, naming both halves, rather than accepted and ignored. What "asked for" means took a correction, below. The structural checker is handed a format id and a path and never a recipe, so it takes the shape from the first entry and holds every other line to that one. Asking each line only to be valid on its own would pass a file that changed shape half way down. Five deliberate breakages, five caught: a truncated line, two shapes in one file, mixed endings, a missing final newline and an octet above 255. JSON lines is checked by Python's own json module, which gives this format its first reader that is not a regular expression of ours. Three defects found after the code was written, each worth its own note: A window could not produce syslog or JSON lines AT ALL. A menu cannot be empty - it opens on its declared default - so a window sends every setting it draws, and the first version refused a setting that could do nothing whenever the KEY arrived. The command line never showed it, because there an unset flag is an absent key, and every test written before the report had the command line's shape. A value equal to the default is not something anybody asked for, and it cannot disagree with the shape. The folded summary line listed the format's whole declaration rather than what somebody chose, for the same reason. Nobody noticed while formats declared one or two settings. With seven the line ran off the edge of the window. Boxes people type into are left as they were: there an empty box and a typed default really do differ. syslog missed its size in about one file in ten. The line counted its process id as four digits always, and it runs from 100 to 9998. Only the last entry is built to a length, so the miss needed that entry to draw a short pid. Measured before the repair: 35 files out of 360, and this project's own size guard could not see it because it asks each format with its settings left alone. Nine mutations, all caught, including one for each of those three. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 + CHANGELOG.md | 37 ++ internal/format/logfile/address.go | 159 ++++++++ internal/format/logfile/clock.go | 69 ++++ internal/format/logfile/log.go | 284 +++++++-------- internal/format/logfile/options.go | 241 +++++++++++++ internal/format/logfile/shapes.go | 338 +++++++++++++++++ internal/guard/foldedsummary_test.go | 85 +++++ internal/guard/generatorbytes_test.go | 22 ++ internal/guard/logshapes_test.go | 341 ++++++++++++++++++ internal/guard/parity_test.go | 7 + internal/guard/testdata/generator-golden.json | 23 +- internal/gui/parts/property.go | 21 ++ internal/gui/window/generate.go | 7 + internal/gui/window/recipefolds.go | 7 + internal/oracle/strict.py | 95 ++++- web/content/en/site.json | 7 +- web/content/pl/site.json | 7 +- web/public/formats/index.html | 35 ++ web/public/pl/formaty/index.html | 35 ++ 20 files changed, 1647 insertions(+), 179 deletions(-) create mode 100644 internal/format/logfile/address.go create mode 100644 internal/format/logfile/clock.go create mode 100644 internal/format/logfile/options.go create mode 100644 internal/format/logfile/shapes.go create mode 100644 internal/guard/foldedsummary_test.go create mode 100644 internal/guard/logshapes_test.go diff --git a/.gitignore b/.gitignore index 1700121..6563903 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,12 @@ files.jsonl /dist/ /build/ +# What the tool itself writes. The window proposes this directory by default, +# so running the program from a checkout fills it with generated files - and +# this is a file generator, so its own repository fills up faster than any +# other would. +/tfg-out/ + # --------------------------------------------------------------------------- # Test and coverage artifacts # --------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7e332..6e17810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ because it turns other people's test suites red. ### Breaking +- **A generated log now advances through time, so its bytes are different.** + Every entry used to carry the same instant. Ten thousand requests all landing + at one moment is not a log anybody can test a time window, a rate alert or a + rotation against, and it was obvious the moment you looked at a file. + + Entries are now one second apart by default, and `rate` sets how many arrive + a second. The bytes of every log change, so a suite pinning their hashes will + go red. + + **The way back is `--set timestamps=fixed`**, or `timestamps: fixed` on a + target in a recipe. That holds the clock still and writes the same bytes this + tool wrote before, to the byte - there is a pinned hash proving it. + - **A generated GIF now moves, so its bytes are different.** A GIF is the one picture format here that can hold more than one frame, and a still one told you nothing about how the system under test treats an animation - whether it @@ -34,6 +47,30 @@ because it turns other people's test suites red. ### Added +- **A log can now be six shapes rather than one, and seven settings shape it.** + `tfg generate --format log --set entry_format=nginx` writes an nginx access + log. The others are `apache-combined` (the default, and what this format has + always written), `apache-common`, `syslog`, `plain` and `json-lines`. + + Every template was taken from a real file rather than from a specification + remembered: a real nginx and a real Apache, and rsyslog on a real machine. Two + of them would have been wrong otherwise. An nginx line carries one more + quoted field than "combined" does, and Apache's own default is `common`, with + no referrer and no agent at all. + + The rest of the settings: `timestamps` and `rate` for the clock, `methods` for + which verbs appear, `status_mix` for which response codes, `ip_version` to put + IPv6 addresses in front of a reader that may not expect them, and + `line_ending` for a log written by a Windows service. + + **A setting that could not do anything is refused rather than ignored.** + Asking for `methods` beside `entry_format=syslog` is an error naming both, + because a syslog line carries no request - and a setting that silently does + nothing is worse than one that is not offered. + + Every shape still hits the size to the byte, and every line is still a whole + entry. `tfg formats log` lists all of it. + - **JPEG XL, the twenty fourth format.** One frame, 8 bit, RGB. `tfg generate --format jxl --size 300kb` writes a JPEG XL picture in the container the format defines for it. `width`, `height` and `quality` can be diff --git a/internal/format/logfile/address.go b/internal/format/logfile/address.go new file mode 100644 index 0000000..1e6edf6 --- /dev/null +++ b/internal/format/logfile/address.go @@ -0,0 +1,159 @@ +// Addresses, and the rule that keeps the way back exact. +// +// Every draw here is made in the same order and from the same ranges the +// generator used before entry formats existed. That is not tidiness: with +// timestamps=fixed and the settings left alone, the file has to come out byte +// for byte as it did, and a single extra call to the generator would shift +// every entry after it. +// +// Which is why pick does not draw when there is only one thing to choose. A +// list of one is not a choice, and asking for one costs a number out of the +// stream that the old code never spent. +package logfile + +import ( + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" + "strconv" +) + +// pick chooses one of a list, without spending a draw on a list of one. +func pick[T any](rng *rand.Rand, xs []T) T { + if len(xs) == 1 { + return xs[0] + } + return xs[rng.IntN(len(xs))] +} + +const ( + // v4Longest is 255.255.255.255 and v6Longest is eight groups of four hex + // digits with seven colons. Both are the worst case, which is what the + // minimum has to be built from. + v4Longest = 15 + v6Longest = 39 +) + +// address is one client address, drawn but not yet written. +// +// A value rather than a string, because a log of any size is millions of +// entries and one string per entry is a multiple of the file in garbage. The +// resource guard measures exactly that. +type address struct { + v6 bool + parts [8]uint32 +} + +func drawAddress(rng *rand.Rand, o options) address { + v6 := o.ipv6 + if o.ipMixed { + // One draw, so the stream stays predictable, and it is only ever + // reached when the settings asked for a mixture. + v6 = rng.IntN(2) == 1 + } + var a address + a.v6 = v6 + if !v6 { + // The same ranges, in the same order, as before entry formats + // existed. Nothing here may change or the way back stops being exact. + a.parts[0] = uint32(10 + rng.IntN(240)) + a.parts[1] = uint32(rng.IntN(256)) + a.parts[2] = uint32(rng.IntN(256)) + a.parts[3] = uint32(1 + rng.IntN(254)) + return a + } + // Documentation range, so a generated log never names somebody's real + // network. Groups are written the way a reader sees them, with leading + // zeros suppressed, which is why the length varies. + a.parts[0], a.parts[1] = 0x2001, 0x0db8 + for i := 2; i < 8; i++ { + a.parts[i] = uint32(rng.IntN(0x10000)) + } + return a +} + +// length is how many bytes this address takes when written. +func (a address) length() int { + if !a.v6 { + return decDigits(a.parts[0]) + decDigits(a.parts[1]) + + decDigits(a.parts[2]) + decDigits(a.parts[3]) + 3 + } + n := 7 // the colons + for _, p := range a.parts { + n += hexDigits(p) + } + return n +} + +func (a address) append(dst []byte) []byte { + if !a.v6 { + // No leading zeros. Padding octets to three digits made the line + // length trivial to predict and produced addresses no real log + // contains - and a leading zero is read as octal by some address + // parsers, where 069 is not even valid octal. + for i := 0; i < 4; i++ { + if i > 0 { + dst = append(dst, '.') + } + dst = strconv.AppendInt(dst, int64(a.parts[i]), 10) + } + return dst + } + for i, p := range a.parts { + if i > 0 { + dst = append(dst, ':') + } + dst = strconv.AppendInt(dst, int64(p), 16) + } + return dst +} + +// decDigits is how many characters a byte sized number takes. +func decDigits(n uint32) int { + switch { + case n < 10: + return 1 + case n < 100: + return 2 + default: + return 3 + } +} + +// hexDigits is how many characters a sixteen bit group takes in hex, written +// without leading zeros the way every reader shows it. +func hexDigits(n uint32) int { + switch { + case n < 0x10: + return 1 + case n < 0x100: + return 2 + case n < 0x1000: + return 3 + default: + return 4 + } +} + +// longestAddress is the worst case for the settings in force, which is what +// the minimum entry has to leave room for. +func longestAddress(o options) int { + if o.ipv6 || o.ipMixed { + return v6Longest + } + return v4Longest +} + +func longestMethod(o options) int { return longest(o.methods) } +func longestAgent() int { return longest(agents) } +func longestTag() int { return longest(tags) } +func longestLevel() int { return longest(levels) } + +func longest(xs []string) int { + n := 0 + for _, x := range xs { + if len(x) > n { + n = len(x) + } + } + return n +} diff --git a/internal/format/logfile/clock.go b/internal/format/logfile/clock.go new file mode 100644 index 0000000..a2b09ee --- /dev/null +++ b/internal/format/logfile/clock.go @@ -0,0 +1,69 @@ +// The clock: what time each entry says it happened. +// +// Until 2026-08-31 every entry in every log this tool wrote carried the same +// instant, because the timestamp was a constant. That is a fidelity defect +// rather than a missing setting, and docs/BACKLOG.md said so: a log where ten +// thousand requests happen at one instant cannot be used to test a time window +// query, a rate alert, or anything that rotates. +// +// It advances now, and that moves the bytes of every log, so `timestamps=fixed` +// is kept as the way back and reproduces the old file exactly. Same shape as +// `frames=1` for the still GIF, and there is a pinned hash for it too. +package logfile + +import "time" + +// epoch is where every log starts. A constant, because D11 promises the same +// bytes from the same seed and time.Now() would promise the opposite. +// +// It is the instant the fixed timestamp used to carry, so a run with +// timestamps=fixed writes the bytes this tool wrote before this file existed. +var epoch = time.Date(2026, time.August, 1, 12, 0, 0, 0, time.UTC) + +const ( + // apacheTime is the layout the Apache family and nginx write, and + // isoTime is what rsyslog and application logs write. Both are fixed + // width for every instant with a four digit year, which is what lets an + // entry's length be known before it is built. + // + // Measured on a real nginx and a real rsyslog on 2026-08-31 rather than + // recalled - see docs/MVP-FORMATS.md. + apacheTime = "02/Jan/2006:15:04:05 -0700" + isoTime = "2006-01-02T15:04:05.000000-07:00" +) + +// clock hands out the instant for each entry in turn. +// +// It counts, so it is one of the builders core.Record.Discard exists for: the +// filler builds one entry past the end to measure it and throws it away, and +// without a way back the file would skip a tick. csv counts rows the same way. +type clock struct { + // step is how far the clock moves between entries. Zero holds it still, + // which is what timestamps=fixed asks for. + step time.Duration + at time.Time +} + +func newClock(o options) clock { + c := clock{at: epoch} + if o.advancing { + // Entries per second into a gap between entries. Integer division + // rounds towards zero, so a rate above one second per entry still + // moves - a step of nought would silently reproduce fixed. + c.step = time.Second / time.Duration(o.rate) + if c.step <= 0 { + c.step = time.Nanosecond + } + } + return c +} + +// tick returns the instant for this entry and moves on. +func (c *clock) tick() time.Time { + at := c.at + c.at = c.at.Add(c.step) + return at +} + +// back undoes one tick, for the entry that was built only to measure it. +func (c *clock) back() { c.at = c.at.Add(-c.step) } diff --git a/internal/format/logfile/log.go b/internal/format/logfile/log.go index 072ab83..df122ad 100644 --- a/internal/format/logfile/log.go +++ b/internal/format/logfile/log.go @@ -1,7 +1,12 @@ -// Package logfile generates access log files. +// Package logfile generates log files. // // The package is not called "log" so that it cannot be confused with the // standard library package of that name at a glance. The format id is "log". +// +// Six shapes since 2026-08-31, where there was one before: the Apache family, +// nginx, syslog, a plain application log and JSON lines. Every template was +// taken from a real file rather than from memory - see shapes.go, which says +// which two of them memory would have got wrong. package logfile import ( @@ -27,32 +32,17 @@ import ( // half entry that a parser rejects - and "the last line is truncated" is // exactly what a real log looks like mid rotation, so the failure would read // as realism rather than as a defect. Instead the last entry is built to the -// byte, with the request path taking up the difference. Every line parses. -// -// Same shape as the CSV finding on 2026-08-01: padding goes where the format -// has room for a long value, never into a truncated record. +// byte, with one stretchable field per shape taking up the difference. Every +// line parses. const ( generatorVersion = "1" - // Every field except the path is fixed width, so the length of an entry - // is known before it is built and the path absorbs the difference. - timestamp = "01/Aug/2026:12:00:00 +0000" - - // statusWidth and sizeWidth are why the ranges below are picked - a status - // is always three digits and a byte count always six, so neither changes - // the length of a line. + // statusWidth and sizeWidth are why the ranges in shapes.go are picked - a + // status is always three digits and a byte count always six, so neither + // changes the length of a line. statusWidth = 3 sizeWidth = 6 - - // longestAddress is 255.255.255.255. Used for the minimum, which has to - // hold for every draw rather than for the lucky one. - longestAddress = 15 - - // fixedWidth is every byte of a line except the address, the path and the - // user agent. A constant expression, so it costs nothing at run time. - fixedWidth = len(" - - [") + len(timestamp) + len("] \"GET /") + - len(" HTTP/1.1\" ") + statusWidth + len(" ") + sizeWidth + len(" \"-\" \"") + len("\"\n") ) func init() { @@ -65,39 +55,91 @@ func init() { // Unlike text, a log file of nought bytes holds no entries and a log // with no entries is not a fixture anybody asked for. The minimum is // one whole entry, and asking for less is refused with the number. + // + // The number announced is the DEFAULT shape's, because a guard holds + // this tool to accepting whatever minimum it prints. A shape that + // needs more raises the floor when it is chosen, and says its own + // number then - the same way a picture size named by hand does. MinBytes: minimumBytes(), Padding: format.PaddingChannel{ - Name: "the request path of the last entry", + Name: "the request path or message of the last entry", Where: format.PlacementEnd, Capacity: 0, }, - Label: format.LabelVisible, - Oracle: format.OracleNone, - // Entry format, rate, time range and level mix come later. Declaring - // none now makes a recipe asking for them fail loudly. - Properties: nil, + Label: format.LabelVisible, + Oracle: format.OracleNone, + Properties: properties(), + GeneratorVersion: generatorVersion, Generator: generator{}, }) } +func properties() []format.Property { + return []format.Property{ + { + Name: "entry_format", Kind: format.PropertyChoice, + Choices: shapeIDs, Default: defaultShape, + Detail: "Which kind of log to write. Web server shapes carry a request and a status, the others carry a level and a message.", + }, + { + Name: "timestamps", Kind: format.PropertyChoice, + Choices: []string{"advancing", "fixed"}, Default: "advancing", + Detail: "Whether each entry happens later than the one before it. Fixed puts every entry at the same instant, which is what this format did before it could advance.", + }, + { + Name: "rate", Kind: format.PropertyInt, + Min: minRate, Max: maxRate, Unit: "entries per second", + Default: strconv.Itoa(defaultRate), + Detail: "How fast the entries arrive. Only means anything while timestamps advance.", + }, + { + Name: "methods", Kind: format.PropertyChoice, + Choices: []string{"get", "read", "mixed"}, Default: "get", + Detail: "Which request methods appear. Read is GET and HEAD, mixed adds POST, PUT, PATCH and DELETE.", + }, + { + Name: "status_mix", Kind: format.PropertyChoice, + Choices: []string{"realistic", "success", "client-errors", "server-errors"}, Default: "realistic", + Detail: "Which response codes appear. Realistic is mostly success with a tail of errors.", + }, + { + Name: "ip_version", Kind: format.PropertyChoice, + Choices: []string{"v4", "v6", "mixed"}, Default: "v4", + Detail: "Which kind of client address appears. Choose v6 to find out whether a reader handles it.", + }, + { + Name: "line_ending", Kind: format.PropertyChoice, + Choices: []string{"lf", "crlf"}, Default: "lf", + Detail: "How each line ends. Choose crlf for a log written by a Windows service.", + }, + } +} + type generator struct{} type memo struct { - labelLine string // includes the trailing newline, empty when absent + labelLine string // includes the terminator, empty when absent seed uint64 + opt options } func (generator) Plan(r format.Request) (format.Plan, error) { - min := minimumBytes() + opt, err := parseOptions(r.Properties) + if err != nil { + return format.Plan{}, err + } + + min := opt.shape.shortest(opt) if r.Bytes < min { return format.Plan{}, &format.BelowMinimumError{ Format: "LOG", Requested: r.Bytes, Minimum: min, - Reason: "a log holds whole entries and one entry in the combined format needs that much", - Hint: fmt.Sprintf("Ask for %d B or more.", min), + Reason: fmt.Sprintf( + "a log holds whole entries and one entry in the %s shape needs that much", opt.shape.id), + Hint: fmt.Sprintf("Ask for %d B or more, or choose a shorter entry_format.", min), } } @@ -105,22 +147,39 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Bytes: r.Bytes, Exact: true, Determinism: format.DeterminismByte, + // Only what is true OF THIS SHAPE. A syslog line carries no request, + // so recording methods beside it would be the manifest stating a fact + // about the file that is not one - and the manifest is the half of this + // tool a test suite reads rather than a person, so a value nobody can + // see in the file is worse there than anywhere. Properties: map[string]any{ "encoding": "utf-8", - "line_ending": "lf", - "entry_format": "apache-combined", + "line_ending": opt.lineEnding, + "entry_format": opt.shape.id, + "timestamps": opt.timestamps, }, } + if opt.advancing { + p.Properties["rate"] = opt.rate + } + if opt.shape.web { + p.Properties["methods"] = opt.methodMix + p.Properties["ip_version"] = opt.ipVersion + } + if opt.shape.web || opt.shape.id == "json-lines" { + p.Properties["status_mix"] = opt.statusMix + } - m := memo{seed: r.Seed} + m := memo{seed: r.Seed, opt: opt} if r.Label { // A log has no comment syntax that every reader agrees on, so the // label is a line of its own. It is the one line that is not an entry, - // and it says so in words rather than pretending to be one. - line := "# " + core.Label("log", r.Bytes, r.Seed) + "\n" + // and it says so in words rather than pretending to be one - except in + // JSON lines, where a comment is not a line any reader would take. + line := opt.shape.label(core.Label("log", r.Bytes, r.Seed), opt) // It has to leave room for at least one whole entry, or the file would // be a label and nothing else. - if int64(len(line))+minEntry() <= r.Bytes { + if int64(len(line))+min <= r.Bytes { m.labelLine = line } else { p.Notes = append(p.Notes, format.Note{ @@ -156,132 +215,37 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { // for every record based format here, so it lives in core rather than // being written out a fourth time. rng := core.NewRand(m.seed) - return core.FillRecords(ctx, w, rng, remaining, entries{}) + rec := &entries{st: state{rng: rng, clock: newClock(m.opt), opt: m.opt}} + return core.FillRecords(ctx, w, rng, remaining, rec) } // entries is the log seen as a stream of records. -type entries struct{} +type entries struct{ st state } -func (entries) Shortest() int64 { return minEntry() } +func (e *entries) Shortest() int64 { return e.st.opt.shape.shortest(e.st.opt) } -func (entries) Append(dst []byte, rng *rand.Rand) []byte { - return appendEntry(dst, rng, -1) +func (e *entries) Append(dst []byte, _ *rand.Rand) []byte { + return e.st.opt.shape.appendTo(dst, &e.st, -1) } -func (entries) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte { - return appendEntry(dst, rng, n) +func (e *entries) AppendExact(dst []byte, _ *rand.Rand, n int64) []byte { + return e.st.opt.shape.appendTo(dst, &e.st, n) } -// Discard has nothing to put back. An entry carries no state from one to the -// next, so throwing one away leaves no trace to undo. -func (entries) Discard() {} - -// appendEntry appends one line in the Apache combined format. -// -// want below zero means "whatever length it comes out". Any other value is the exact -// length the line must have, newline included, and the request path is -// stretched to reach it. -// -// It appends rather than returning a new slice because a log of any size is -// millions of entries, and one allocation per entry is a multiple of the file -// in garbage. The resource guard measures that. -func appendEntry(dst []byte, rng *rand.Rand, want int64) []byte { - // Every field but the path is fixed width or drawn from a list, so the - // length of the line is known before the path is chosen. - a, b, c, d := 10+rng.IntN(240), rng.IntN(256), rng.IntN(256), 1+rng.IntN(254) - status := statuses[rng.IntN(len(statuses))] - size := 100000 + rng.IntN(899999) - agent := agents[rng.IntN(len(agents))] - path := paths[rng.IntN(len(paths))] - - // The length of everything but the path, as arithmetic rather than by - // building a string and measuring it - that allocated once per entry, - // about the size of the file again in garbage over a large log. - base := int64(fixedWidth + len(agent) + digits(a) + digits(b) + digits(c) + digits(d) + 3) - - // No leading zeros. Padding octets to three digits made the line length - // trivial to predict and produced addresses no real log contains - and a - // leading zero is read as octal by some address parsers, where 069 is not - // even valid octal. Untouchable rule 4: fidelity does not drop for the - // convenience of the implementation. - dst = strconv.AppendInt(dst, int64(a), 10) - dst = append(dst, '.') - dst = strconv.AppendInt(dst, int64(b), 10) - dst = append(dst, '.') - dst = strconv.AppendInt(dst, int64(c), 10) - dst = append(dst, '.') - dst = strconv.AppendInt(dst, int64(d), 10) - dst = append(dst, " - - ["...) - dst = append(dst, timestamp...) - dst = append(dst, "] \"GET /"...) - - if want < 0 { - dst = append(dst, path...) - } else { - dst = appendPath(dst, want-base) - } - - dst = append(dst, " HTTP/1.1\" "...) - dst = strconv.AppendInt(dst, int64(status), 10) - dst = append(dst, ' ') - dst = strconv.AppendInt(dst, int64(size), 10) - dst = append(dst, " \"-\" \""...) - dst = append(dst, agent...) - return append(dst, "\"\n"...) -} - -// digits is how many characters a byte sized number takes. The address is -// written the way a person sees it, so the length varies and the path has to -// know by how much. -func digits(n int) int { - switch { - case n < 10: - return 1 - case n < 100: - return 2 - default: - return 3 - } -} - -// appendPath writes a URL path of exactly n bytes out of readable segments, so -// a padded entry still looks like a request rather than a run of one letter. -func appendPath(dst []byte, n int64) []byte { - if n < 1 { - // Only reachable if the caller ignored the minimum. The check in Write - // turns that into an error rather than a file of the wrong size. - return append(dst, 'x') - } - // One word and a slash between the repeats, so the padding reads as a path - // rather than as prose. The vocabulary is a literal here because a URL path - // is ASCII by definition, but it goes through the shared filler all the - // same - one copy of "cut to the byte" rather than six. - return core.AppendFiller(dst, pathFiller, n, func(int) string { return "/" }) -} - -var pathFiller = []string{"segment"} - -// minEntry is the length of the shortest line this generator can produce, and -// minimumBytes is the smallest file - one whole entry. -// -// Computed rather than written down, so it cannot drift away from the template -// above the way a number in a document would. -func minEntry() int64 { - // The longest agent, because any entry may draw it and the minimum has to - // hold for every draw rather than for the lucky one. - longest := 0 - for _, a := range agents { - if len(a) > longest { - longest = len(a) - } - } - // The longest address too, for the same reason, plus one character of - // path - the shortest a path can be. - return int64(fixedWidth + longestAddress + longest + 1) +// Discard puts the clock back. The filler builds one entry past the end to +// measure it and throws it away, and without this the file would skip a tick - +// which nothing else here could see, because the size stays exact and every +// line still parses. csv counts rows and puts them back for the same reason. +func (e *entries) Discard() { e.st.clock.back() } + +// minimumBytes is the floor the registry announces: the default shape with +// nothing set. Computed rather than written down, so it cannot drift away from +// the templates the way a number in a document would. +func minimumBytes() int64 { + o := defaultOptions() + return o.shape.shortest(o) } -func minimumBytes() int64 { return minEntry() } - var statuses = []int{200, 200, 200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 409, 429, 500, 502, 503} var paths = []string{ @@ -298,3 +262,19 @@ var agents = []string{ "python-requests/2.32.3", "Go-http-client/2.0", } + +// syslogHost and tags are what the message shapes say produced the line. Taken +// from the shape of a real rsyslog file rather than invented. +const syslogHost = "app-01" + +// The range a process id is drawn from. Named rather than written into +// the draw, because the widest of them is what the minimum entry has to +// leave room for and the two must not drift apart. +const ( + minPid = 100 + maxPid = 9998 +) + +var tags = []string{"sshd", "cron", "systemd", "kernel", "nginx", "dockerd"} + +var levels = []string{"INFO", "INFO", "INFO", "WARN", "ERROR", "DEBUG"} diff --git a/internal/format/logfile/options.go b/internal/format/logfile/options.go new file mode 100644 index 0000000..32b09f3 --- /dev/null +++ b/internal/format/logfile/options.go @@ -0,0 +1,241 @@ +// The settings, and the combinations this format refuses. +// +// Most of these only mean something for some shapes: a request method has no +// place in a syslog line, and a rate has none while the clock is held still. +// Accepting them there and quietly doing nothing is the failure rule 6 exists +// against - the recipe would say one thing, the file would be another, and +// nothing would say so. They are refused instead, by a message naming both +// settings, which is how a pair of settings that disagree is already reported +// when contains sits beside an archive's own properties. +package logfile + +import ( + "fmt" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +const ( + defaultShape = "apache-combined" + + minRate = 1 + maxRate = 1_000_000 + // One a second, so the clock moves on EVERY line rather than every + // tenth. The defect this default exists to fix was noticed by looking + // at a file, so a default that only advances after ten entries would + // leave a small log looking exactly as wrong as it did before. + defaultRate = 1 +) + +// options is every setting for one target, already checked and turned into +// what the shapes actually use. +type options struct { + shape *shape + + lineEnding string + eol string + + timestamps string + advancing bool + rate int + + methodMix string + methods []string + + statusMix string + statuses []int + + ipVersion string + ipv6 bool + ipMixed bool +} + +func defaultOptions() options { + o := options{ + shape: shapes[defaultShape], + lineEnding: "lf", + eol: "\n", + timestamps: "advancing", + advancing: true, + rate: defaultRate, + methodMix: "get", + methods: methodSets["get"], + statusMix: "realistic", + statuses: statusSets["realistic"], + ipVersion: "v4", + } + return o +} + +var methodSets = map[string][]string{ + "get": {"GET"}, + "read": {"GET", "HEAD"}, + "mixed": {"GET", "GET", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}, +} + +var statusSets = map[string][]int{ + // realistic is the mix this format has always written: mostly success, + // with a tail of the codes a real service actually returns. + "realistic": statuses, + "success": {200, 200, 201, 204, 206}, + "client-errors": {400, 401, 403, 404, 409, 410, 422, 429}, + "server-errors": {500, 500, 502, 503, 504}, +} + +// parseOptions reads the settings and refuses the pairs that disagree. +func parseOptions(props map[string]string) (options, error) { + o := defaultOptions() + + if v, ok := value(props, "entry_format"); ok { + s, known := shapes[v] + if !known { + return options{}, badValue("entry_format", v, "it is not one of the shapes this format writes") + } + o.shape = s + } + + if v, ok := value(props, "line_ending"); ok { + switch v { + case "lf": + o.eol = "\n" + case "crlf": + o.eol = "\r\n" + default: + return options{}, badValue("line_ending", v, "it has to be lf or crlf") + } + o.lineEnding = v + } + + if v, ok := value(props, "timestamps"); ok { + switch v { + case "advancing", "fixed": + default: + return options{}, badValue("timestamps", v, "it has to be advancing or fixed") + } + o.timestamps = v + o.advancing = v == "advancing" + } + + if v, ok := value(props, "rate"); ok { + n, err := strconv.Atoi(v) + if err != nil { + return options{}, fmt.Errorf("log: rate must be a whole number of entries per second, got %q", v) + } + if n < minRate || n > maxRate { + return options{}, fmt.Errorf("log: rate must be between %d and %d entries per second, got %d", minRate, maxRate, n) + } + // A rate somebody CHOSE while the clock is held still would change + // nothing at all, so it is said out loud rather than ignored. A rate + // sitting at its default was not chosen - see asked. + if _, chosen := asked(props, "rate", strconv.Itoa(defaultRate)); chosen && !o.advancing { + return options{}, conflict("rate and timestamps", v, + "a rate says how fast entries arrive and timestamps=fixed puts them all at one instant") + } + o.rate = n + } + + if v, ok := value(props, "methods"); ok { + set, known := methodSets[v] + if !known { + return options{}, badValue("methods", v, "it has to be get, read or mixed") + } + if _, chosen := asked(props, "methods", "get"); chosen { + if err := needsWeb(o.shape, "methods", v, "request method"); err != nil { + return options{}, err + } + } + o.methodMix, o.methods = v, set + } + + if v, ok := value(props, "status_mix"); ok { + set, known := statusSets[v] + if !known { + return options{}, badValue("status_mix", v, "it has to be realistic, success, client-errors or server-errors") + } + // JSON lines carry a status of their own, so this one applies there + // too - it is the address and the method that have no place outside a + // web shape. + if _, chosen := asked(props, "status_mix", "realistic"); chosen && + !o.shape.web && o.shape.id != "json-lines" { + return options{}, conflict("status_mix and entry_format", v, + "the "+o.shape.id+" shape carries no response code") + } + o.statusMix, o.statuses = v, set + } + + if v, ok := value(props, "ip_version"); ok { + switch v { + case "v4": + case "v6": + o.ipv6 = true + case "mixed": + o.ipMixed = true + default: + return options{}, badValue("ip_version", v, "it has to be v4, v6 or mixed") + } + if _, chosen := asked(props, "ip_version", "v4"); chosen { + if err := needsWeb(o.shape, "ip_version", v, "client address"); err != nil { + return options{}, err + } + } + o.ipVersion = v + } + + return o, nil +} + +// needsWeb refuses a setting that only means something for a shape carrying a +// request. +func needsWeb(s *shape, key, val, what string) error { + if s.web { + return nil + } + return conflict(key+" and entry_format", val, "the "+s.id+" shape carries no "+what) +} + +func value(props map[string]string, key string) (string, bool) { + v, ok := props[key] + if !ok || v == "" { + return "", false + } + return v, true +} + +// asked says whether somebody actually asked for this, which is not the same +// question as whether the key arrived. +// +// A window sends every setting it draws, and a menu always carries a value +// because it opens on its declared default - measured and written down in +// internal/gui/parts/property.go on 2026-08-27, and safe there because for a +// format setting a default present and a default absent mean the same thing. +// +// The first version of this file broke that. It refused a setting that could +// do nothing for the chosen shape whenever the KEY was present, so the window, +// which always sends methods, could not reach syslog or JSON lines at all - +// every attempt came back refused for a setting the person had never touched. +// The command line never showed it, because there an unset flag is an absent +// key. Reported from a screenshot on 2026-08-31. +// +// So a value equal to the default was not stated, and only a real choice can +// disagree with the shape. +func asked(props map[string]string, key, def string) (string, bool) { + v, ok := value(props, key) + if !ok || v == def { + return v, false + } + return v, true +} + +func badValue(key, val, why string) error { + return &format.PropertyValueError{Format: "log", Key: key, Value: val, Reason: why} +} + +// conflict names both settings, because naming one of a pair leaves the reader +// to guess which of the two to change. +func conflict(keys, val, why string) error { + return &format.PropertyValueError{ + Format: "log", Key: keys, Value: val, + Reason: why + ". Drop one of the two, or change the other", + } +} diff --git a/internal/format/logfile/shapes.go b/internal/format/logfile/shapes.go new file mode 100644 index 0000000..dbd07d8 --- /dev/null +++ b/internal/format/logfile/shapes.go @@ -0,0 +1,338 @@ +// The shapes: the ways a log line can be written. +// +// Every one of these was taken from a real file on 2026-08-31 rather than from +// memory, which is the rule this project has for claims about the world outside +// the repository. A real nginx and a real Apache in containers, and rsyslog on +// this machine. The measurements and the samples are in +// docs/MVP-FORMATS.md section 5.1. +// +// Two of them corrected what would otherwise have been written from memory: +// nginx's default log_format ends with $http_x_forwarded_for, so a real nginx +// line carries one more quoted field than "combined" does, and the Apache +// image's default is common rather than combined - no referrer and no agent. +// +// Every shape has to answer the same two questions, because the exact size +// promise is built on them: what is the fewest bytes a line can be for ANY +// draw, and where does the difference go when a line has to hit a length. The +// answer to the second is one stretchable field per shape - a request path for +// the web shapes, a message for the rest. +package logfile + +import ( + // D11 promises the same bytes from the same seed, so a deliberate, + // reproducible generator is the product rather than a weakness. + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// shape is one way a log line can be written. +// +// Function fields rather than six types implementing an interface, because +// what differs between them is a layout and a minimum, not behaviour. +type shape struct { + id string + // web says whether this shape carries a request, which is what decides + // if the method, status and address settings mean anything for it. A + // setting that would do nothing is refused rather than ignored. + web bool + // appendTo writes one entry. want below zero means whatever length it + // comes out. Any other value is the exact length the line must have, its + // terminator included, and the stretchable field reaches it. + appendTo func(dst []byte, st *state, want int64) []byte + // shortest is the fewest bytes appendTo can produce for ANY draw, which + // has to hold for the unluckiest one or the closing entry cannot reach + // the length it was asked for. + shortest func(o options) int64 + // label wraps the self describing text as a line this shape's readers + // accept. Every line has to be a whole record, so JSON lines cannot take + // a hash comment and gets an object instead. + label func(text string, o options) string +} + +// state is everything one entry needs. Held across entries so the clock keeps +// its place and nothing is allocated per line. +type state struct { + rng *rand.Rand + clock clock + opt options +} + +var shapes = map[string]*shape{ + "apache-common": {id: "apache-common", web: true, appendTo: appendApacheCommon, shortest: shortestApacheCommon, label: hashLabel}, + "apache-combined": {id: "apache-combined", web: true, appendTo: appendApacheCombined, shortest: shortestApacheCombined, label: hashLabel}, + "nginx": {id: "nginx", web: true, appendTo: appendNginx, shortest: shortestNginx, label: hashLabel}, + "syslog": {id: "syslog", web: false, appendTo: appendSyslog, shortest: shortestSyslog, label: hashLabel}, + "plain": {id: "plain", web: false, appendTo: appendPlain, shortest: shortestPlain, label: hashLabel}, + "json-lines": {id: "json-lines", web: false, appendTo: appendJSONLine, shortest: shortestJSONLine, label: jsonLabel}, +} + +// shapeIDs is the closed set the registry offers, in one order so that every +// surface lists them the same way. +var shapeIDs = []string{"apache-combined", "apache-common", "nginx", "syslog", "plain", "json-lines"} + +// hashLabel is the label line for every shape whose readers treat a leading +// hash as a comment. It says in words that it is not an entry. +func hashLabel(text string, o options) string { return "# " + text + o.eol } + +// jsonLabel is the label for JSON lines, where every line must be a whole +// object. A hash comment would be the one unparseable line in the file. +func jsonLabel(text string, o options) string { + return `{"label":"` + text + `"}` + o.eol +} + +// --- the web shapes ------------------------------------------------------- +// +// A real Apache common line, captured 2026-08-31: +// +// 192.0.2.10 - - [31/Aug/2026:18:44:46 +0000] "GET /index.html HTTP/1.1" 200 191 +// +// The address is the only part of that line not as captured. The real one was +// the container bridge address, and this repository's own guard against +// publishing private content refuses those - correctly, and it caught this one +// rather than a reader doing so. Swapped for the documentation range, which +// changes nothing about the shape being shown. +// +// combined adds the referrer and the agent, and nginx adds one more quoted +// field after those - $http_x_forwarded_for, which its default format carries +// and plain "combined" does not. + +func appendApacheCommon(dst []byte, st *state, want int64) []byte { + return appendWeb(dst, st, want, webTail{}) +} + +func appendApacheCombined(dst []byte, st *state, want int64) []byte { + return appendWeb(dst, st, want, webTail{referrer: true, agent: true}) +} + +func appendNginx(dst []byte, st *state, want int64) []byte { + return appendWeb(dst, st, want, webTail{referrer: true, agent: true, forwarded: true}) +} + +// webTail says which quoted fields follow the status and the byte count. +type webTail struct{ referrer, agent, forwarded bool } + +// width is what the tail costs for an agent of a given length. +func (t webTail) width(agentLen int) int64 { + var n int64 + if t.referrer { + n += int64(len(` "-"`)) + } + if t.agent { + n += int64(len(` ""`) + agentLen) + } + if t.forwarded { + n += int64(len(` "-"`)) + } + return n +} + +// longest is the tail at its most expensive, for the minimum. +func (t webTail) longest() int64 { return t.width(longestAgent()) } + +func appendWeb(dst []byte, st *state, want int64, tail webTail) []byte { + addr := drawAddress(st.rng, st.opt) + // The order of these draws is the order the generator used before entry + // formats existed, and it has to stay that way: timestamps=fixed has to + // reproduce the old file exactly, and one extra draw would shift every + // entry after it. pick spends nothing on a list of one. + method := pick(st.rng, st.opt.methods) + status := pick(st.rng, st.opt.statuses) + size := 100000 + st.rng.IntN(899999) + agent := pick(st.rng, agents) + path := pick(st.rng, paths) + at := st.clock.tick() + + // Everything but the path, as arithmetic rather than by building the line + // and measuring it. Building it allocated once per entry, which over a + // large log is the size of the file again in garbage. + base := int64(len(" - - [")+len(apacheTime)+len(`] "`)+len(method)+len(" /")+ + len(` HTTP/1.1" `)+statusWidth+len(" ")+sizeWidth) + + int64(addr.length()) + tail.width(len(agent)) + int64(len(st.opt.eol)) + + dst = addr.append(dst) + dst = append(dst, " - - ["...) + dst = at.AppendFormat(dst, apacheTime) + dst = append(dst, `] "`...) + dst = append(dst, method...) + dst = append(dst, " /"...) + if want < 0 { + dst = append(dst, path...) + } else { + dst = appendPath(dst, want-base) + } + dst = append(dst, ` HTTP/1.1" `...) + dst = strconv.AppendInt(dst, int64(status), 10) + dst = append(dst, ' ') + dst = strconv.AppendInt(dst, int64(size), 10) + if tail.referrer { + dst = append(dst, ` "-"`...) + } + if tail.agent { + dst = append(dst, ` "`...) + dst = append(dst, agent...) + dst = append(dst, '"') + } + if tail.forwarded { + dst = append(dst, ` "-"`...) + } + return append(dst, st.opt.eol...) +} + +// shortestWeb is the fewest bytes a web line can be, and every part of it is +// the WORST case rather than a typical one: the longest address the settings +// can draw, the longest method, the longest agent, and one character of path. +func shortestWeb(o options, tail webTail) int64 { + return int64(len(" - - [")+len(apacheTime)+len(`] "`)+longestMethod(o)+len(" /")+ + len(` HTTP/1.1" `)+statusWidth+len(" ")+sizeWidth) + + int64(longestAddress(o)) + tail.longest() + int64(len(o.eol)) + 1 +} + +func shortestApacheCommon(o options) int64 { return shortestWeb(o, webTail{}) } +func shortestApacheCombined(o options) int64 { + return shortestWeb(o, webTail{referrer: true, agent: true}) +} +func shortestNginx(o options) int64 { + return shortestWeb(o, webTail{referrer: true, agent: true, forwarded: true}) +} + +// --- the message shapes --------------------------------------------------- +// +// A real rsyslog line from this machine, captured 2026-08-31: +// +// 2026-08-31T20:35:01.021105+02:00 Mainnn CRON[3693]: pam_unix(cron:session): session closed for user root +// +// No priority in angle brackets: that belongs on the wire, not in the file, +// and no line in any file on this machine carries one. Nor is there an +// RFC 3164 style "Aug 31 20:35:01" line anywhere here - modern rsyslog writes +// the ISO form, so that is the one a tester's own machine produces. + +func appendSyslog(dst []byte, st *state, want int64) []byte { + tag := pick(st.rng, tags) + pid := minPid + st.rng.IntN(maxPid-minPid+1) + at := st.clock.tick() + + // The pid's own width, not the widest one it could have been. It runs + // from 100 to 9998, so it is three digits about one time in ten - and + // assuming four made the closing entry a byte short exactly that often. + // Measured after the fact: 35 files out of 360 missed their size, and + // only this shape, because only the LAST entry is built to a length. + base := int64(len(isoTime)+1+len(syslogHost)+1+len(tag)+1+pidWidth(pid)+len("]: ")) + int64(len(st.opt.eol)) + + dst = at.AppendFormat(dst, isoTime) + dst = append(dst, ' ') + dst = append(dst, syslogHost...) + dst = append(dst, ' ') + dst = append(dst, tag...) + dst = append(dst, '[') + dst = strconv.AppendInt(dst, int64(pid), 10) + dst = append(dst, "]: "...) + return appendMessage(dst, st, want, base) +} + +// The widest pid here, because the shortest line has to hold for the +// unluckiest draw rather than the lucky one. +func shortestSyslog(o options) int64 { + return int64(len(isoTime)+1+len(syslogHost)+1+longestTag()+1+pidWidth(maxPid)+len("]: ")) + + int64(len(o.eol)) + 1 +} + +// pidWidth is how many characters a process id takes. Written out rather +// than measured with strconv, because a log of any size is millions of +// entries and formatting one to count it allocates on every line. +func pidWidth(pid int) int { + if pid < 1000 { + return 3 + } + return 4 +} + +// plain is an application log: an instant, a level, and a sentence. The shape +// most home grown loggers write and the one with the least agreement about it, +// so this is the plainest reading of it. +func appendPlain(dst []byte, st *state, want int64) []byte { + level := pick(st.rng, levels) + at := st.clock.tick() + + base := int64(len(isoTime)+1+len(level)+1) + int64(len(st.opt.eol)) + + dst = at.AppendFormat(dst, isoTime) + dst = append(dst, ' ') + dst = append(dst, level...) + dst = append(dst, ' ') + return appendMessage(dst, st, want, base) +} + +func shortestPlain(o options) int64 { + return int64(len(isoTime)+1+longestLevel()+1) + int64(len(o.eol)) + 1 +} + +// JSON lines: one object a line, which is what makes it the one shape here +// with a reader that is not a regular expression. The structural checker hands +// each line to Python's own json module. +// +// The message is the stretchable field and the filler is ASCII words, so +// nothing in it ever needs escaping - which matters, because an escape would +// make the line longer than the arithmetic said. +func appendJSONLine(dst []byte, st *state, want int64) []byte { + level := pick(st.rng, levels) + status := pick(st.rng, st.opt.statuses) + at := st.clock.tick() + + base := int64(len(`{"time":"","level":"","status":,"msg":""}`)+ + len(isoTime)+len(level)+statusWidth) + int64(len(st.opt.eol)) + + dst = append(dst, `{"time":"`...) + dst = at.AppendFormat(dst, isoTime) + dst = append(dst, `","level":"`...) + dst = append(dst, level...) + dst = append(dst, `","status":`...) + dst = strconv.AppendInt(dst, int64(status), 10) + dst = append(dst, `,"msg":"`...) + if want < 0 { + dst = core.AppendFiller(dst, messageWords, int64(12+st.rng.IntN(40)), nil) + } else { + dst = core.AppendFiller(dst, messageWords, want-base, nil) + } + dst = append(dst, `"}`...) + return append(dst, st.opt.eol...) +} + +func shortestJSONLine(o options) int64 { + return int64(len(`{"time":"","level":"","status":,"msg":""}`)+ + len(isoTime)+longestLevel()+statusWidth) + int64(len(o.eol)) + 1 +} + +// appendMessage writes the sentence the message shapes end with, stretched to +// reach a length when one was asked for. +func appendMessage(dst []byte, st *state, want int64, base int64) []byte { + if want < 0 { + dst = core.AppendFiller(dst, messageWords, int64(16+st.rng.IntN(48)), nil) + } else { + dst = core.AppendFiller(dst, messageWords, want-base, nil) + } + return append(dst, st.opt.eol...) +} + +// appendPath writes a URL path of exactly n bytes out of readable segments, so +// a padded entry still looks like a request rather than a run of one letter. +func appendPath(dst []byte, n int64) []byte { + if n < 1 { + // Only reachable if the caller ignored the minimum. The check in + // FillRecords turns that into an error rather than a wrong size. + return append(dst, 'x') + } + return core.AppendFiller(dst, pathFiller, n, func(int) string { return "/" }) +} + +var pathFiller = []string{"segment"} + +// messageWords is the vocabulary the message shapes pad with. ASCII on +// purpose: a character needing a JSON escape would be written as two bytes and +// the length arithmetic would be wrong by one. +var messageWords = []string{ + "request", "handled", "for", "user", "session", "cache", "miss", "queue", + "retry", "upstream", "timeout", "connection", "closed", "by", "peer", +} diff --git a/internal/guard/foldedsummary_test.go b/internal/guard/foldedsummary_test.go new file mode 100644 index 0000000..43e5c94 --- /dev/null +++ b/internal/guard/foldedsummary_test.go @@ -0,0 +1,85 @@ +package guard + +import ( + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// The folded line says what was CHOSEN, not what the fields started on. +// +// A menu cannot be empty. It opens on the value its format declared as the +// default, so asking "does this field have a value" answers yes for every menu +// on the screen before anybody has touched one. The line built from that answer +// is not a summary of anything - it is the format's whole declaration, written +// out. +// +// It went unnoticed while formats declared one or two settings between them. +// log declared seven on 2026-08-31 and the line ran off the right edge of the +// window, which is how it was found - from a screenshot, not from a test. +// +// Both halves matter and they fail in opposite directions. A line that names an +// untouched default is the defect above. A line that stays quiet about a value +// somebody DID pick is worse: the section arrives folded, so that setting is +// off the screen with nothing to say it is there. +func TestTheFoldedLineNamesWhatWasChosenAndNotTheDefaults(t *testing.T) { + label := settingLabelFor(t, "log", "entry_format") + + t.Run("an untouched default is not named", func(t *testing.T) { + screen := window.NewGenerate(newFakeHost(t)) + body := screen.Object() + chooserIn(t, screen.Fields(), recipe.KeyFormat).SetSelected("log") + + if said := shownText(body); strings.Contains(said, label) { + t.Errorf("the screen names %q with nothing chosen, so the folded line is listing the format's\n"+ + "declaration rather than anybody's choices. log declares seven settings and the line then\n"+ + "runs off the edge of the window.", label) + } + }) + + t.Run("a chosen value is named", func(t *testing.T) { + screen := window.NewGenerate(newFakeHost(t)) + body := screen.Object() + fields := screen.Fields() + chooserIn(t, fields, recipe.KeyFormat).SetSelected("log") + + // Open it, choose, shut it again - which is the order a person does it + // in, and the only one that means anything: the line is worked out when + // the section closes, because at build time nobody had chosen yet. + // + // A property field on this screen registers under the key the format + // declared, with nothing in front of it - the recipe screen is the one + // that prefixes, because there the same key belongs to several batches. + openFold(t, body, "", text.SettingsFor("log")) + chooserIn(t, fields, "entry_format").SetSelected("syslog") + foldTitled(t, body, "", text.SettingsFor("log")).OnTapped() + + said := shownText(body) + if !strings.Contains(said, "syslog") { + t.Errorf("syslog was chosen and nothing on the screen says so. The settings section arrives\n" + + "folded, so a value nobody can see is one they cannot tell they set.") + } + }) +} + +// settingLabelFor is how the window words one declared setting, taken from the +// registry so this guard cannot drift from what the screen draws. +func settingLabelFor(t *testing.T, formatID, property string) string { + t.Helper() + d, err := format.Get(formatID) + if err != nil { + t.Fatal(err) + } + for _, p := range d.Properties { + if p.Name == property { + return text.SettingLabel(p.Name) + } + } + t.Fatalf("%s declares no %s, so this guard would check nothing", formatID, property) + return "" +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index b08ee5b..27988f9 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -194,6 +194,28 @@ func goldenCases() map[string]engine.Target { "zip_16kib": {ID: "g", Format: "zip", Sizes: engine.Uniform(1, 16384), Label: true}, "md_8kib": {ID: "g", Format: "md", Sizes: engine.Uniform(1, 8192), Label: true}, "log_8kib": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8192), Label: true}, + + // The way back from the breaking change of 2026-08-31, and the reason + // this case exists rather than a sentence promising the same thing: + // timestamps=fixed has to reproduce the file this tool wrote before it + // could advance the clock, to the byte. The hash below is the one + // log_8kib carried until that day, moved here unchanged. Same shape as + // frames=1 for the still GIF. + "log_8kib_the_way_back": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"timestamps": "fixed"}}, + + // One case per shape that is not built out of the Apache line, because + // each has its own stretchable field and its own arithmetic. JSON lines + // is the one whose closing entry has to land inside a quoted string. + "log_syslog": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"entry_format": "syslog"}}, + "log_json_lines": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"entry_format": "json-lines"}}, + + // The axes that change the length of a line rather than its shape: a + // longer address, and a terminator of two bytes instead of one. + "log_nginx_v6_crlf": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8193), Label: true, + Properties: map[string]string{"entry_format": "nginx", "ip_version": "v6", "line_ending": "crlf"}}, "csv_8kib": {ID: "g", Format: "csv", Sizes: engine.Uniform(1, 8192), Label: true}, "json_8kib": {ID: "g", Format: "json", Sizes: engine.Uniform(1, 8192), Label: true}, "xml_8kib": {ID: "g", Format: "xml", Sizes: engine.Uniform(1, 8192), Label: true}, diff --git a/internal/guard/logshapes_test.go b/internal/guard/logshapes_test.go new file mode 100644 index 0000000..5559f19 --- /dev/null +++ b/internal/guard/logshapes_test.go @@ -0,0 +1,341 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// logShapes is every shape the format offers, taken from the registry rather +// than written out here. A list copied into a guard stops describing the thing +// it guards the moment somebody adds to the registry, and this project has +// been caught by that before. +func logShapes(t *testing.T) []string { + t.Helper() + d, err := format.Get("log") + if err != nil { + t.Fatal(err) + } + for _, p := range d.Properties { + if p.Name == "entry_format" { + if len(p.Choices) == 0 { + t.Fatal("entry_format declares no choices, so this guard would check nothing") + } + return p.Choices + } + } + t.Fatal("the log format declares no entry_format, so there are no shapes to walk") + return nil +} + +func writeLog(t *testing.T, size int64, props map[string]string) []byte { + t.Helper() + d, err := format.Get("log") + if err != nil { + t.Fatal(err) + } + p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: 7741, Label: true, Properties: props}) + if err != nil { + t.Fatalf("planning %d B with %v: %v", size, props, err) + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, p); err != nil { + t.Fatalf("writing %d B with %v: %v", size, props, err) + } + if int64(buf.Len()) != size { + t.Fatalf("%v: asked for %d B and got %d", props, size, buf.Len()) + } + return buf.Bytes() +} + +// Every shape writes whole lines, and hits the size to the byte. +// +// A log is read line by line, so a file that is the right length but ends in +// half an entry is a broken file. Each shape reaches its length by stretching +// one field of the last entry - a request path for the web shapes, a message +// for the rest - and each has its own arithmetic to get that wrong in. +// +// The sizes cross the awkward places: one byte above the minimum, an odd size, +// and sizes where the closing entry has to be much longer than a natural one. +func TestEveryLogShapeWritesWholeLinesAtTheRightSize(t *testing.T) { + for _, shape := range logShapes(t) { + t.Run(shape, func(t *testing.T) { + props := map[string]string{"entry_format": shape} + for _, size := range []int64{300, 301, 512, 1000, 4096, 4097, 65537} { + body := writeLog(t, size, props) + if !bytes.HasSuffix(body, []byte("\n")) { + t.Errorf("%d B: the file does not end with a newline, so the last entry is unterminated", size) + continue + } + for i, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") { + if strings.TrimSpace(line) == "" { + t.Errorf("%d B: line %d is empty", size, i+1) + } + } + } + }) + } +} + +// Every shape hits the size across seeds, not just on the sizes somebody tried. +// +// This is the guard that would have caught the defect it was written after, and +// the shape of that defect is the reason it sweeps seeds rather than sizes. The +// syslog line counted its process id as four digits always, when it runs from +// 100 to 9998 and so is three about one time in ten. Only the LAST entry of a +// file is built to a length, so the miss needed the last entry to draw a short +// pid - one file in ten, which a handful of hand picked sizes walks straight +// past. Measured before the repair: 35 files out of 360, syslog alone. +// +// The project's own size guard never saw it either, because it asks each format +// with its settings left alone, and left alone this one is Apache combined. +func TestEveryLogShapeHitsTheSizeAcrossSeeds(t *testing.T) { + d, err := format.Get("log") + if err != nil { + t.Fatal(err) + } + sizes := []int64{400, 512, 777, 1000, 2048, 4097} + + for _, shape := range logShapes(t) { + t.Run(shape, func(t *testing.T) { + checked := 0 + for seed := uint64(0); seed < 40; seed++ { + for _, size := range sizes { + p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: seed, Label: true, + Properties: map[string]string{"entry_format": shape}}) + if err != nil { + continue // below this shape's own minimum, which it names + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, p); err != nil { + t.Fatalf("seed %d, %d B: %v", seed, size, err) + } + if int64(buf.Len()) != size { + t.Fatalf("seed %d: asked for %d B and got %d.\n"+ + "One field of the closing entry is stretched to reach the length, so its arithmetic is out by %d for this draw.", + seed, size, buf.Len(), int64(buf.Len())-size) + } + checked++ + } + } + if checked < 100 { + t.Fatalf("only %d files were produced, too few for a sweep to mean anything", checked) + } + }) + } +} + +// Every shape can be reached with EVERY setting sent, which is how a window +// asks. +// +// This is the guard the reported defect needed and did not have. A menu cannot +// be empty: it opens on its declared default, so the window sends a value for +// every setting the format declares, touched or not. The first version of the +// log settings refused a setting that could do nothing for the chosen shape +// whenever the KEY arrived - so from the window, where methods always arrives, +// syslog and JSON lines could not be produced at all. Every attempt came back +// refused for a setting nobody had touched. +// +// The command line never showed it, because there an unset flag is an absent +// key, and every test written before the report used the command line's shape +// of a request. Reported from a screenshot on 2026-08-31. +// +// So the request here is built the way a window builds one: every declared +// setting, each at its declared default. +func TestEveryLogShapeIsReachableWithEverySettingSent(t *testing.T) { + d, err := format.Get("log") + if err != nil { + t.Fatal(err) + } + + for _, shape := range logShapes(t) { + t.Run(shape, func(t *testing.T) { + props := map[string]string{} + for _, p := range d.Properties { + if p.Default != "" { + props[p.Name] = p.Default + } + } + props["entry_format"] = shape + if len(props) < 2 { + t.Fatal("no declared defaults were sent, so this guard is not asking what a window asks") + } + + plan, err := d.Generator.Plan(format.Request{Bytes: 20 << 10, Seed: 7741, Label: true, Properties: props}) + if err != nil { + t.Fatalf("a window sending every setting at its default cannot produce %s: %v\n"+ + "A default that arrived is not a setting anybody asked for, so it cannot disagree with the shape.", + shape, err) + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, plan); err != nil { + t.Fatalf("%s planned and then would not write: %v", shape, err) + } + if buf.Len() != 20<<10 { + t.Fatalf("%s came out %d B rather than %d", shape, buf.Len(), 20<<10) + } + }) + } +} + +// The clock advances, and every tick is there. +// +// Until 2026-08-31 every entry carried the same instant, which docs/BACKLOG.md +// recorded as a fidelity defect rather than a missing setting: a log where ten +// thousand requests happen at once cannot test a time window or a rate alert. +// +// Two things are asked, and they fail differently. That the instants MOVE is +// the defect itself coming back. That they move by exactly one step each time, +// with no gap, is the record builder putting the clock back for the entry it +// built only to measure and then threw away - miss that and the file skips a +// second before its last line, which nothing else here would see, because the +// size stays exact and every line still parses. +func TestALogClockAdvancesByOneTickPerEntry(t *testing.T) { + body := string(writeLog(t, 4096, map[string]string{"rate": "1"})) + + stamp := regexp.MustCompile(`\[([^\]]+)\]`) + var seen []time.Time + for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + if strings.HasPrefix(line, "# ") { + continue + } + m := stamp.FindStringSubmatch(line) + if m == nil { + t.Fatalf("no timestamp in %q", line) + } + at, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1]) + if err != nil { + t.Fatalf("unparseable timestamp %q: %v", m[1], err) + } + seen = append(seen, at) + } + if len(seen) < 3 { + t.Fatalf("only %d entries, too few to say anything about the clock", len(seen)) + } + + if seen[0].Equal(seen[len(seen)-1]) { + t.Errorf("every entry carries %s, so the clock is not advancing at all.\n"+ + "That is the defect docs/BACKLOG.md recorded: a log where everything happens at one instant.", + seen[0].Format(time.RFC3339)) + } + for i := 1; i < len(seen); i++ { + if gap := seen[i].Sub(seen[i-1]); gap != time.Second { + t.Errorf("entry %d is %s after the one before it, and at one entry a second every gap should be 1s.\n"+ + "A gap of two means a tick was spent on the entry that was built to be measured and thrown away, and not put back.", + i+1, gap) + break + } + } +} + +// The way back is exact, and it is a property of the file rather than a promise. +// +// Advancing the clock moved the bytes of every log this tool writes, so +// timestamps=fixed exists to reproduce the old file. The pinned hash for that +// lives with the other golden values - this asks the cheaper question, which is +// whether the clock is held still at all. +func TestALogWithFixedTimestampsHoldsOneInstant(t *testing.T) { + body := string(writeLog(t, 4096, map[string]string{"timestamps": "fixed"})) + + stamp := regexp.MustCompile(`\[([^\]]+)\]`) + first, count := "", 0 + for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + if strings.HasPrefix(line, "# ") { + continue + } + m := stamp.FindStringSubmatch(line) + if m == nil { + t.Fatalf("no timestamp in %q", line) + } + if first == "" { + first = m[1] + } else if m[1] != first { + t.Fatalf("timestamps=fixed produced %q and then %q, so it is not fixed", first, m[1]) + } + count++ + } + if count < 3 { + t.Fatalf("only %d entries, too few to say anything", count) + } +} + +// A setting that would do nothing is refused, not ignored. +// +// Most of these only mean something for some shapes: a request method has no +// place in a syslog line, and a rate has none while the clock is held still. +// Taking them and quietly doing nothing is the silence rule 6 forbids - the +// recipe would say one thing and the file would be another. +// +// The refusal has to name BOTH settings. Naming one leaves the reader to guess +// which of the pair to change, and either could be the one they meant. +func TestALogRefusesASettingThatWouldChangeNothing(t *testing.T) { + d, err := format.Get("log") + if err != nil { + t.Fatal(err) + } + + cases := []struct{ props map[string]string }{ + {map[string]string{"entry_format": "syslog", "methods": "mixed"}}, + {map[string]string{"entry_format": "syslog", "ip_version": "v6"}}, + {map[string]string{"entry_format": "plain", "status_mix": "success"}}, + {map[string]string{"timestamps": "fixed", "rate": "5"}}, + } + for _, c := range cases { + t.Run(fmt.Sprint(c.props), func(t *testing.T) { + _, err := d.Generator.Plan(format.Request{Bytes: 4096, Seed: 7741, Label: true, Properties: c.props}) + if err == nil { + t.Fatalf("%v was accepted, and one of those two settings then does nothing at all", c.props) + } + for key := range c.props { + if !strings.Contains(err.Error(), key) { + t.Errorf("the refusal does not name %q, so it says which half of the pair is wrong only by luck: %v", key, err) + } + } + }) + } + + // And the pair that is NOT a conflict, so the guard cannot pass by + // refusing everything: JSON lines carry a response code of their own. + if _, err := d.Generator.Plan(format.Request{Bytes: 4096, Seed: 7741, Label: true, + Properties: map[string]string{"entry_format": "json-lines", "status_mix": "success"}}); err != nil { + t.Errorf("json-lines does carry a status, so status_mix belongs there: %v", err) + } +} + +// The label is a line the shape's own reader accepts. +// +// Every line has to be a whole record, and a hash comment is not one in JSON +// lines - it is the single line in the file that no reader would load. So the +// carrier changes with the shape, and this asks the readers rather than the +// code: every line of a JSON log parses as an object, the label included. +func TestTheLogLabelIsALineItsOwnReaderAccepts(t *testing.T) { + body := writeLog(t, 4096, map[string]string{"entry_format": "json-lines"}) + + lines := strings.Split(strings.TrimRight(string(body), "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("only %d lines, too few to say anything", len(lines)) + } + for i, line := range lines { + var obj map[string]any + if err := json.Unmarshal([]byte(line), &obj); err != nil { + t.Fatalf("line %d is not a JSON object, so a reader stops there: %v\n %s", i+1, err, line) + } + } + if !strings.Contains(lines[0], `"label"`) { + t.Errorf("the first line carries no label field, so nothing in the file says what it is: %s", lines[0]) + } + + // The shapes whose readers do treat a hash as a comment keep it. + hashed := writeLog(t, 4096, map[string]string{"entry_format": "syslog"}) + if !bytes.HasPrefix(hashed, []byte("# ")) { + t.Errorf("the syslog label is not a hash comment, so the one line that is not an entry does not say so") + } +} diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index b67abfc..637626b 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -113,6 +113,13 @@ var reachableFromTheWindow = []string{ "property:jpg.height", "property:jpg.quality", "property:jpg.width", + "property:log.entry_format", + "property:log.ip_version", + "property:log.line_ending", + "property:log.methods", + "property:log.rate", + "property:log.status_mix", + "property:log.timestamps", "property:pdf.page_size", "property:pdf.pages", "property:png.height", diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 82c2c07..57276e7 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -107,7 +107,28 @@ }, "log_8kib": { "bytes": 8192, - "sha256": "231e732a7781da7640a1ff202a4c37c5380eb429e924499a60cdf46015075102" + "sha256": "593705edb46ba0c12a23863b0798df448ebd569e650a9fed97ebe131e1f89684", + "measured_on": "2026-08-31" + }, + "log_8kib_the_way_back": { + "bytes": 8192, + "sha256": "231e732a7781da7640a1ff202a4c37c5380eb429e924499a60cdf46015075102", + "measured_on": "2026-08-31" + }, + "log_json_lines": { + "bytes": 8192, + "sha256": "0d51a986bd84fca51e301fb918055be8273b2164f767b43ce8f3d706df34a871", + "measured_on": "2026-08-31" + }, + "log_nginx_v6_crlf": { + "bytes": 8193, + "sha256": "71aac4d67ff5d3ca6a5ab958776685e13fd918eef5da3098625da20efd0e74f6", + "measured_on": "2026-08-31" + }, + "log_syslog": { + "bytes": 8192, + "sha256": "adfd35176aaa710daae05cb1165ac0db79c134fbe8bb3bf6c6ccba6dcf44f9fa", + "measured_on": "2026-08-31" }, "md_8kib": { "bytes": 8192, diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 03f271f..43c12be 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -22,6 +22,23 @@ type PropertyField struct { // recipe and a --set flag both carry, so the engine judges one thing however // it was asked. Value func() string + // Chosen says whether what Value returns is something somebody picked, + // rather than what the field started on. + // + // It exists because a menu cannot be empty: it opens on its declared + // default and so always has a value, which is fine for what is SENT - a + // default present and a default absent mean the same to a format - and + // wrong for what is SAID. A folded section that lists every setting it + // holds, whether or not anybody touched one, stops being a summary: log + // declares seven and the line ran off the edge of the window, reported + // from a screenshot on 2026-08-31. + // + // A box somebody types in answers this the old way, on emptiness, and + // that difference is deliberate. For a preset parameter an empty box and + // a typed default are NOT the same thing - the manifest records which + // numbers were ours through defaulted, untouchable rule 5 - so hiding a + // typed value there would hide a real one. + Chosen func() bool } // FromProperty draws the field a declaration describes. @@ -107,6 +124,7 @@ func choiceField(p format.Property) PropertyField { Name: p.Name, Control: sel, Value: func() string { return sel.Selected }, + Chosen: func() bool { return sel.Selected != "" && sel.Selected != p.Default }, } } @@ -126,6 +144,7 @@ func boolField(p format.Property) PropertyField { Name: p.Name, Control: check, Value: func() string { return strconv.FormatBool(check.Checked) }, + Chosen: func() bool { return strconv.FormatBool(check.Checked) != p.Default }, } } @@ -139,6 +158,8 @@ func textField(p format.Property) PropertyField { Name: p.Name, Control: entry, Value: func() string { return entry.Text }, + // A box answers on emptiness, for the reason on Chosen. + Chosen: func() bool { return entry.Text != "" }, } } diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index 92b4c14..5fe47a4 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -395,6 +395,13 @@ func (g *Generate) onFormatChosen(id string) { func (g *Generate) settingsSaid() string { said := make([]string, 0, len(g.props)) for _, f := range g.props { + // What was CHOSEN, not what the field started on. A menu opens on its + // default and so always has a value, and listing all of them turned + // this line into every setting the format declares - which for log, + // with seven, ran off the edge of the window. + if f.Chosen != nil && !f.Chosen() { + continue + } if v := f.Value(); v != "" { said = append(said, text.SettingSaid(text.SettingLabel(f.Name), v)) } diff --git a/internal/gui/window/recipefolds.go b/internal/gui/window/recipefolds.go index 6a51850..d5a0f43 100644 --- a/internal/gui/window/recipefolds.go +++ b/internal/gui/window/recipefolds.go @@ -137,6 +137,13 @@ func (r *Recipe) wire(fold *parts.Folding, folded *bool, say func() string) { func (b *batch) settingsSaid() string { said := make([]string, 0, len(b.props)) for _, f := range b.props { + // What was CHOSEN, not what the field started on. A menu opens on its + // default and so always has a value, and listing all of them turned + // this line into every setting the format declares - which for log, + // with seven, ran off the edge of the window. + if f.Chosen != nil && !f.Chosen() { + continue + } if v := f.Value(); v != "" { said = append(said, text.SettingSaid(text.SettingLabel(f.Name), v)) } diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 3a2d2e4..ceec166 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -157,8 +157,29 @@ def check_zip(data): ok(f"{entries} entries, comment {comment_len} B") +OCTET = r"(?:0|[1-9][0-9]{0,2})" +ADDR_V4 = OCTET + r"(?:\." + OCTET + r"){3}" +ADDR_V6 = r"[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){7}" +ADDR = r"(?:" + ADDR_V4 + r"|" + ADDR_V6 + r")" +ISO_TIME = r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}[+-][0-9]{2}:[0-9]{2}" +REQUEST = (r" - - \[[^\]]+\] \"(?:GET|POST|PUT|DELETE|HEAD|PATCH) /\S* HTTP/1\.[01]\"" + r" [1-5][0-9]{2} [0-9]+") + +# One pattern per shape this generator writes. Ordered most specific first, +# because nginx is combined plus one more quoted field and combined is common +# plus two, so a looser pattern tried first would claim a line it does not +# describe. +LOG_SHAPES = [ + ("nginx", re.compile(r"^" + ADDR + REQUEST + r" \"[^\"]*\" \"[^\"]*\" \"[^\"]*\"$")), + ("apache-combined", re.compile(r"^" + ADDR + REQUEST + r" \"[^\"]*\" \"[^\"]*\"$")), + ("apache-common", re.compile(r"^" + ADDR + REQUEST + r"$")), + ("syslog", re.compile(r"^" + ISO_TIME + r" \S+ \S+\[[0-9]+\]: .*$")), + ("plain", re.compile(r"^" + ISO_TIME + r" (?:DEBUG|INFO|WARN|ERROR) .*$")), +] + + def check_log(data): - """Every line is a whole entry in the Apache combined format. + """Every line is a whole entry, and every entry is the SAME shape. A log is read line by line, so a line that is not a whole entry is a broken file however right its length is. And "the last line is truncated" is what @@ -170,36 +191,78 @@ def check_log(data): octal by some address parsers - where 069 is not even valid octal. Our generator produced padded octets until 2026-08-01, and a checker written to match it would have blessed that instead of catching it. + + Since 2026-08-31 the generator writes six shapes, and this checker is told + which one only by the file - it is handed a format id and a path, never the + recipe. So it takes the shape from the first entry and holds every other + line to THAT one. Asking each line merely to be valid on its own would pass + a file that changed shape half way down, which no reader could load. """ try: text = data.decode("utf-8") except UnicodeDecodeError as exc: fail(f"not valid UTF-8: {exc}") - if not text.endswith("\n"): + if not text.endswith(("\n", "\r\n")): fail("the file does not end with a newline, so the last entry is unterminated") - octet = r"(?:0|[1-9][0-9]{0,2})" - pattern = re.compile( - r"^" + octet + r"\." + octet + r"\." + octet + r"\." + octet + - r" - - \[[^\]]+\] \"(?:GET|POST|PUT|DELETE|HEAD|PATCH) /\S* HTTP/1\.[01]\"" - r" [1-5][0-9]{2} [0-9]+ \"[^\"]*\" \"[^\"]*\"$") + # Line endings have to be consistent. A file mixing them is the shape a + # careless writer produces and a strict reader rejects. + crlf = "\r\n" in text + if crlf and re.search(r"(? 255 for part in address.split(".")): - fail(f"line {number} has an octet above 255: {address}") + if line.startswith('{"label":'): + continue # the same thing where a comment would not parse + if shape is None: + shape = detect_log_shape(line, number) + check_log_line(line, number, shape) entries += 1 if entries == 0: fail("the file holds no entries at all") - ok(f"{entries} entries, all whole") + ok(f"{entries} {shape} entries, all whole, {'crlf' if crlf else 'lf'} endings") + + +def detect_log_shape(line, number): + if line.startswith("{"): + return "json-lines" + for name, pattern in LOG_SHAPES: + if pattern.match(line): + return name + fail(f"line {number} is not a whole entry in any shape this tool writes: {line[:90]!r}") + + +def check_log_line(line, number, shape): + if shape == "json-lines": + # A real parser rather than a pattern, which is what makes this the one + # shape here checked by somebody else's implementation. + import json + try: + obj = json.loads(line) + except ValueError as exc: + fail(f"line {number} is not valid JSON: {exc}") + if not isinstance(obj, dict): + fail(f"line {number} is JSON but not an object: {line[:60]!r}") + for key in ("time", "level", "msg"): + if key not in obj: + fail(f"line {number} has no {key!r} field") + return + + pattern = dict(LOG_SHAPES)[shape] + if not pattern.match(line): + fail(f"line {number} is not a whole {shape} entry: {line[:90]!r}") + + if shape in ("nginx", "apache-combined", "apache-common"): + # An octet above 255 parses as a number and is not an address. + address = line.split(" ", 1)[0] + if "." in address and any(int(part) > 255 for part in address.split(".")): + fail(f"line {number} has an octet above 255: {address}") def check_csv(data): diff --git a/web/content/en/site.json b/web/content/en/site.json index 2b081c4..fff897a 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -52,11 +52,9 @@ "breadcrumbHome": "Home", "imageAlt": "Testing Files Generator - real test files at any exact size, with a manifest saying how your system should react to each one", "schemaDescription": "A free and open source generator of test files for QA. It produces real files of twenty formats at any exact size and writes a manifest saying how the system under test should react to each one.", - "ctaDownload": "Download", "ctaSource": "View the source", "ctaNote": "Free and open source, GPL-3.0. Nothing to sign up for. The Windows and macOS downloads are signed and start without a warning. The Linux ones are unsigned.", - "colFormat": "Format", "colExtension": "Extension", "colSmallest": "Smallest file", @@ -70,7 +68,6 @@ "noBinary": "no binary yet", "colCode": "Code", "colMeaning": "Meaning", - "footerBlurb": "Test files for QA, at any exact size, with a manifest that states how your system should react to each one.", "footerProject": "Project", "footerSource": "Source on GitHub", @@ -80,7 +77,6 @@ "footerPages": "Pages", "footerLicence": "Copyright (C) 2026 DonislawDev. Released under the GNU General Public License, version 3. The files you generate are yours - the licence covers the tool, not its output.", "footerPrivacy": "This site loads no fonts, no scripts and no trackers from anywhere. It sets no cookies.", - "notFoundTitle": "That page is not here", "notFoundLead": "The address you followed does not match any page on this site.", "notFoundBack": "Go to the home page" @@ -115,7 +111,8 @@ "slides": "slides", "hertz": "hertz", "megapixels": "megapixels", - "million cells": "million cells" + "million cells": "million cells", + "entries per second": "entries per second" }, "faq": [ { diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 69757a3..85a04d1 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -52,11 +52,9 @@ "breadcrumbHome": "Start", "imageAlt": "Testing Files Generator - prawdziwe pliki testowe o dokładnym rozmiarze, z manifestem mówiącym, jak system ma na nie zareagować", "schemaDescription": "Darmowy generator plików testowych dla QA o otwartym kodzie. Tworzy prawdziwe pliki w dwudziestu formatach o dokładnie zadanym rozmiarze i zapisuje manifest mówiący, jak testowany system ma na każdy z nich zareagować.", - "ctaDownload": "Pobierz", "ctaSource": "Zobacz kod źródłowy", "ctaNote": "Darmowe i otwarte, GPL-3.0. Bez zakładania konta. Pliki dla Windows i macOS są podpisane i uruchamiają się bez ostrzeżenia. Pliki dla Linuksa nie są podpisane.", - "colFormat": "Format", "colExtension": "Rozszerzenie", "colSmallest": "Najmniejszy plik", @@ -70,7 +68,6 @@ "noBinary": "brak binarki", "colCode": "Kod", "colMeaning": "Znaczenie", - "footerBlurb": "Pliki testowe dla QA o dokładnie zadanym rozmiarze, z manifestem mówiącym, jak Twój system ma na każdy z nich zareagować.", "footerProject": "Projekt", "footerSource": "Kod na GitHubie", @@ -80,7 +77,6 @@ "footerPages": "Strony", "footerLicence": "Copyright (C) 2026 DonislawDev. Wydane na licencji GNU General Public License w wersji 3. Wygenerowane pliki należą do Ciebie - licencja obejmuje narzędzie, nie to, co ono tworzy.", "footerPrivacy": "Ta strona nie ładuje żadnych fontów, skryptów ani liczników z zewnątrz. Nie ustawia ciasteczek.", - "notFoundTitle": "Tej strony tu nie ma", "notFoundLead": "Adres, którym tu trafiłeś, nie pasuje do żadnej strony w tym serwisie.", "notFoundBack": "Wróć na stronę główną" @@ -115,7 +111,8 @@ "slides": "slajdów", "hertz": "herców", "megapixels": "megapikseli", - "million cells": "milionów komórek" + "million cells": "milionów komórek", + "entries per second": "wpisów na sekundę" }, "faq": [ { diff --git a/web/public/formats/index.html b/web/public/formats/index.html index f0f0e29..f31407b 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -405,6 +405,41 @@

Settings each format accepts

quality 1 - 100 + + log + entry_format + apache-combined, apache-common, json-lines, nginx, plain, syslog + + + + timestamps + advancing, fixed + + + + rate + 1 - 1000000 entries per second + + + + methods + get, mixed, read + + + + status_mix + client-errors, realistic, server-errors, success + + + + ip_version + mixed, v4, v6 + + + + line_ending + crlf, lf + pdf pages diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index b168bb2..bec5a50 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -405,6 +405,41 @@

Ustawienia, które przyjmuje każdy format

quality 1 - 100 + + log + entry_format + apache-combined, apache-common, json-lines, nginx, plain, syslog + + + + timestamps + advancing, fixed + + + + rate + 1 - 1000000 wpisów na sekundę + + + + methods + get, mixed, read + + + + status_mix + client-errors, realistic, server-errors, success + + + + ip_version + mixed, v4, v6 + + + + line_ending + crlf, lf + pdf pages From 1ba144cb6d93c50f19fb25546705276c7ed6c427 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 1 Sep 2026 08:21:53 +0200 Subject: [PATCH 2/2] format: pull the log settings apart so the code shape gates stay green The gates called parseOptions at 32 decision points against a ceiling of 22 and 86 lines against 79. Those ceilings are today's measurement and only ever go down, so the branches come out rather than the number going up. One reader per setting now, run in order - and the order is the point, since the shape and the clock have to be settled before anything can be asked whether it disagrees with them. Two more things the same run named. The check that refuses a chosen setting for a shape that cannot use it was written twice as an if inside an if, so it is one helper now. And writing an address nested a condition inside a loop inside a condition, which splits cleanly by family - the two halves had nothing in common but the name. No behaviour moves: every pinned hash is unchanged and the mutation patterns all still find their line. Co-Authored-By: Claude Opus 5 --- internal/format/logfile/address.go | 31 ++-- internal/format/logfile/options.go | 219 ++++++++++++++++++----------- 2 files changed, 155 insertions(+), 95 deletions(-) diff --git a/internal/format/logfile/address.go b/internal/format/logfile/address.go index 1e6edf6..cc2580e 100644 --- a/internal/format/logfile/address.go +++ b/internal/format/logfile/address.go @@ -85,19 +85,28 @@ func (a address) length() int { } func (a address) append(dst []byte) []byte { - if !a.v6 { - // No leading zeros. Padding octets to three digits made the line - // length trivial to predict and produced addresses no real log - // contains - and a leading zero is read as octal by some address - // parsers, where 069 is not even valid octal. - for i := 0; i < 4; i++ { - if i > 0 { - dst = append(dst, '.') - } - dst = strconv.AppendInt(dst, int64(a.parts[i]), 10) + if a.v6 { + return a.appendV6(dst) + } + return a.appendV4(dst) +} + +// No leading zeros. Padding octets to three digits made the line length trivial +// to predict and produced addresses no real log contains - and a leading zero +// is read as octal by some address parsers, where 069 is not even valid octal. +func (a address) appendV4(dst []byte) []byte { + for i := 0; i < 4; i++ { + if i > 0 { + dst = append(dst, '.') } - return dst + dst = strconv.AppendInt(dst, int64(a.parts[i]), 10) } + return dst +} + +// Groups without leading zeros, which is how every reader shows them and why +// the length of one of these varies. +func (a address) appendV6(dst []byte) []byte { for i, p := range a.parts { if i > 0 { dst = append(dst, ':') diff --git a/internal/format/logfile/options.go b/internal/format/logfile/options.go index 32b09f3..2f9cd8a 100644 --- a/internal/format/logfile/options.go +++ b/internal/format/logfile/options.go @@ -84,105 +84,156 @@ var statusSets = map[string][]int{ } // parseOptions reads the settings and refuses the pairs that disagree. +// +// One reader per setting, run in order, because the order matters: the shape +// and the clock have to be settled before anything can be asked whether it +// disagrees with them. It was one function until the code shape gates called +// it at 32 decision points against a ceiling of 22 - and those ceilings only +// ever go down, so the branches came out rather than the number going up. func parseOptions(props map[string]string) (options, error) { o := defaultOptions() - - if v, ok := value(props, "entry_format"); ok { - s, known := shapes[v] - if !known { - return options{}, badValue("entry_format", v, "it is not one of the shapes this format writes") + for _, read := range []func(map[string]string, *options) error{ + readShape, readLineEnding, readTimestamps, readRate, + readMethods, readStatusMix, readIPVersion, + } { + if err := read(props, &o); err != nil { + return options{}, err } - o.shape = s } + return o, nil +} - if v, ok := value(props, "line_ending"); ok { - switch v { - case "lf": - o.eol = "\n" - case "crlf": - o.eol = "\r\n" - default: - return options{}, badValue("line_ending", v, "it has to be lf or crlf") - } - o.lineEnding = v +func readShape(props map[string]string, o *options) error { + v, ok := value(props, "entry_format") + if !ok { + return nil } + s, known := shapes[v] + if !known { + return badValue("entry_format", v, "it is not one of the shapes this format writes") + } + o.shape = s + return nil +} - if v, ok := value(props, "timestamps"); ok { - switch v { - case "advancing", "fixed": - default: - return options{}, badValue("timestamps", v, "it has to be advancing or fixed") - } - o.timestamps = v - o.advancing = v == "advancing" +func readLineEnding(props map[string]string, o *options) error { + v, ok := value(props, "line_ending") + if !ok { + return nil + } + switch v { + case "lf": + o.eol = "\n" + case "crlf": + o.eol = "\r\n" + default: + return badValue("line_ending", v, "it has to be lf or crlf") } + o.lineEnding = v + return nil +} - if v, ok := value(props, "rate"); ok { - n, err := strconv.Atoi(v) - if err != nil { - return options{}, fmt.Errorf("log: rate must be a whole number of entries per second, got %q", v) - } - if n < minRate || n > maxRate { - return options{}, fmt.Errorf("log: rate must be between %d and %d entries per second, got %d", minRate, maxRate, n) - } - // A rate somebody CHOSE while the clock is held still would change - // nothing at all, so it is said out loud rather than ignored. A rate - // sitting at its default was not chosen - see asked. - if _, chosen := asked(props, "rate", strconv.Itoa(defaultRate)); chosen && !o.advancing { - return options{}, conflict("rate and timestamps", v, - "a rate says how fast entries arrive and timestamps=fixed puts them all at one instant") - } - o.rate = n +func readTimestamps(props map[string]string, o *options) error { + v, ok := value(props, "timestamps") + if !ok { + return nil + } + if v != "advancing" && v != "fixed" { + return badValue("timestamps", v, "it has to be advancing or fixed") } + o.timestamps = v + o.advancing = v == "advancing" + return nil +} - if v, ok := value(props, "methods"); ok { - set, known := methodSets[v] - if !known { - return options{}, badValue("methods", v, "it has to be get, read or mixed") - } - if _, chosen := asked(props, "methods", "get"); chosen { - if err := needsWeb(o.shape, "methods", v, "request method"); err != nil { - return options{}, err - } - } - o.methodMix, o.methods = v, set +func readRate(props map[string]string, o *options) error { + v, ok := value(props, "rate") + if !ok { + return nil + } + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("log: rate must be a whole number of entries per second, got %q", v) + } + if n < minRate || n > maxRate { + return fmt.Errorf("log: rate must be between %d and %d entries per second, got %d", minRate, maxRate, n) + } + // A rate somebody CHOSE while the clock is held still would change nothing + // at all, so it is said out loud rather than ignored. A rate sitting at its + // default was not chosen - see asked. + if _, chosen := asked(props, "rate", strconv.Itoa(defaultRate)); chosen && !o.advancing { + return conflict("rate and timestamps", v, + "a rate says how fast entries arrive and timestamps=fixed puts them all at one instant") } + o.rate = n + return nil +} - if v, ok := value(props, "status_mix"); ok { - set, known := statusSets[v] - if !known { - return options{}, badValue("status_mix", v, "it has to be realistic, success, client-errors or server-errors") - } - // JSON lines carry a status of their own, so this one applies there - // too - it is the address and the method that have no place outside a - // web shape. - if _, chosen := asked(props, "status_mix", "realistic"); chosen && - !o.shape.web && o.shape.id != "json-lines" { - return options{}, conflict("status_mix and entry_format", v, - "the "+o.shape.id+" shape carries no response code") - } - o.statusMix, o.statuses = v, set - } - - if v, ok := value(props, "ip_version"); ok { - switch v { - case "v4": - case "v6": - o.ipv6 = true - case "mixed": - o.ipMixed = true - default: - return options{}, badValue("ip_version", v, "it has to be v4, v6 or mixed") - } - if _, chosen := asked(props, "ip_version", "v4"); chosen { - if err := needsWeb(o.shape, "ip_version", v, "client address"); err != nil { - return options{}, err - } - } - o.ipVersion = v +func readMethods(props map[string]string, o *options) error { + v, ok := value(props, "methods") + if !ok { + return nil } + set, known := methodSets[v] + if !known { + return badValue("methods", v, "it has to be get, read or mixed") + } + if err := refuseUnlessWeb(props, o.shape, "methods", "get", v, "request method"); err != nil { + return err + } + o.methodMix, o.methods = v, set + return nil +} - return o, nil +func readStatusMix(props map[string]string, o *options) error { + v, ok := value(props, "status_mix") + if !ok { + return nil + } + set, known := statusSets[v] + if !known { + return badValue("status_mix", v, "it has to be realistic, success, client-errors or server-errors") + } + // JSON lines carry a status of their own, so this one applies there too - it + // is the address and the method that have no place outside a web shape. + if _, chosen := asked(props, "status_mix", "realistic"); chosen && + !o.shape.web && o.shape.id != "json-lines" { + return conflict("status_mix and entry_format", v, + "the "+o.shape.id+" shape carries no response code") + } + o.statusMix, o.statuses = v, set + return nil +} + +func readIPVersion(props map[string]string, o *options) error { + v, ok := value(props, "ip_version") + if !ok { + return nil + } + switch v { + case "v4": + case "v6": + o.ipv6 = true + case "mixed": + o.ipMixed = true + default: + return badValue("ip_version", v, "it has to be v4, v6 or mixed") + } + if err := refuseUnlessWeb(props, o.shape, "ip_version", "v4", v, "client address"); err != nil { + return err + } + o.ipVersion = v + return nil +} + +// refuseUnlessWeb refuses a CHOSEN setting that only means something for a +// shape carrying a request. A value left at its default was not chosen and so +// cannot disagree with anything - see asked. +func refuseUnlessWeb(props map[string]string, s *shape, key, def, val, what string) error { + if _, chosen := asked(props, key, def); !chosen { + return nil + } + return needsWeb(s, key, val, what) } // needsWeb refuses a setting that only means something for a shape carrying a