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:
dhkatzandClaude Opus 5.5 committed 2026-10-29 02:52:59 -07:00
commit 9bdc3ff455
16 files changed
+1638

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
package pack
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"unicode"
"unicode/utf8"
)
// Format is the pack format this package reads.
const Format = 1
// Ratings.
const (
General = "general"
Adult = "adult"
)
// Manifest limits.
const (
MaxIDLength = 64
MaxNameLength = 64 // characters
MaxCreatorLength = 64 // characters
MaxDescriptionLength = 20000
)
// Manifest is t5de-product.json.
type Manifest struct {
Format int `json:"format"`
ID string `json:"id"`
Revision int `json:"revision"`
Name string `json:"name"`
Creator string `json:"creator,omitempty"`
Description string `json:"description,omitempty"` // HTML, cleaned by package safehtml when shown
Rating string `json:"rating"`
}
// ParseManifest parses and checks t5de-product.json. Name and Creator come back trimmed.
func ParseManifest(data []byte) (*Manifest, error) {
m, err := parseManifest(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", ManifestName, err)
}
return m, nil
}
func parseManifest(data []byte) (*Manifest, error) {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
var m Manifest
if err := d.Decode(&m); err != nil {
return nil, err
}
if _, err := d.Token(); err != io.EOF {
return nil, errors.New("data after the object")
}
m.Name = strings.TrimSpace(m.Name)
m.Creator = strings.TrimSpace(m.Creator)
switch {
case m.Format != Format:
return nil, fmt.Errorf("format: is %d, must be %d", m.Format, Format)
case !validID(m.ID):
return nil, fmt.Errorf("id: %q must be 1-%d of a-z, 0-9 and -, not starting or ending with -", m.ID, MaxIDLength)
case m.Revision < 1:
return nil, fmt.Errorf("revision: is %d, must be at least 1", m.Revision)
case m.Name == "":
return nil, errors.New("name: missing")
case utf8.RuneCountInString(m.Name) > MaxNameLength:
return nil, fmt.Errorf("name: over %d characters", MaxNameLength)
case hasControl(m.Name):
return nil, errors.New("name: has control characters")
case utf8.RuneCountInString(m.Creator) > MaxCreatorLength:
return nil, fmt.Errorf("creator: over %d characters", MaxCreatorLength)
case hasControl(m.Creator):
return nil, errors.New("creator: has control characters")
case len(m.Description) > MaxDescriptionLength:
return nil, fmt.Errorf("description: over %d bytes", MaxDescriptionLength)
case m.Rating != General && m.Rating != Adult:
return nil, fmt.Errorf("rating: %q must be %q or %q", m.Rating, General, Adult)
}
return &m, nil
}
func validID(id string) bool {
if id == "" || len(id) > MaxIDLength || id[0] == '-' || id[len(id)-1] == '-' {
return false
}
for _, c := range []byte(id) {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
return true
}
func hasControl(s string) bool {
return strings.ContainsFunc(s, unicode.IsControl)
}