pack loads local product packs (format 1): t5de-product.json and the product's files, as product.chkn or files/, with index.xml's rating, type, gender and parent product. cfl writes and reads IMVU's product container, uncompressed. safehtml cleans product descriptions by an allowlist (bluemonday). Co-Authored-By: Claude Opus 5.5 <[email protected]>
316 lines
12 KiB
Go
316 lines
12 KiB
Go
package pack
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.t5de.dev/t5de/t5de-api/cfl"
|
||
)
|
||
|
||
// Synthetic test files: not IMVU's.
|
||
const (
|
||
testManifest = `{"format": 1, "id": "blue-coat", "revision": 2, "name": " Blue Coat ", "creator": "Tester",
|
||
"description": "<p>A coat</p>", "rating": "general"}`
|
||
testIndex = `<?xml version="1.0" encoding="UTF-8" ?><Template><ProductRating>GA</ProductRating>` +
|
||
`<ProductTypeID>106</ProductTypeID><BodyPatternProductId>80</BodyPatternProductId>` +
|
||
`<ProductGender>female</ProductGender><ParentCategoryIDs>1,2</ParentCategoryIDs>` +
|
||
`<__DATAIMPORT>product://12345/index.xml</__DATAIMPORT><ProductName>Test Coat</ProductName>` +
|
||
`<Other><ProductName>nested, ignored</ProductName></Other></Template>`
|
||
)
|
||
|
||
var testPNG = []byte("\x89PNG\r\n\x1a\nfake")
|
||
|
||
func testFiles() map[string][]byte {
|
||
return map[string][]byte{"index.xml": []byte(testIndex), "coat.png": testPNG, "coat.xmf": []byte("mesh")}
|
||
}
|
||
|
||
func writeFile(t *testing.T, path string, data []byte) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// dirPack makes a pack with its files in files/.
|
||
func dirPack(t *testing.T, manifest string, files map[string][]byte) string {
|
||
dir := t.TempDir()
|
||
writeFile(t, filepath.Join(dir, ManifestName), []byte(manifest))
|
||
if err := os.Mkdir(filepath.Join(dir, FilesDir), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for name, data := range files {
|
||
writeFile(t, filepath.Join(dir, FilesDir, name), data)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
type zipEntry struct {
|
||
name string
|
||
data []byte
|
||
method uint16
|
||
}
|
||
|
||
func makeZip(t *testing.T, entries []zipEntry) []byte {
|
||
t.Helper()
|
||
var b bytes.Buffer
|
||
z := zip.NewWriter(&b)
|
||
for _, e := range entries {
|
||
w, err := z.CreateHeader(&zip.FileHeader{Name: e.name, Method: e.method})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w.Write(e.data)
|
||
}
|
||
if err := z.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b.Bytes()
|
||
}
|
||
|
||
// chknPack makes a pack with its files in product.chkn.
|
||
func chknPack(t *testing.T, manifest string, entries []zipEntry) string {
|
||
dir := t.TempDir()
|
||
writeFile(t, filepath.Join(dir, ManifestName), []byte(manifest))
|
||
writeFile(t, filepath.Join(dir, ChknName), makeZip(t, entries))
|
||
return dir
|
||
}
|
||
|
||
func zipEntries(method uint16) []zipEntry {
|
||
var entries []zipEntry
|
||
for name, data := range testFiles() {
|
||
entries = append(entries, zipEntry{name, data, method})
|
||
}
|
||
return entries
|
||
}
|
||
|
||
func TestLoad(t *testing.T) {
|
||
fromDir, err := Load(dirPack(t, testManifest, testFiles()))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m := fromDir.Manifest
|
||
if m.ID != "blue-coat" || m.Revision != 2 || m.Name != "Blue Coat" || m.Creator != "Tester" || m.Rating != General {
|
||
t.Errorf("manifest: %+v", m)
|
||
}
|
||
want := Index{Rating: "GA", Name: "Test Coat", Gender: "female", TypeID: 106, BodyPattern: 80, Parent: 12345}
|
||
if fromDir.Index != want {
|
||
t.Errorf("index: got %+v, want %+v", fromDir.Index, want)
|
||
}
|
||
var names []string
|
||
for _, f := range fromDir.Files {
|
||
names = append(names, f.Name)
|
||
}
|
||
if got := strings.Join(names, " "); got != "coat.png coat.xmf index.xml" {
|
||
t.Errorf("files: %s", got)
|
||
}
|
||
|
||
// The same files zipped, stored or deflated, have the same checksum. Folder entries are ignored.
|
||
for _, method := range []uint16{zip.Store, zip.Deflate} {
|
||
entries := append(zipEntries(method), zipEntry{name: "folder/", method: method})
|
||
fromZip, err := Load(chknPack(t, testManifest, entries))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if fromZip.Checksum() != fromDir.Checksum() {
|
||
t.Errorf("method %d: checksum differs", method)
|
||
}
|
||
}
|
||
|
||
changed := testFiles()
|
||
changed["coat.png"] = append(testPNG, 0)
|
||
other, err := Load(dirPack(t, testManifest, changed))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if other.Checksum() == fromDir.Checksum() {
|
||
t.Error("checksum didn't change")
|
||
}
|
||
|
||
data, err := fromDir.CFL()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
entries, err := cfl.Read(data)
|
||
if err != nil || len(entries) != 3 || entries[2].Name != "index.xml" {
|
||
t.Errorf("CFL: %q, %v", entries, err)
|
||
}
|
||
}
|
||
|
||
func TestLoadErrors(t *testing.T) {
|
||
adult := strings.Replace(testManifest, `"general"`, `"adult"`, 1)
|
||
with := func(name string, data []byte) map[string][]byte {
|
||
f := testFiles()
|
||
f[name] = data
|
||
return f
|
||
}
|
||
without := func(name string) map[string][]byte {
|
||
f := testFiles()
|
||
delete(f, name)
|
||
return f
|
||
}
|
||
extra := func(e ...zipEntry) []zipEntry { return append(zipEntries(zip.Store), e...) }
|
||
|
||
for _, c := range []struct {
|
||
name, want string
|
||
dir func(t *testing.T) string
|
||
}{
|
||
{"rating mismatch", "ProductRating", func(t *testing.T) string { return dirPack(t, adult, testFiles()) }},
|
||
{"no index", "no index.xml", func(t *testing.T) string { return dirPack(t, testManifest, without("index.xml")) }},
|
||
{"contents json", "_contents.json", func(t *testing.T) string {
|
||
return dirPack(t, testManifest, with("_contents.json", []byte("{}")))
|
||
}},
|
||
{"bad index", "index.xml: ProductRating", func(t *testing.T) string {
|
||
return dirPack(t, testManifest, with("index.xml", []byte("<Template/>")))
|
||
}},
|
||
{"subfolder", "folders", func(t *testing.T) string {
|
||
dir := dirPack(t, testManifest, testFiles())
|
||
writeFile(t, filepath.Join(dir, FilesDir, "sub", "a.png"), testPNG)
|
||
return dir
|
||
}},
|
||
{"both", "both exist", func(t *testing.T) string {
|
||
dir := dirPack(t, testManifest, testFiles())
|
||
writeFile(t, filepath.Join(dir, ChknName), makeZip(t, zipEntries(zip.Store)))
|
||
return dir
|
||
}},
|
||
{"neither", "no product.chkn", func(t *testing.T) string {
|
||
dir := t.TempDir()
|
||
writeFile(t, filepath.Join(dir, ManifestName), []byte(testManifest))
|
||
return dir
|
||
}},
|
||
{"no manifest", ManifestName, func(t *testing.T) string { return t.TempDir() }},
|
||
{"bad manifest", "revision", func(t *testing.T) string {
|
||
return dirPack(t, strings.Replace(testManifest, `"revision": 2`, `"revision": 0`, 1), testFiles())
|
||
}},
|
||
{"nested zip entry", "in a folder", func(t *testing.T) string {
|
||
return chknPack(t, testManifest, extra(zipEntry{"sub/a.png", testPNG, zip.Store}))
|
||
}},
|
||
{"backslash in zip", `can't have`, func(t *testing.T) string {
|
||
return chknPack(t, testManifest, extra(zipEntry{`sub\a.png`, testPNG, zip.Store}))
|
||
}},
|
||
{"duplicate in zip", "duplicate", func(t *testing.T) string {
|
||
return chknPack(t, testManifest, extra(zipEntry{"coat.png", testPNG, zip.Store}))
|
||
}},
|
||
{"not latin-1", "Latin-1", func(t *testing.T) string {
|
||
return chknPack(t, testManifest, extra(zipEntry{"snow☃.png", testPNG, zip.Store}))
|
||
}},
|
||
{"too many files", "over 512 files", func(t *testing.T) string {
|
||
var e []zipEntry
|
||
for i := range MaxFiles {
|
||
e = append(e, zipEntry{name: fmt.Sprintf("f%d.png", i), method: zip.Store})
|
||
}
|
||
return chknPack(t, testManifest, extra(e...))
|
||
}},
|
||
{"zip bomb", "over 64 MiB", func(t *testing.T) string {
|
||
// Deflates to well under 1 MiB.
|
||
return chknPack(t, testManifest, extra(zipEntry{"big.png", make([]byte, MaxFileSize+1), zip.Deflate}))
|
||
}},
|
||
{"too big in all", "in all", func(t *testing.T) string {
|
||
var e []zipEntry
|
||
for i := range 5 {
|
||
e = append(e, zipEntry{fmt.Sprintf("big%d.png", i), make([]byte, MaxFileSize), zip.Deflate})
|
||
}
|
||
return chknPack(t, testManifest, extra(e...))
|
||
}},
|
||
{"not a zip", ChknName, func(t *testing.T) string {
|
||
dir := t.TempDir()
|
||
writeFile(t, filepath.Join(dir, ManifestName), []byte(testManifest))
|
||
writeFile(t, filepath.Join(dir, ChknName), []byte("not a zip"))
|
||
return dir
|
||
}},
|
||
} {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
_, err := Load(c.dir(t))
|
||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||
t.Errorf("got %v, want an error with %q", err, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseManifest(t *testing.T) {
|
||
for _, c := range []struct{ json, want string }{
|
||
{`{"format": 2, "id": "a", "revision": 1, "name": "A", "rating": "general"}`, "format"},
|
||
{`{"id": "a", "revision": 1, "name": "A", "rating": "general"}`, "format"},
|
||
{`{"format": 1, "id": "-a", "revision": 1, "name": "A", "rating": "general"}`, "id"},
|
||
{`{"format": 1, "id": "a-", "revision": 1, "name": "A", "rating": "general"}`, "id"},
|
||
{`{"format": 1, "id": "A", "revision": 1, "name": "A", "rating": "general"}`, "id"},
|
||
{`{"format": 1, "id": "", "revision": 1, "name": "A", "rating": "general"}`, "id"},
|
||
{`{"format": 1, "id": "` + strings.Repeat("a", 65) + `", "revision": 1, "name": "A", "rating": "general"}`, "id"},
|
||
{`{"format": 1, "id": "a", "revision": 0, "name": "A", "rating": "general"}`, "revision"},
|
||
{`{"format": 1, "id": "a", "revision": 1.5, "name": "A", "rating": "general"}`, "revision"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": " ", "rating": "general"}`, "name"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "` + strings.Repeat("é", 65) + `", "rating": "general"}`, "name"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A\u0007", "rating": "general"}`, "name"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A", "creator": "` + strings.Repeat("c", 65) + `", "rating": "general"}`, "creator"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A", "description": "` + strings.Repeat("d", 20001) + `", "rating": "general"}`, "description"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A", "rating": "GA"}`, "rating"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A"}`, "rating"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A", "rating": "general", "price": 5}`, "price"},
|
||
{`{"format": 1, "id": "a", "revision": 1, "name": "A", "rating": "general"} {}`, "after"},
|
||
} {
|
||
_, err := ParseManifest([]byte(c.json))
|
||
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), ManifestName) {
|
||
t.Errorf("%s: got %v, want an error with %q", c.json, err, c.want)
|
||
}
|
||
}
|
||
|
||
m, err := ParseManifest([]byte(`{"format": 1, "id": "a-1", "revision": 1, "name": "` + strings.Repeat("é", 64) + `", "rating": "adult"}`))
|
||
if err != nil || m.Rating != Adult {
|
||
t.Errorf("valid manifest: %+v, %v", m, err)
|
||
}
|
||
}
|
||
|
||
func TestParseIndex(t *testing.T) {
|
||
x, err := ParseIndex([]byte("<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><Template>" +
|
||
"<ProductRating> AP </ProductRating><ProductName>Caf\xe9 & Bar</ProductName><Junk a=b>x<br></Junk></Template>"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if want := (Index{Rating: "AP", Name: "Café & Bar"}); *x != want {
|
||
t.Errorf("got %+v, want %+v", *x, want)
|
||
}
|
||
|
||
if x, err := ParseIndex([]byte("\ufeff<Template><ProductRating>GA</ProductRating></Template>")); err != nil || x.Rating != "GA" {
|
||
t.Errorf("with a BOM: %+v, %v", x, err)
|
||
}
|
||
|
||
for _, c := range []struct{ xml, want string }{
|
||
{`<Product><ProductRating>GA</ProductRating></Product>`, "Template"},
|
||
{``, "Template"},
|
||
{`<Template></Template>`, "ProductRating"},
|
||
{`<Template><ProductRating>PG</ProductRating></Template>`, "ProductRating"},
|
||
{`<Template><ProductRating>GA</ProductRating><ProductTypeID>x</ProductTypeID></Template>`, "ProductTypeID"},
|
||
{`<Template><ProductRating>GA</ProductRating><BodyPatternProductId>-1</BodyPatternProductId></Template>`, "BodyPatternProductId"},
|
||
{`<Template><ProductRating>GA</ProductRating><__DATAIMPORT>http://e.com/index.xml</__DATAIMPORT></Template>`, "__DATAIMPORT"},
|
||
{`<Template><ProductRating>GA</ProductRating><__DATAIMPORT>product://0/index.xml</__DATAIMPORT></Template>`, "__DATAIMPORT"},
|
||
{`<Template><ProductRating>GA</ProductRating><ProductRating>AP</ProductRating></Template>`, "twice"},
|
||
{`<?xml version="1.0" encoding="EBCDIC"?><Template/>`, "encoding"},
|
||
} {
|
||
_, err := ParseIndex([]byte(c.xml))
|
||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||
t.Errorf("%s: got %v, want an error with %q", c.xml, err, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCheckName(t *testing.T) {
|
||
for _, name := range []string{"index.xml", "a b.png", "café.jpg", strings.Repeat("a", 200)} {
|
||
if err := CheckName(name); err != nil {
|
||
t.Errorf("%q: %v", name, err)
|
||
}
|
||
}
|
||
for _, name := range []string{"", ".", "..", "a/b", `a\b`, "\xff", strings.Repeat("a", 201), "_CONTENTS.json", "a\x00b", "a\nb", "snow☃"} {
|
||
if err := CheckName(name); err == nil {
|
||
t.Errorf("%q: no error", name)
|
||
}
|
||
}
|
||
}
|