Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ mygame.tcade
└── game.wasm
```

`termcade dev build [dir]` produces one at `<dir>/build/<slug>.tcade` (it compiles
`./cmd/wasm`, validates the module's exports, and zips it with the manifest).
"Exactly" is enforced, not implied: a package carrying anything else — an
extra file, a second `termcade.toml` or `game.wasm` (zip permits duplicate
names), a nested or directory entry such as `assets/game.wasm`, a required
name marked as anything but a regular file (zip also marks directories by
external attributes, with no trailing slash needed), or an encrypted entry —
is rejected outright rather than having the extra bytes silently ignored. `termcade dev build [dir]` produces one at
`<dir>/build/<slug>.tcade` (it compiles `./cmd/wasm`, validates the module's
exports, and zips it with the manifest).

## Installing

Expand Down
132 changes: 132 additions & 0 deletions manifest/manifest_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package manifest

import (
"archive/zip"
"bytes"
"io/fs"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -103,6 +106,135 @@ func TestPackageRoundTrip(t *testing.T) {
}
}

var goodWasm = []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}

type zipEntry struct {
name string
body []byte
encrypted bool // sets general-purpose flag bit 0 without encrypting
mode fs.FileMode // external attributes, e.g. fs.ModeDir without a "/" name
}

// rawZip builds a zip in memory with exactly the entries given, in order —
// including duplicate names (legal in zip, rejected by the package contract),
// encrypted flags and attribute-marked directories, none of which
// WritePackage can produce.
func rawZip(t *testing.T, entries ...zipEntry) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
var w interface{ Write([]byte) (int, error) }
var err error
switch {
case e.encrypted:
fh := &zip.FileHeader{Name: e.name, Method: zip.Store}
fh.Flags |= 0x1
w, err = zw.CreateRaw(fh)
case e.mode != 0:
fh := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
fh.SetMode(e.mode)
w, err = zw.CreateHeader(fh)
default:
w, err = zw.Create(e.name)
}
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(e.body); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}

// A .tcade holds exactly one termcade.toml and one game.wasm at its root and
// nothing else; every deviation below must be rejected by name, never
// silently ignored.
func TestPackageEntryContract(t *testing.T) {
cases := map[string]struct {
entries []zipEntry
want string // substring of the rejection message
}{
"extra root entry": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {"README.md", []byte("hi"), false, 0}},
want: `unexpected package entry "README.md"`,
},
"duplicate manifest": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {FileName, []byte(goodTOML), false, 0}},
want: "package has more than one " + FileName,
},
"duplicate wasm": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {WasmName, goodWasm, false, 0}},
want: "package has more than one " + WasmName,
},
"nested lookalike manifest": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {"sub/" + FileName, []byte(goodTOML), false, 0}},
want: `"sub/` + FileName + `" is not at the root`,
},
"nested lookalike wasm": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {"sub/" + WasmName, goodWasm, false, 0}},
want: `"sub/` + WasmName + `" is not at the root`,
},
"directory entry": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}, {"sub/", nil, false, 0}},
want: `"sub/" is not at the root`,
},
// A directory is marked by external attributes too, not only by a
// trailing slash: a required name carrying ModeDir (or any other
// non-regular mode) is not a file, whatever its name says.
"manifest marked as directory": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, fs.ModeDir | 0o755}, {WasmName, goodWasm, false, 0}},
want: `"` + FileName + `" is not a regular file`,
},
"wasm marked as directory": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, fs.ModeDir | 0o755}},
want: `"` + WasmName + `" is not a regular file`,
},
"wasm marked as symlink": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, fs.ModeSymlink | 0o777}},
want: `"` + WasmName + `" is not a regular file`,
},
"encrypted entry": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, true, 0}},
want: `"` + WasmName + `" is encrypted`,
},
"missing wasm": {
entries: []zipEntry{{FileName, []byte(goodTOML), false, 0}},
want: "package has no " + WasmName + " at its root",
},
"empty archive": {
entries: nil,
want: "package has no " + FileName + " at its root",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
_, err := ReadPackage(rawZip(t, tc.entries...))
if err == nil {
t.Fatalf("accepted, want rejection containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error %q does not contain %q", err, tc.want)
}
})
}

// The contract's other side: exactly the two right entries pass it, in
// either order.
for _, entries := range [][]zipEntry{
{{FileName, []byte(goodTOML), false, 0}, {WasmName, goodWasm, false, 0}},
{{WasmName, goodWasm, false, 0}, {FileName, []byte(goodTOML), false, 0}},
} {
if _, err := ReadPackage(rawZip(t, entries...)); err != nil {
t.Errorf("valid package %v rejected: %v", entries[0].name, err)
}
}
}

func TestPackageRejects(t *testing.T) {
dir := t.TempDir()

Expand Down
84 changes: 67 additions & 17 deletions manifest/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"strings"

"github.com/BurntSushi/toml"
)
Expand Down Expand Up @@ -44,15 +45,19 @@ func ReadPackage(raw []byte) (*Package, error) {
}

func readPackage(zr *zip.Reader) (*Package, error) {
manifestRaw, err := zipFile(zr, FileName, 1<<20)
manifestF, wasmF, err := rootEntries(zr)
if err != nil {
return nil, err
}
manifestRaw, err := readEntry(manifestF, 1<<20)
if err != nil {
return nil, err
}
m, err := Parse(manifestRaw)
if err != nil {
return nil, err
}
wasm, err := zipFile(zr, WasmName, maxWasmSize)
wasm, err := readEntry(wasmF, maxWasmSize)
if err != nil {
return nil, err
}
Expand All @@ -62,26 +67,71 @@ func readPackage(zr *zip.Reader) (*Package, error) {
return &Package{Manifest: m, Wasm: wasm}, nil
}

func zipFile(zr *zip.Reader, name string, limit int64) ([]byte, error) {
// rootEntries enforces the package contract: exactly one termcade.toml and
// one game.wasm at the zip's root, and nothing else. A zip's central
// directory may carry duplicate names, nested paths and directory entries,
// and a .tcade comes from strangers, so each violation is its own explicit
// rejection rather than a silently ignored entry.
func rootEntries(zr *zip.Reader) (manifestF, wasmF *zip.File, err error) {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", name, err)
// zip's general-purpose flag bit 0 marks an encrypted entry. The
// reader cannot decrypt one, so reject it by name instead of
// letting it fail later as a read error or an empty file.
if f.Flags&0x1 != 0 {
return nil, nil, fmt.Errorf("package entry %q is encrypted", f.Name)
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, limit+1))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", name, err)
// A directory is also marked by external attributes, with no
// trailing slash required — so a name match is not enough. The two
// required entries must be regular files (FileInfo semantics, which
// cover the MS-DOS directory bit and Unix mode bits alike); any
// other type — directory, symlink, device — has no meaning in a
// two-file package.
if f.Name == FileName || f.Name == WasmName {
if !f.FileInfo().Mode().IsRegular() {
return nil, nil, fmt.Errorf("package entry %q is not a regular file", f.Name)
}
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("%s exceeds the %d byte limit", name, limit)
switch f.Name {
case FileName:
if manifestF != nil {
return nil, nil, fmt.Errorf("package has more than one %s", FileName)
}
manifestF = f
case WasmName:
if wasmF != nil {
return nil, nil, fmt.Errorf("package has more than one %s", WasmName)
}
wasmF = f
default:
if strings.Contains(f.Name, "/") {
return nil, nil, fmt.Errorf("package entry %q is not at the root: a .tcade contains only %s and %s", f.Name, FileName, WasmName)
}
return nil, nil, fmt.Errorf("unexpected package entry %q: a .tcade contains only %s and %s", f.Name, FileName, WasmName)
}
return raw, nil
}
return nil, fmt.Errorf("package has no %s at its root", name)
if manifestF == nil {
return nil, nil, fmt.Errorf("package has no %s at its root", FileName)
}
if wasmF == nil {
return nil, nil, fmt.Errorf("package has no %s at its root", WasmName)
}
return manifestF, wasmF, nil
}

func readEntry(f *zip.File, limit int64) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", f.Name, err)
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, limit+1))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", f.Name, err)
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("%s exceeds the %d byte limit", f.Name, limit)
}
return raw, nil
}

// WritePackage zips a manifest and module into a .tcade at path.
Expand Down