diff --git a/cmd/lgbgen/atomic_write_test.go b/cmd/lgbgen/atomic_write_test.go new file mode 100644 index 000000000..087f80b5e --- /dev/null +++ b/cmd/lgbgen/atomic_write_test.go @@ -0,0 +1,83 @@ +//go:build bootstrap + +package main + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +func TestWriteFileAtomicallyPreservesDestinationOnFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "core_compiled.lgb") + if err := os.WriteFile(path, []byte("original"), 0o640); err != nil { + t.Fatal(err) + } + + wantErr := errors.New("encode failed") + _, err := writeFileAtomically(path, func(w io.Writer) error { + if _, err := io.WriteString(w, "partial"); err != nil { + return err + } + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("writeFileAtomically() error = %v, want %v", err, wantErr) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("destination = %q after failed write, want original", got) + } + assertNoAtomicTemps(t, dir) +} + +func TestWriteFileAtomicallyReplacesDestination(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "core_compiled.lgb") + if err := os.WriteFile(path, []byte("old"), 0o640); err != nil { + t.Fatal(err) + } + + size, err := writeFileAtomically(path, func(w io.Writer) error { + _, err := io.WriteString(w, "replacement") + return err + }) + if err != nil { + t.Fatal(err) + } + if size != int64(len("replacement")) { + t.Fatalf("size = %d, want %d", size, len("replacement")) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "replacement" { + t.Fatalf("destination = %q, want replacement", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if gotMode := info.Mode().Perm(); gotMode != 0o640 { + t.Fatalf("destination mode = %o, want 640", gotMode) + } + assertNoAtomicTemps(t, dir) +} + +func assertNoAtomicTemps(t *testing.T, dir string) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, ".core_compiled.lgb.tmp-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary files left behind: %v", matches) + } +} diff --git a/cmd/lgbgen/main.go b/cmd/lgbgen/main.go index c9207a609..3c8db335e 100644 --- a/cmd/lgbgen/main.go +++ b/cmd/lgbgen/main.go @@ -15,6 +15,7 @@ package main import ( "flag" "fmt" + "io" "io/fs" "os" "os/signal" @@ -388,6 +389,13 @@ func main() { fs.StringVar(&cpuProfilePath, "cpuprofile", "", "write Go CPU profile for the lgbgen process") fs.StringVar(&memProfilePath, "memprofile", "", "write Go allocation profile (allocs) for the lgbgen process") fs.StringVar(&codeDir, "code-dir", "", "base dir for generated gogen_ir wireup files (default: repo root)") + // --compress DEFLATE-compresses the bundle body. Default OFF for the + // committed core: it trims the binary but adds work and allocations to every + // process start (the core is decoded on every boot), and that tradeoff is a + // policy call for the maintainer, not a silent default. Decode inflates it + // transparently either way (see the FlagCompressed path in pkg/bytecode). + compress := false + fs.BoolVar(&compress, "compress", false, "DEFLATE-compress the .lgb bundle body (smaller binary, slower boot)") fs.Parse(os.Args[1:]) switch target { @@ -568,44 +576,81 @@ func main() { return } if targetBoth { - writeBundle(outPath, consts, nsChunks, bundleOrder) + writeBundle(outPath, consts, nsChunks, bundleOrder, compress) compileIRForLowering() runGoTarget(goOutDir, codeDir) return } // Bytecode mode: write .lgb bundle (ir.* excluded). - writeBundle(outPath, consts, nsChunks, bundleOrder) + writeBundle(outPath, consts, nsChunks, bundleOrder, compress) } // writeBundle encodes the compiled namespace chunks into the .lgb bundle at -// outPath and closes the file before returning (so callers may proceed to the -// Go-lowering target against the same in-memory state). -func writeBundle(outPath string, consts *vm.Consts, nsChunks map[string]*vm.CodeChunk, nsOrder []string) { - f, err := os.Create(outPath) +// outPath atomically before returning (so callers may proceed to the Go-lowering +// target against the same in-memory state). Encoding happens in a temporary +// file in the destination directory; the previous bundle remains intact unless +// encoding, flushing, and closing all succeed. +func writeBundle(outPath string, consts *vm.Consts, nsChunks map[string]*vm.CodeChunk, nsOrder []string, compress bool) { + // The core bundle is decoded on every process start, so the decode path + // inflates it transparently; compressing here trims the embedded bytes from + // every binary (see the FlagCompressed decode path in pkg/bytecode). + size, err := writeFileAtomically(outPath, func(w io.Writer) error { + return bytecode.EncodeBundleOrderedCompressed(w, consts, nsChunks, nsOrder, compress) + }) if err != nil { - fmt.Fprintf(os.Stderr, "create %s: %v\n", outPath, err) + fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) os.Exit(1) } - if err := bytecode.EncodeBundleOrdered(f, consts, nsChunks, nsOrder); err != nil { - f.Close() - fmt.Fprintf(os.Stderr, "encode failed: %v\n", err) - os.Exit(1) + fmt.Printf("wrote %s (%d bytes, %d consts, %d namespaces)\n", + outPath, size, len(consts.Values()), len(nsChunks)) + refreshManifest() +} + +// writeFileAtomically writes path through a same-directory temporary file and +// replaces path only after the contents are synced and closed successfully. +// Any failure leaves an existing destination untouched. +func writeFileAtomically(path string, write func(io.Writer) error) (int64, error) { + mode := fs.FileMode(0o644) + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } else if !os.IsNotExist(err) { + return 0, fmt.Errorf("stat destination: %w", err) } - // Stat is best-effort: success here is just for the byte-count in the - // success line. If it fails, we still wrote the bundle, so report what we - // know without dereferencing a nil FileInfo. - if fi, err := f.Stat(); err == nil { - fmt.Printf("wrote %s (%d bytes, %d consts, %d namespaces)\n", - outPath, fi.Size(), len(consts.Values()), len(nsChunks)) - } else { - fmt.Printf("wrote %s (%d consts, %d namespaces; stat failed: %v)\n", - outPath, len(consts.Values()), len(nsChunks), err) + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return 0, fmt.Errorf("create temporary file: %w", err) } - f.Close() - refreshManifest() + tmpName := tmp.Name() + defer os.Remove(tmpName) + + closeOnError := func(err error) (int64, error) { + _ = tmp.Close() + return 0, err + } + if err := write(tmp); err != nil { + return closeOnError(fmt.Errorf("encode temporary file: %w", err)) + } + if err := tmp.Sync(); err != nil { + return closeOnError(fmt.Errorf("sync temporary file: %w", err)) + } + info, err := tmp.Stat() + if err != nil { + return closeOnError(fmt.Errorf("stat temporary file: %w", err)) + } + if err := tmp.Chmod(mode); err != nil { + return closeOnError(fmt.Errorf("set temporary file mode: %w", err)) + } + if err := tmp.Close(); err != nil { + return 0, fmt.Errorf("close temporary file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return 0, fmt.Errorf("replace destination: %w", err) + } + return info.Size(), nil } // refreshManifest records the content digest of all .lg + lgbgen sources diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 79fec4f06..74aa1b3c7 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -24,11 +24,17 @@ lg -r myfile.lg # run file, then REPL ```bash lg -c app.lgb app.lg # compile to bytecode lg app.lgb # run bytecode +lg -c app.lgb -z app.lg # compile with a compressed bytecode body lg -b myapp app.lg # bundle into a self-contained binary +lg -b myapp -z app.lg # bundle with compressed bytecode ./myapp # runs anywhere, no lg needed ``` +`-z` is opt-in and applies to `-c` and `-b`. The header remains plaintext for +early compatibility checks; the bytecode body is compressed with DEFLATE and +inflated transparently by `lg` and `lg-runtime`. + The standalone binary is a copy of `lg` with your bytecode appended — copy it to another machine and it runs. diff --git a/lg.go b/lg.go index 2abc528aa..d35ee75af 100644 --- a/lg.go +++ b/lg.go @@ -153,11 +153,11 @@ func bundleBinary(ctx *compiler.Context, nsRes *resolver.NSResolver, src string, maps.Copy(nsChunks, nsRes.LoadedChunks) nsChunks[mainNS] = chunk nsOrder := append(nsRes.LoadOrder, mainNS) - if err := bytecode.EncodeBundleOrdered(&lgbBuf, ctx.Consts(), nsChunks, nsOrder); err != nil { + if err := bytecode.EncodeBundleOrderedCompressed(&lgbBuf, ctx.Consts(), nsChunks, nsOrder, compressBundle); err != nil { return err } } else { - if err := bytecode.EncodeCompilation(&lgbBuf, ctx.Consts(), chunk); err != nil { + if err := bytecode.EncodeCompilationCompressed(&lgbBuf, ctx.Consts(), chunk, compressBundle); err != nil { return err } } @@ -242,9 +242,9 @@ func compileLG(ctx *compiler.Context, nsRes *resolver.NSResolver, src string, ds maps.Copy(nsChunks, nsRes.LoadedChunks) nsChunks[mainNS] = chunk nsOrder := append(nsRes.LoadOrder, mainNS) - return bytecode.EncodeBundleOrdered(out, ctx.Consts(), nsChunks, nsOrder) + return bytecode.EncodeBundleOrderedCompressed(out, ctx.Consts(), nsChunks, nsOrder, compressBundle) } - return bytecode.EncodeCompilation(out, ctx.Consts(), chunk) + return bytecode.EncodeCompilationCompressed(out, ctx.Consts(), chunk, compressBundle) } var nreplServer *nrepl.NreplServer @@ -272,6 +272,7 @@ var debug bool var showVersion bool var compileOutput string var bundleOutput string +var compressBundle bool var bundleBase string var wasmOutput string var wasmShell string @@ -291,6 +292,7 @@ func init() { flag.BoolVar(&showVersion, "version", false, "print version and exit") flag.StringVar(&compileOutput, "c", "", "compile .lg file to .lgb bytecode (specify output path)") flag.StringVar(&bundleOutput, "b", "", "bundle .lg file into a standalone executable (specify output path)") + flag.BoolVar(&compressBundle, "z", false, "with -c/-b: DEFLATE-compress the bundle body (smaller .lgb / standalone binary; transparently inflated at load)") flag.StringVar(&bundleBase, "bundle-base", "", "path to target-platform lg binary for cross-OS bundling (defaults to current executable)") flag.StringVar(&wasmOutput, "w", "", "build .lg file into a WASM web app (specify output directory)") flag.StringVar(&wasmShell, "w-shell", "xterm", "shell for -w: 'xterm' (default), 'none' (emit core only; client supplies its own shell via window.LetGoHost), or a path to a custom HTML template containing __LG_HOST_JS_BODY_PLACEHOLDER__") @@ -435,6 +437,10 @@ func runMain() int { } flag.Parse() + if compressBundle && compileOutput == "" && bundleOutput == "" { + fmt.Fprintln(os.Stderr, "error: -z requires -c or -b") + return 2 + } if showVersion { fmt.Print(bytecode.FormatVersionReport("lg", versionString())) diff --git a/pkg/bytecode/bench_test.go b/pkg/bytecode/bench_test.go index 9bc60c478..275505bb8 100644 --- a/pkg/bytecode/bench_test.go +++ b/pkg/bytecode/bench_test.go @@ -28,6 +28,78 @@ func BenchmarkDecodeCore(b *testing.B) { } } +// BenchmarkDecodeCoreCompressed decodes a DEFLATE-compressed copy of the core +// bundle, so the delta against BenchmarkDecodeCore is the boot-time cost of +// shipping the embedded core compressed (it is decoded on every process start). +func BenchmarkDecodeCoreCompressed(b *testing.B) { + b.ReportAllocs() + corePath := filepath.Join("..", "rt", "core_compiled.lgb") + if _, err := os.Stat(corePath); os.IsNotExist(err) { + b.Skip("core_compiled.lgb not found at expected path") + } + data, err := os.ReadFile(corePath) + if err != nil { + b.Fatal(err) + } + // Re-encode the committed (uncompressed) core with FlagCompressed so the + // comparison is the same content through the compressed path. + m, err := Decode(bytes.NewReader(data)) + if err != nil { + b.Fatal(err) + } + m.Flags |= FlagCompressed + var comp bytes.Buffer + if err := Encode(&comp, m); err != nil { + b.Fatal(err) + } + compressed := comp.Bytes() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Decode(bytes.NewReader(compressed)); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkEncodeCore(b *testing.B) { + benchmarkEncodeCore(b, false) +} + +func BenchmarkEncodeCoreCompressed(b *testing.B) { + benchmarkEncodeCore(b, true) +} + +func benchmarkEncodeCore(b *testing.B, compressed bool) { + b.Helper() + b.ReportAllocs() + corePath := filepath.Join("..", "rt", "core_compiled.lgb") + data, err := os.ReadFile(corePath) + if os.IsNotExist(err) { + b.Skip("core_compiled.lgb not found at expected path") + } + if err != nil { + b.Fatal(err) + } + m, err := Decode(bytes.NewReader(data)) + if err != nil { + b.Fatal(err) + } + if compressed { + m.Flags |= FlagCompressed + } + + b.SetBytes(int64(len(data))) + var out bytes.Buffer + b.ResetTimer() + for i := 0; i < b.N; i++ { + out.Reset() + if err := Encode(&out, m); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(out.Len()), "bytes/output") +} + func BenchmarkDecodeModuleSmall(b *testing.B) { b.ReportAllocs() mb := NewModuleBuilder() diff --git a/pkg/bytecode/capabilities.go b/pkg/bytecode/capabilities.go index 818bbdd29..f1b878d4f 100644 --- a/pkg/bytecode/capabilities.go +++ b/pkg/bytecode/capabilities.go @@ -110,11 +110,15 @@ func UnsupportedCapabilityError(caps uint32) error { // FormatVersionReport returns the multi-line -v/--version body for tool // (e.g. "lg" or "lg-runtime") at the given version string. +// +// The lgb line reports both the default write version (plain bundles stay on +// uncompressedFormatVersion) and FormatVersion (the newest this tree reads and +// writes when a newer-only flag such as FlagCompressed is set). func FormatVersionReport(tool, versionStr string) string { count, hash := vm.OpcodeSetSignature() var b strings.Builder fmt.Fprintf(&b, "%s %s\n", tool, versionStr) - fmt.Fprintf(&b, "lgb: format %d\n", FormatVersion) + fmt.Fprintf(&b, "lgb: format %d (default write), %d (max)\n", uncompressedFormatVersion, FormatVersion) fmt.Fprintf(&b, "capabilities: %s\n", DescribeCapabilities(SupportedCapabilities)) fmt.Fprintf(&b, "opcodes: %d (signature %016x)\n", count, hash) return b.String() diff --git a/pkg/bytecode/capabilities_test.go b/pkg/bytecode/capabilities_test.go index 30d45256b..2d0290a08 100644 --- a/pkg/bytecode/capabilities_test.go +++ b/pkg/bytecode/capabilities_test.go @@ -1,6 +1,7 @@ package bytecode import ( + "fmt" "strings" "testing" ) @@ -50,7 +51,7 @@ func TestFormatVersionReport(t *testing.T) { got := FormatVersionReport("lg", "1.99.0 (deadbee)") for _, want := range []string{ "lg 1.99.0 (deadbee)\n", - "lgb: format 2\n", + fmt.Sprintf("lgb: format %d (default write), %d (max)\n", uncompressedFormatVersion, FormatVersion), "capabilities: CapOpcodeSet", "opcodes:", "signature", diff --git a/pkg/bytecode/compression_test.go b/pkg/bytecode/compression_test.go new file mode 100644 index 000000000..cee0c25ed --- /dev/null +++ b/pkg/bytecode/compression_test.go @@ -0,0 +1,245 @@ +package bytecode + +import ( + "bytes" + "compress/flate" + "encoding/binary" + "errors" + "io" + "strings" + "testing" + + "github.com/nooga/let-go/pkg/vm" +) + +// buildCompressibleModule returns a module whose string table repeats, so a +// compressed encoding is meaningfully smaller than the plain one (real bundles +// are dominated by recurring symbol/const/opcode bytes). +func buildCompressibleModule() *Module { + consts := vm.NewConsts() + chunk := vm.NewCodeChunk(consts) + chunk.Append(vm.OP_LOAD_CONST, 0, vm.OP_RETURN) + chunk.SetMaxStack(1) + + fn := vm.MakeFunc(0, false, chunk) + fn.SetName("compressible-fn") + + b := NewModuleBuilder() + b.AddChunk(chunk) + b.AddConst(fn) + // A repetitive string table is the compressible payload. + for i := 0; i < 200; i++ { + b.internString("a-recurring-namespace/symbol-name-that-repeats") + } + return b.Build() +} + +func TestCompressedRoundtrip(t *testing.T) { + m := buildCompressibleModule() + + var plain bytes.Buffer + if err := Encode(&plain, m); err != nil { + t.Fatalf("plain encode: %v", err) + } + + mc := buildCompressibleModule() + mc.Flags |= FlagCompressed + var comp bytes.Buffer + if err := Encode(&comp, mc); err != nil { + t.Fatalf("compressed encode: %v", err) + } + + if comp.Len() >= plain.Len() { + t.Fatalf("compressed (%d) not smaller than plain (%d)", comp.Len(), plain.Len()) + } + + // Header stays plaintext: magic + version readable without inflating, so a + // version/opcode-set mismatch is rejected before any decompression. + got := comp.Bytes() + if !bytes.Equal(got[:4], Magic[:]) { + t.Errorf("compressed header magic = %x, want %x", got[:4], Magic[:]) + } + if version := binary.LittleEndian.Uint16(got[4:6]); version != FormatVersion { + t.Errorf("compressed version = %d, want %d", version, FormatVersion) + } + if version := binary.LittleEndian.Uint16(plain.Bytes()[4:6]); version != uncompressedFormatVersion { + t.Errorf("plain version = %d, want %d", version, uncompressedFormatVersion) + } + + // Decodes back to the same shape as the plain encoding. + dp, err := Decode(bytes.NewReader(plain.Bytes())) + if err != nil { + t.Fatalf("decode plain: %v", err) + } + dc, err := Decode(bytes.NewReader(comp.Bytes())) + if err != nil { + t.Fatalf("decode compressed: %v", err) + } + if dc.Flags&FlagCompressed == 0 { + t.Error("decoded compressed module lost FlagCompressed") + } + if dc.Version != FormatVersion { + t.Errorf("decoded compressed version = %d, want %d", dc.Version, FormatVersion) + } + if len(dc.Chunks) != len(dp.Chunks) || len(dc.Consts) != len(dp.Consts) { + t.Fatalf("shape mismatch: chunks %d/%d consts %d/%d", + len(dc.Chunks), len(dp.Chunks), len(dc.Consts), len(dp.Consts)) + } + fn, ok := dc.Consts[0].(*vm.Func) + if !ok || fn.FuncName() != "compressible-fn" { + t.Fatalf("const[0] = %#v, want func compressible-fn", dc.Consts[0]) + } +} + +func TestV2RejectsCompressionFlagAtHeader(t *testing.T) { + m := buildCompressibleModule() + var plain bytes.Buffer + if err := Encode(&plain, m); err != nil { + t.Fatalf("plain encode: %v", err) + } + + data := append([]byte(nil), plain.Bytes()...) + flags := binary.LittleEndian.Uint16(data[6:8]) | FlagCompressed + binary.LittleEndian.PutUint16(data[6:8], flags) + _, err := Decode(bytes.NewReader(data)) + if err == nil || !strings.Contains(err.Error(), "unsupported LGB flags") { + t.Fatalf("Decode() error = %v, want unsupported-flags error", err) + } +} + +func writeCompressedTestFrame(t *testing.T, declaredSize uint64, body []byte) []byte { + t.Helper() + var framed bytes.Buffer + w := NewWriter(&framed) + if err := w.WriteBytes(Magic[:]); err != nil { + t.Fatal(err) + } + if err := w.WriteUint16(FormatVersion); err != nil { + t.Fatal(err) + } + if err := w.WriteUint16(FlagCompressed); err != nil { + t.Fatal(err) + } + if err := w.WriteVarint(declaredSize); err != nil { + t.Fatal(err) + } + if err := w.WriteByte(compressionFlate); err != nil { + t.Fatal(err) + } + if err := w.Flush(); err != nil { + t.Fatal(err) + } + fw, err := flate.NewWriter(&framed, flate.BestSpeed) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(body); err != nil { + t.Fatal(err) + } + if err := fw.Close(); err != nil { + t.Fatal(err) + } + return framed.Bytes() +} + +func TestCompressedBodyRejectsOversizedDeclaration(t *testing.T) { + data := writeCompressedTestFrame(t, maxUncompressedBundleBodySize+1, nil) + _, err := DecodeToExecUnitBytes(data, nil) + if err == nil || !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("DecodeToExecUnitBytes() error = %v, want size-limit error", err) + } +} + +func TestCompressedBodyRejectsInflateBeyondDeclaration(t *testing.T) { + data := writeCompressedTestFrame(t, 1, []byte{0, 1}) + _, err := DecodeToExecUnitBytes(data, nil) + if err == nil || !strings.Contains(err.Error(), "does not match declared size") { + t.Fatalf("DecodeToExecUnitBytes() error = %v, want declared-size mismatch", err) + } +} + +type failingWriteCloser struct { + writeErr error + closeErr error + closed bool +} + +func (w *failingWriteCloser) Write([]byte) (int, error) { return 0, w.writeErr } + +func (w *failingWriteCloser) Close() error { + w.closed = true + return w.closeErr +} + +func TestCompressedBodyWriterClosesAfterWriteError(t *testing.T) { + writeErr := errors.New("write failed") + closeErr := errors.New("close failed") + w := &failingWriteCloser{writeErr: writeErr, closeErr: closeErr} + err := writeAndCloseCompressedBody(w, []byte("body")) + if !w.closed { + t.Fatal("compressed writer was not closed after write error") + } + if !errors.Is(err, writeErr) || !errors.Is(err, closeErr) { + t.Fatalf("error = %v, want joined write and close errors", err) + } +} + +var _ io.WriteCloser = (*failingWriteCloser)(nil) + +// TestCompressedExecUnitBytePath exercises the byte-backed inflate swap +// (DecodeToExecUnitBytes, the embedded-core path) so the zero-copy source-map +// reader is rebuilt over the inflated buffer rather than the compressed one. +func TestCompressedExecUnitBytePath(t *testing.T) { + mc := buildCompressibleModule() + mc.Flags |= FlagCompressed + var comp bytes.Buffer + if err := Encode(&comp, mc); err != nil { + t.Fatalf("compressed encode: %v", err) + } + + resolve := func(ns, name string) *vm.Var { return nil } + + // Streaming path. + if _, err := DecodeToExecUnit(bytes.NewReader(comp.Bytes()), resolve); err != nil { + t.Fatalf("streaming DecodeToExecUnit: %v", err) + } + // Byte-backed (zero-copy) path. + if _, err := DecodeToExecUnitBytes(comp.Bytes(), resolve); err != nil { + t.Fatalf("byte-backed DecodeToExecUnitBytes: %v", err) + } +} + +// TestEncodeNormalizesDownWithoutCompression covers the sharp edge where a +// decoded v3 module is re-encoded with FlagCompressed cleared: the wire +// version must drop back to the uncompressed write version so older decoders +// are not rejected for a plaintext body. +func TestEncodeNormalizesDownWithoutCompression(t *testing.T) { + mc := buildCompressibleModule() + mc.Flags |= FlagCompressed + var comp bytes.Buffer + if err := Encode(&comp, mc); err != nil { + t.Fatalf("compressed encode: %v", err) + } + decoded, err := Decode(bytes.NewReader(comp.Bytes())) + if err != nil { + t.Fatalf("decode compressed: %v", err) + } + if decoded.Version != FormatVersion { + t.Fatalf("decoded version = %d, want %d", decoded.Version, FormatVersion) + } + + decoded.Flags &^= FlagCompressed + var plain bytes.Buffer + if err := Encode(&plain, decoded); err != nil { + t.Fatalf("re-encode without compression: %v", err) + } + if version := binary.LittleEndian.Uint16(plain.Bytes()[4:6]); version != uncompressedFormatVersion { + t.Fatalf("re-encoded version = %d, want %d", version, uncompressedFormatVersion) + } + if flags := binary.LittleEndian.Uint16(plain.Bytes()[6:8]); flags&FlagCompressed != 0 { + t.Fatalf("re-encoded flags still have FlagCompressed: 0x%04x", flags) + } + if _, err := Decode(bytes.NewReader(plain.Bytes())); err != nil { + t.Fatalf("decode re-encoded plain: %v", err) + } +} diff --git a/pkg/bytecode/decoder.go b/pkg/bytecode/decoder.go index f588d02fa..b275cb513 100644 --- a/pkg/bytecode/decoder.go +++ b/pkg/bytecode/decoder.go @@ -1,6 +1,9 @@ package bytecode import ( + "bytes" + "compress/flate" + "errors" "fmt" "io" "math/big" @@ -74,6 +77,7 @@ func (d *decoder) decodeExec(parent *vm.Consts) (*ExecUnit, error) { return nil, err } d.flags = flags + d.version = version if version == 1 { // v1 predates capabilities, so like a capability-less v2 bundle it @@ -92,7 +96,7 @@ func (d *decoder) decodeExec(parent *vm.Consts) (*ExecUnit, error) { } return unit, nil } - if version == 2 { + if version == 2 || version == FormatVersion { return d.decodeToExecUnitV2(parent) } return nil, fmt.Errorf("unsupported LGB version %d", version) @@ -218,10 +222,105 @@ func (d *decoder) readCapabilities() error { return nil } +// beginCompressedBody swaps d.r for a reader over the inflated body when +// FlagCompressed is set. It is called after the plaintext header + capability +// section (so a version/opcode mismatch is rejected before any inflate) and +// before the string table — every body section then reads through the new +// reader transparently. +// +// For a byte-backed reader (NewReaderBytes, the embedded-core path) the whole +// body is inflated into a resident buffer and re-wrapped with NewReaderBytes, so +// the decoder's zero-copy deferred source-map slicing keeps working — off the +// inflated buffer instead of the compressed one. For a streaming reader the +// remaining input is wrapped in a flate reader directly; source maps fall back +// to eager decode, which is already the streaming path's behavior. +func (d *decoder) beginCompressedBody() error { + declaredSize, err := d.r.ReadVarint() + if err != nil { + return fmt.Errorf("reading uncompressed bundle body size: %w", err) + } + if declaredSize > maxUncompressedBundleBodySize { + return fmt.Errorf("declared uncompressed bundle body size %d exceeds limit %d", declaredSize, maxUncompressedBundleBodySize) + } + codec, err := d.r.ReadByte() + if err != nil { + return fmt.Errorf("reading compression codec: %w", err) + } + if codec != compressionFlate { + return fmt.Errorf("unsupported bundle compression codec %d", codec) + } + if d.r.HasBackingData() { + // data[pos:] is the not-yet-consumed remainder, regardless of bufio + // readahead: pos counts logically-consumed bytes and data is the full + // original slice. + rest := d.r.data[d.r.pos:] + zr := flate.NewReader(bytes.NewReader(rest)) + inflated, readErr := io.ReadAll(io.LimitReader(zr, int64(declaredSize)+1)) + closeErr := zr.Close() + if readErr != nil { + readErr = fmt.Errorf("inflating bundle body: %w", readErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("closing compressed bundle body: %w", closeErr) + } + if err := errors.Join(readErr, closeErr); err != nil { + return err + } + if uint64(len(inflated)) != declaredSize { + return fmt.Errorf("inflated bundle body size %d does not match declared size %d", len(inflated), declaredSize) + } + d.r = NewReaderBytes(inflated) + return nil + } + zr := flate.NewReader(d.r.r) + d.r = NewReader(io.LimitReader(zr, int64(declaredSize)+1)) + d.compressedBodySize = declaredSize + d.compressedBodyCloser = zr + return nil +} + +// finishCompressedBody verifies that a streaming decode consumed exactly the +// declared body size and probes the DEFLATE reader once more so a missing end +// marker or extra inflated byte is reported. Byte-backed decodes are verified +// eagerly in beginCompressedBody and have no stored closer. +func (d *decoder) finishCompressedBody() error { + if d.compressedBodyCloser == nil { + return nil + } + var validationErr error + if got := uint64(d.r.Offset()); got != d.compressedBodySize { + validationErr = fmt.Errorf("decoded bundle body size %d does not match declared size %d", got, d.compressedBodySize) + } else if _, err := d.r.ReadByte(); err == nil { + validationErr = fmt.Errorf("inflated bundle body exceeds declared size %d", d.compressedBodySize) + } else if !errors.Is(err, io.EOF) { + validationErr = fmt.Errorf("validating compressed bundle body: %w", err) + } + closeErr := d.closeCompressedBody() + return errors.Join(validationErr, closeErr) +} + +func (d *decoder) closeCompressedBody() error { + if d.compressedBodyCloser == nil { + return nil + } + closer := d.compressedBodyCloser + d.compressedBodyCloser = nil + if err := closer.Close(); err != nil { + return fmt.Errorf("closing compressed bundle body: %w", err) + } + return nil +} + func (d *decoder) decodeToExecUnitV2(parent *vm.Consts) (*ExecUnit, error) { if err := d.readCapabilities(); err != nil { return nil, err } + if d.flags&FlagCompressed != 0 { + if err := d.beginCompressedBody(); err != nil { + return nil, err + } + defer d.closeCompressedBody() + } strings, err := d.readStringTable() if err != nil { @@ -295,6 +394,9 @@ func (d *decoder) decodeToExecUnitV2(parent *vm.Consts) (*ExecUnit, error) { } } + if err := d.finishCompressedBody(); err != nil { + return nil, err + } return unit, nil } @@ -311,6 +413,7 @@ func DecodeWithResolver(r io.Reader, resolve VarResolver) (*Module, error) { return nil, err } d.flags = flags + d.version = version if version == 1 { // Same implicit pre-removal signature handling as the exec-unit v1 // path above. The raw ChunkData code is re-synced from the remapped @@ -332,22 +435,25 @@ func DecodeWithResolver(r io.Reader, resolve VarResolver) (*Module, error) { } return m, nil } - if version == 2 { + if version == 2 || version == FormatVersion { return d.readModuleV2() } return nil, fmt.Errorf("unsupported LGB version %d", version) } type decoder struct { - r *Reader - resolve VarResolver - flags uint16 - constsBase int - strings []string - chunks []*vm.CodeChunk - moduleCaps uint32 // populated when FlagCapabilities is set in v2 - stats *DecodeStats - remapFunc func([]*vm.CodeChunk) // migration to apply after chunks are decoded, or nil + r *Reader + resolve VarResolver + version uint16 + flags uint16 + constsBase int + strings []string + chunks []*vm.CodeChunk + moduleCaps uint32 // populated when FlagCapabilities is set in v2+ + stats *DecodeStats + remapFunc func([]*vm.CodeChunk) // migration to apply after chunks are decoded, or nil + compressedBodySize uint64 + compressedBodyCloser io.Closer } // readModuleV1 is the frozen v1 decode path. Do not modify. @@ -410,6 +516,12 @@ func (d *decoder) readModuleV2() (*Module, error) { if err := d.readCapabilities(); err != nil { return nil, err } + if d.flags&FlagCompressed != 0 { + if err := d.beginCompressedBody(); err != nil { + return nil, err + } + defer d.closeCompressedBody() + } strings, err := d.readStringTable() if err != nil { @@ -468,7 +580,7 @@ func (d *decoder) readModuleV2() (*Module, error) { } m := &Module{ - Version: 2, + Version: d.version, Flags: d.flags, Strings: strings, Chunks: chunkDatas, @@ -479,6 +591,9 @@ func (d *decoder) readModuleV2() (*Module, error) { if d.flags&FlagCapabilities != 0 { m.Capabilities = d.moduleCaps } + if err := d.finishCompressedBody(); err != nil { + return nil, err + } return m, nil } @@ -498,6 +613,18 @@ func (d *decoder) readHeader() (version, flags uint16, err error) { if err != nil { return 0, 0, fmt.Errorf("reading flags: %w", err) } + var supportedFlags uint16 + switch version { + case 1: + supportedFlags = v1Flags + case 2: + supportedFlags = v2Flags + case FormatVersion: + supportedFlags = v3Flags + } + if supportedFlags != 0 && flags&^supportedFlags != 0 { + return 0, 0, fmt.Errorf("unsupported LGB flags 0x%04x for version %d (supported: 0x%04x)", flags&^supportedFlags, version, supportedFlags) + } return version, flags, nil } diff --git a/pkg/bytecode/encoder.go b/pkg/bytecode/encoder.go index 165ad53d5..2941eb58e 100644 --- a/pkg/bytecode/encoder.go +++ b/pkg/bytecode/encoder.go @@ -1,6 +1,9 @@ package bytecode import ( + "bytes" + "compress/flate" + "errors" "fmt" "io" "math/big" @@ -9,8 +12,93 @@ import ( "github.com/nooga/let-go/pkg/vm" ) -// Encode serializes a Module to binary format. +// Encode serializes a Module to binary format. When m.Flags has FlagCompressed +// set, the header (magic, version, flags, capabilities) is written in plaintext +// and the body is emitted as a single DEFLATE stream after its declared +// uncompressed size and a one-byte codec tag. Otherwise the whole module is +// written uncompressed, byte-identically to before. func Encode(w io.Writer, m *Module) error { + enc := newEncoder(w, m) + version, err := encodeFormatVersion(m) + if err != nil { + return err + } + if m.Flags&FlagCompressed == 0 { + if err := enc.writeHeader(m, version); err != nil { + return err + } + if err := enc.writeBody(m); err != nil { + return err + } + return enc.w.Flush() + } + + // Serialize the body first so its exact uncompressed size can be declared in + // the framing before any compressed bytes are written. + var rawBody bytes.Buffer + body := &encoder{w: NewWriter(&rawBody), strings: enc.strings, strIndex: enc.strIndex, chunks: enc.chunks} + if err := body.writeBody(m); err != nil { + return err + } + if err := body.w.Flush(); err != nil { + return err + } + if uint64(rawBody.Len()) > maxUncompressedBundleBodySize { + return fmt.Errorf("bundle body is %d bytes; compressed bundles are limited to %d uncompressed bytes", rawBody.Len(), maxUncompressedBundleBodySize) + } + + // The v3 header and size remain plaintext, so older decoders reject the + // version before interpreting any DEFLATE bytes. + if err := enc.writeHeader(m, version); err != nil { + return err + } + if err := enc.w.WriteVarint(uint64(rawBody.Len())); err != nil { + return err + } + if err := enc.w.WriteByte(compressionFlate); err != nil { + return err + } + if err := enc.w.Flush(); err != nil { + return err + } + fw, err := flate.NewWriter(w, flate.BestCompression) + if err != nil { + return err + } + return writeAndCloseCompressedBody(fw, rawBody.Bytes()) +} + +// encodeFormatVersion picks the minimum format version that admits every bit +// set in m.Flags. Clearing FlagCompressed therefore normalizes back to the +// uncompressed write version instead of emitting a plaintext v3 bundle that +// older decoders would reject for no reason. +func encodeFormatVersion(m *Module) (uint16, error) { + if m.Flags&^v3Flags != 0 { + return 0, fmt.Errorf("unsupported LGB flags 0x%04x", m.Flags&^v3Flags) + } + if m.Flags&^v2Flags != 0 { + return FormatVersion, nil + } + return uncompressedFormatVersion, nil +} + +// writeAndCloseCompressedBody always closes the compressor, including after a +// body write failure. Joining the errors preserves both the primary write +// failure and any finalization failure for errors.Is/errors.As callers. +func writeAndCloseCompressedBody(w io.WriteCloser, body []byte) error { + _, writeErr := io.Copy(w, bytes.NewReader(body)) + if writeErr != nil { + writeErr = fmt.Errorf("writing compressed bundle body: %w", writeErr) + } + closeErr := w.Close() + if closeErr != nil { + closeErr = fmt.Errorf("closing compressed bundle body: %w", closeErr) + } + return errors.Join(writeErr, closeErr) +} + +// newEncoder builds an encoder over w with the module's string index primed. +func newEncoder(w io.Writer, m *Module) *encoder { enc := &encoder{ w: NewWriter(w), strings: m.Strings, @@ -20,27 +108,32 @@ func Encode(w io.Writer, m *Module) error { for i, s := range m.Strings { enc.strIndex[s] = i } - if err := enc.writeHeader(m); err != nil { - return err - } - if err := enc.writeStringTable(); err != nil { + return enc +} + +// writeBody serializes everything after the header + capability section: the +// string table, chunks, consts, NS table, and (under FlagLocalVars) the +// per-chunk local-variable tables. Under FlagCompressed this is the byte range +// that gets deflated; uncompressed it runs inline. +func (e *encoder) writeBody(m *Module) error { + if err := e.writeStringTable(); err != nil { return err } - if err := enc.writeChunks(); err != nil { + if err := e.writeChunks(); err != nil { return err } - if err := enc.writeConsts(m); err != nil { + if err := e.writeConsts(m); err != nil { return err } - if err := enc.writeNSTable(m.NSTable); err != nil { + if err := e.writeNSTable(m.NSTable); err != nil { return err } if m.Flags&FlagLocalVars != 0 { - if err := enc.writeLocalVarTables(); err != nil { + if err := e.writeLocalVarTables(); err != nil { return err } } - return enc.w.Flush() + return nil } // writeLocalVarTables serializes per-chunk local-variable debug tables (under @@ -83,6 +176,12 @@ func EncodeModule(w io.Writer, consts *vm.Consts, chunks []*vm.CodeChunk) error // If consts is a child pool, only the child's entries are serialized with the // base offset stored so the decoder can reconstruct the layering. func EncodeCompilation(w io.Writer, consts *vm.Consts, mainChunk *vm.CodeChunk) error { + return EncodeCompilationCompressed(w, consts, mainChunk, false) +} + +// EncodeCompilationCompressed is EncodeCompilation with an opt-in DEFLATE body +// (see FlagCompressed). compress=false is byte-identical to EncodeCompilation. +func EncodeCompilationCompressed(w io.Writer, consts *vm.Consts, mainChunk *vm.CodeChunk, compress bool) error { b := NewModuleBuilder() b.constsBase = consts.Base() // Main chunk must be index 0 @@ -93,6 +192,10 @@ func EncodeCompilation(w io.Writer, consts *vm.Consts, mainChunk *vm.CodeChunk) b.AddConst(v) } m := b.Build() + if compress { + m.Flags |= FlagCompressed + m.Version = FormatVersion + } return Encode(w, m) } @@ -112,6 +215,13 @@ func EncodeBundle(w io.Writer, consts *vm.Consts, nsChunks map[string]*vm.CodeCh // EncodeBundleOrdered serializes a multi-namespace bundle with explicit ordering. // nsOrder determines the chunk index assignment (lower index = earlier dependency). func EncodeBundleOrdered(w io.Writer, consts *vm.Consts, nsChunks map[string]*vm.CodeChunk, nsOrder []string) error { + return EncodeBundleOrderedCompressed(w, consts, nsChunks, nsOrder, false) +} + +// EncodeBundleOrderedCompressed is EncodeBundleOrdered with an opt-in DEFLATE +// body (see FlagCompressed). compress=false is byte-identical to +// EncodeBundleOrdered. +func EncodeBundleOrderedCompressed(w io.Writer, consts *vm.Consts, nsChunks map[string]*vm.CodeChunk, nsOrder []string, compress bool) error { b := NewModuleBuilder() // Register namespace chunks in dependency order for _, name := range nsOrder { @@ -125,6 +235,10 @@ func EncodeBundleOrdered(w io.Writer, consts *vm.Consts, nsChunks map[string]*vm b.AddConst(v) } m := b.Build() + if compress { + m.Flags |= FlagCompressed + m.Version = FormatVersion + } return Encode(w, m) } @@ -278,7 +392,7 @@ func (b *ModuleBuilder) SetNSEntry(name string, chunk *vm.CodeChunk) { // Build creates the Module. func (b *ModuleBuilder) Build() *Module { m := &Module{ - Version: FormatVersion, + Version: uncompressedFormatVersion, // Every new bundle records the producer's opcode-set signature so a // mismatched runtime rejects it at decode (see CapOpcodeSet). Flags: FlagCapabilities, @@ -315,11 +429,11 @@ type encoder struct { // chunkMap maps live CodeChunk pointers to chunk indices (populated by EncodeModule path) } -func (e *encoder) writeHeader(m *Module) error { +func (e *encoder) writeHeader(m *Module, version uint16) error { if err := e.w.WriteBytes(Magic[:]); err != nil { return err } - if err := e.w.WriteUint16(m.Version); err != nil { + if err := e.w.WriteUint16(version); err != nil { return err } if err := e.w.WriteUint16(m.Flags); err != nil { diff --git a/pkg/bytecode/flags_layout_test.go b/pkg/bytecode/flags_layout_test.go new file mode 100644 index 000000000..22cd9ff0e --- /dev/null +++ b/pkg/bytecode/flags_layout_test.go @@ -0,0 +1,117 @@ +package bytecode + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestFlagsLayout locks the positional flag-bit convention so two branches +// cannot each assign the same bit without a test failure. The failure mode it +// prevents is a clean git merge of two Flag* declarations that both use the +// same shift (the #501 / #624 shape). +func TestFlagsLayout(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(thisFile) + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse package: %v", err) + } + pkg := pkgs["bytecode"] + if pkg == nil { + t.Fatal("bytecode package not found") + } + + var ( + flagsEndBlock *ast.GenDecl + flagsEndIsLast bool + outsideFlags []string + explicitValued []string + ) + + for _, f := range pkg.Files { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + + hasFlagsEnd := false + for _, spec := range gd.Specs { + vs := spec.(*ast.ValueSpec) + for _, name := range vs.Names { + if name.Name == "flagsEnd" { + hasFlagsEnd = true + } + } + } + + if !hasFlagsEnd { + for _, spec := range gd.Specs { + vs := spec.(*ast.ValueSpec) + for _, name := range vs.Names { + if strings.HasPrefix(name.Name, "Flag") { + outsideFlags = append(outsideFlags, name.Name) + } + } + } + continue + } + + flagsEndBlock = gd + for i, spec := range gd.Specs { + vs := spec.(*ast.ValueSpec) + for _, name := range vs.Names { + if name.Name == "flagsEnd" { + flagsEndIsLast = i == len(gd.Specs)-1 + } + } + // Bits are positional: only the first entry may carry an + // explicit value (`= 1 << iota`). Everything after inherits. + if i == 0 { + continue + } + if len(vs.Values) > 0 { + for _, name := range vs.Names { + explicitValued = append(explicitValued, name.Name) + } + } + } + } + } + + if flagsEndBlock == nil { + t.Fatal("flagsEnd const block not found in package bytecode") + } + if !flagsEndIsLast { + last := flagsEndBlock.Specs[len(flagsEndBlock.Specs)-1].(*ast.ValueSpec).Names[0].Name + t.Errorf("flagsEnd must be the last entry in its const block so appended flags take the next bit; found %s last", last) + } + for _, name := range outsideFlags { + t.Errorf("flag %s is declared outside the flagsEnd block; a flag declared elsewhere can silently take a bit that is already in use", name) + } + for _, name := range explicitValued { + t.Errorf("flag %s carries an explicit value; bits are positional and must be left to iota", name) + } +} + +func TestKnownFlagsMatchesNewestVersionSet(t *testing.T) { + // Appending a flag grows knownFlags; this forces a coordinated edit to + // v3Flags (and a conscious choice about older version sets) before the + // suite goes green again. + if knownFlags != v3Flags { + t.Fatalf("knownFlags=%#04x v3Flags=%#04x; the newest admitted set must cover every flag in the iota block", knownFlags, v3Flags) + } +} diff --git a/pkg/bytecode/tags.go b/pkg/bytecode/tags.go index 5392a9337..ca6bb2317 100644 --- a/pkg/bytecode/tags.go +++ b/pkg/bytecode/tags.go @@ -3,16 +3,59 @@ package bytecode // Magic bytes identifying an LGB file. var Magic = [4]byte{'L', 'G', 'B', 0x01} -// FormatVersion is the current serialization format version. -const FormatVersion uint16 = 2 +// FormatVersion is the newest serialization format version. Version 3 adds +// the compressed-body framing. Plain bundles continue to use version 2 so +// compression remains opt-in and their encoding stays byte-identical. +const FormatVersion uint16 = 3 -// Module flags. +const uncompressedFormatVersion uint16 = 2 + +// Module flags. Bits are positional via iota — never write an explicit shift. +// Append new flags before flagsEnd; knownFlags is the derived full mask and must +// not be used as the admitted set for older format versions (see vNFlags below). +const ( + FlagConstsBase uint16 = 1 << iota // ConstsBase field is present in consts section + FlagCapabilities // Capability mask follows the header + FlagLocalVars // per-chunk local-variable debug tables follow the NS table (v2+) + // FlagCompressed: the module body (everything after the header + capability + // section) is a single compressed stream, prefixed by its declared + // uncompressed size and a codec byte. The magic, version, flags, and + // capability payload — including the opcode-set signature — stay plaintext, + // so a version/opcode mismatch is still rejected before any inflate. + // Compression is opt-in at compile time + // (lg -c -z / lg -b -z); a bundle without this bit decodes byte-identically + // to before. + FlagCompressed + + flagsEnd // first unused bit; keep last +) + +// knownFlags covers every bit assigned in the block above. It is for layout +// checks only — readHeader admits flags per format version via vNFlags. +const knownFlags = flagsEnd - 1 + +// Per-version admitted flag sets. Appending a flag forces an explicit decision +// about which versions accept it; using knownFlags as the admitted set would +// silently widen what older versions accept. const ( - FlagConstsBase uint16 = 1 << 0 // ConstsBase field is present in consts section - FlagCapabilities uint16 = 1 << 1 // Capability mask follows the header - FlagLocalVars uint16 = 1 << 2 // per-chunk local-variable debug tables follow the NS table (v2+) + v1Flags uint16 = FlagConstsBase | FlagCapabilities + v2Flags uint16 = v1Flags | FlagLocalVars + v3Flags uint16 = v2Flags | FlagCompressed ) +// Compression codecs (the uncompressed byte after the declared body size). +// Kept as a value, not a second flag bit, so a future codec (e.g. zstd) slots +// in without spending flag space or breaking the framing. +const ( + compressionNone byte = 0 // reserved; a compressed bundle never writes this + compressionFlate byte = 1 // compress/flate (raw DEFLATE) — stdlib, TinyGo/wasip1-safe +) + +// Keep a corrupt or adversarial bundle from making the byte-backed decode path +// allocate without bound. This is intentionally well above current production +// bundles while still providing a deterministic failure before an OOM. +const maxUncompressedBundleBodySize uint64 = 256 << 20 + // Capability bits (valid when FlagCapabilities is set). const ( // CapOpcodeSet: the capability mask is followed by the producer's opcode-set diff --git a/pkg/rt/generated.sums b/pkg/rt/generated.sums index 3487dbbed..21f8b3607 100644 --- a/pkg/rt/generated.sums +++ b/pkg/rt/generated.sums @@ -2,4 +2,4 @@ # Content digest of all .lg + lgbgen sources that feed the .lgb # bundle and the lowered Go tree. The genmanifest staleness test # fails if this no longer matches the sources on disk. -4f9d9ff0405bc1290f5650975c260dd73d4a854e0210191102006333ff635d40 +8a37b928db870c263343c5208cd6fab011d990342f8f837d44776006b6a35b6f diff --git a/test/e2e/compress_flag_test.go b/test/e2e/compress_flag_test.go new file mode 100644 index 000000000..d7eae05d5 --- /dev/null +++ b/test/e2e/compress_flag_test.go @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 let-go contributors; see CONTRIBUTORS. + * SPDX-License-Identifier: MIT + */ + +package e2e + +import ( + "errors" + "os/exec" + "strings" + "testing" +) + +func TestCompressFlagRequiresCompileOrBundle(t *testing.T) { + lg := buildLG(t) + cmd := exec.Command(lg, "-z") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != 2 { + t.Fatalf("want exit 2 for bare -z, got %v\n%s", err, out) + } + if !strings.Contains(string(out), "-z requires -c or -b") { + t.Fatalf("want -z requires message, got:\n%s", out) + } +}