Add pack, cfl and safehtml
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]>
This commit is contained in:
commit
9bdc3ff455
16 files changed
+1638
No files matched your search
@@ -0,0 +1,315 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user