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..cc2580e --- /dev/null +++ b/internal/format/logfile/address.go @@ -0,0 +1,168 @@ +// 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 { + 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, '.') + } + 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, ':') + } + 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..2f9cd8a --- /dev/null +++ b/internal/format/logfile/options.go @@ -0,0 +1,292 @@ +// 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. +// +// 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() + 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 + } + } + return o, nil +} + +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 +} + +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 +} + +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 +} + +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 +} + +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 +} + +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 +// 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