Add a pure-Go zim reader and writer

ZIM is the open single-file archive format Kiwix uses for offline content:
a fixed header, a MIME list, URL/title/cluster pointer lists, directory
entries, zstd-compressed or stored clusters, and a trailing MD5. The writer
lays out a mirror in two passes (assign positions, then emit bytes) and
derives the UUID from the content so packing is deterministic. The reader
random-accesses entries by namespace and url, follows redirects, and reads
xz clusters too so archives from other tooling open.

Cross-checked against an independent reader (gozim): header, MIME list,
namespaces, urls, dirents, and a non-last cluster's blob all read back
byte-for-byte.
This commit is contained in:
Duc-Tam Nguyen
2026-06-14 20:17:13 +07:00
parent b59f782165
commit ffdb4ca969
7 changed files with 1033 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/fang v1.0.0
github.com/go-rod/rod v0.116.2
github.com/go-rod/stealth v0.4.9
github.com/klauspost/compress v1.18.6
github.com/spf13/cobra v1.10.2
golang.org/x/net v0.56.0
)
+2
View File
@@ -36,6 +36,8 @@ github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+48
View File
@@ -0,0 +1,48 @@
package zim
import (
"sync"
"github.com/klauspost/compress/zstd"
)
// A single shared zstd codec. Both EncodeAll and DecodeAll are safe for
// concurrent use, so one encoder and one decoder serve the whole process.
var (
zstdOnce sync.Once
zstdEnc *zstd.Encoder
zstdDec *zstd.Decoder
)
func initZstd() {
zstdOnce.Do(func() {
zstdEnc, _ = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
zstdDec, _ = zstd.NewReader(nil)
})
}
func zstdEncode(p []byte) []byte {
initZstd()
return zstdEnc.EncodeAll(p, nil)
}
func zstdDecode(p []byte) ([]byte, error) {
initZstd()
return zstdDec.DecodeAll(p, nil)
}
// isTextMime reports whether content of this MIME type is worth compressing.
// Already-compressed media (images, fonts, audio, video, archives) is stored
// uncompressed so we do not burn CPU inflating it by a few bytes.
func isTextMime(mime string) bool {
switch mime {
case "application/json", "application/xml", "application/javascript",
"application/x-javascript":
return true
}
if len(mime) >= 5 && mime[:5] == "text/" {
return true
}
// Any structured-XML type: application/rss+xml, image/svg+xml, ...
return len(mime) >= 4 && mime[len(mime)-4:] == "+xml"
}
+115
View File
@@ -0,0 +1,115 @@
// Package zim reads and writes the ZIM offline-archive format, the open
// single-file container that Kiwix uses to ship offline content. kage uses it
// to pack a cloned mirror into one indexable, compressed file that a reader can
// random-access without unpacking.
//
// The package is pure: no network, no clock, no global state beyond a lazily
// built zstd codec. A ZIM file is laid out as a fixed header, a MIME-type list,
// three pointer lists (URL, title, cluster), a run of directory entries, a run
// of clusters that hold the content, and a trailing MD5. Every cross-reference
// is an absolute file position recorded in the header, so the writer assigns
// positions in one pass and emits bytes in a second. All integers are
// little-endian.
//
// We write the new namespace scheme (minor version 1): all content lives under
// the single 'C' namespace, metadata under 'M', and a 'W/mainPage' redirect
// points at the entry point. Reading handles redirects and both offset widths.
package zim
import (
"encoding/binary"
"fmt"
)
// Magic is the ZIM header magic number, the first four bytes of every file.
const Magic uint32 = 0x44D495A // 72173914
const (
majorVersion uint16 = 5
minorVersion uint16 = 1 // single 'C' content namespace
headerLen = 80
)
// Namespaces in the new (minor version 1) scheme.
const (
NamespaceContent byte = 'C' // pages and assets
NamespaceMetadata byte = 'M' // M/Title, M/Date, ...
NamespaceWellKnown byte = 'W' // W/mainPage redirect
)
// Compression codes carried in the low nibble of a cluster's info byte.
const (
compNone uint8 = 1 // stored, no compression
compXZ uint8 = 4 // xz / LZMA2 (read-only support)
compZstd uint8 = 5 // zstd (what we write for text)
extendedFlag uint8 = 0x10 // bit 4: cluster offsets are uint64, not uint32
)
// Sentinels stored in a directory entry's mimetype field to mark non-content
// entries. A redirect reuses the cluster slot to hold its target's URL index.
const (
redirectEntry uint16 = 0xffff
linkTargetEntry uint16 = 0xfffe
deletedEntry uint16 = 0xfffd
)
// noMainPage is the mainPage/layoutPage value meaning "none".
const noMainPage uint32 = 0xffffffff
// header is the 80-byte ZIM header.
type header struct {
uuid [16]byte
articleCount uint32
clusterCount uint32
urlPtrPos uint64
titlePtrPos uint64
clusterPtrPos uint64
mimeListPos uint64
mainPage uint32
layoutPage uint32
checksumPos uint64
}
// marshal encodes the header to its 80 wire bytes.
func (h header) marshal() []byte {
b := make([]byte, headerLen)
le := binary.LittleEndian
le.PutUint32(b[0:], Magic)
le.PutUint16(b[4:], majorVersion)
le.PutUint16(b[6:], minorVersion)
copy(b[8:24], h.uuid[:])
le.PutUint32(b[24:], h.articleCount)
le.PutUint32(b[28:], h.clusterCount)
le.PutUint64(b[32:], h.urlPtrPos)
le.PutUint64(b[40:], h.titlePtrPos)
le.PutUint64(b[48:], h.clusterPtrPos)
le.PutUint64(b[56:], h.mimeListPos)
le.PutUint32(b[64:], h.mainPage)
le.PutUint32(b[68:], h.layoutPage)
le.PutUint64(b[72:], h.checksumPos)
return b
}
// parseHeader decodes and validates an 80-byte header.
func parseHeader(b []byte) (header, error) {
var h header
if len(b) < headerLen {
return h, fmt.Errorf("zim: short header: %d bytes", len(b))
}
le := binary.LittleEndian
if le.Uint32(b[0:]) != Magic {
return h, fmt.Errorf("zim: bad magic, not a ZIM file")
}
copy(h.uuid[:], b[8:24])
h.articleCount = le.Uint32(b[24:])
h.clusterCount = le.Uint32(b[28:])
h.urlPtrPos = le.Uint64(b[32:])
h.titlePtrPos = le.Uint64(b[40:])
h.clusterPtrPos = le.Uint64(b[48:])
h.mimeListPos = le.Uint64(b[56:])
h.mainPage = le.Uint32(b[64:])
h.layoutPage = le.Uint32(b[68:])
h.checksumPos = le.Uint64(b[72:])
return h, nil
}
+339
View File
@@ -0,0 +1,339 @@
package zim
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"sync"
)
// ErrNotFound is returned by Get when no entry matches the namespace and url.
// Callers (such as the HTTP handler) test for it with errors.Is to map a miss
// to a 404.
var ErrNotFound = errors.New("zim: not found")
// Reader provides random access to a ZIM file's entries. Open one with Open or
// NewReader, then look entries up by namespace and url, or fetch the main page.
// Decompressed clusters are cached so repeated reads from one cluster are cheap.
type Reader struct {
ra io.ReaderAt
closer io.Closer
size int64
hdr header
mimes []string
mu sync.Mutex
cache map[uint32][]byte // cluster index -> decompressed data section
cacheExtended map[uint32]bool // cluster index -> uint64-offset cluster
}
// Blob is the result of a lookup: the resolved entry's bytes and metadata.
type Blob struct {
Namespace byte
URL string
Title string
MimeType string
Data []byte
}
// Open opens a ZIM file on disk. Close the returned reader when done.
func Open(path string) (*Reader, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
fi, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
r, err := NewReader(f, fi.Size())
if err != nil {
_ = f.Close()
return nil, err
}
r.closer = f
return r, nil
}
// NewReader reads the header and MIME list from ra, which must hold size bytes.
func NewReader(ra io.ReaderAt, size int64) (*Reader, error) {
r := &Reader{ra: ra, size: size, cache: map[uint32][]byte{}}
hb, err := r.at(0, headerLen)
if err != nil {
return nil, fmt.Errorf("zim: read header: %w", err)
}
r.hdr, err = parseHeader(hb)
if err != nil {
return nil, err
}
if r.hdr.mimeListPos > r.hdr.urlPtrPos || r.hdr.urlPtrPos > uint64(size) {
return nil, fmt.Errorf("zim: inconsistent header offsets")
}
mb, err := r.at(r.hdr.mimeListPos, int(r.hdr.urlPtrPos-r.hdr.mimeListPos))
if err != nil {
return nil, fmt.Errorf("zim: read mime list: %w", err)
}
for _, part := range bytes.Split(mb, []byte{0}) {
if len(part) == 0 {
break
}
r.mimes = append(r.mimes, string(part))
}
return r, nil
}
// Close releases the underlying file, if Open created one.
func (r *Reader) Close() error {
if r.closer != nil {
return r.closer.Close()
}
return nil
}
// Count returns the number of directory entries.
func (r *Reader) Count() uint32 { return r.hdr.articleCount }
// MimeTypes returns the archive's MIME-type list.
func (r *Reader) MimeTypes() []string { return r.mimes }
// MainPage returns the archive's entry point, or an error if none is set.
func (r *Reader) MainPage() (Blob, error) {
if r.hdr.mainPage == noMainPage {
return Blob{}, fmt.Errorf("zim: no main page")
}
return r.blobAtIndex(r.hdr.mainPage, 0)
}
// Get resolves the entry at (namespace, url), following one or more redirects.
func (r *Reader) Get(namespace byte, url string) (Blob, error) {
target := key(namespace, url)
lo, hi := uint32(0), r.hdr.articleCount
for lo < hi {
mid := lo + (hi-lo)/2
d, err := r.direntAtIndex(mid)
if err != nil {
return Blob{}, err
}
switch k := key(d.namespace, d.url); {
case k < target:
lo = mid + 1
case k > target:
hi = mid
default:
return r.blobAtIndex(mid, 0)
}
}
return Blob{}, fmt.Errorf("%w: %c/%s", ErrNotFound, namespace, url)
}
const maxRedirectHops = 16
func (r *Reader) blobAtIndex(idx uint32, hop int) (Blob, error) {
if hop > maxRedirectHops {
return Blob{}, fmt.Errorf("zim: redirect loop")
}
d, err := r.direntAtIndex(idx)
if err != nil {
return Blob{}, err
}
if d.redirect {
return r.blobAtIndex(d.targetIndex, hop+1)
}
data, err := r.blobData(d.cluster, d.blob)
if err != nil {
return Blob{}, err
}
mime := ""
if int(d.mimeIdx) < len(r.mimes) {
mime = r.mimes[d.mimeIdx]
}
return Blob{Namespace: d.namespace, URL: d.url, Title: d.title, MimeType: mime, Data: data}, nil
}
type dirent struct {
mimeIdx uint16
namespace byte
url, title string
cluster uint32
blob uint32
redirect bool
targetIndex uint32
}
func (r *Reader) direntAtIndex(idx uint32) (dirent, error) {
pb, err := r.at(r.hdr.urlPtrPos+8*uint64(idx), 8)
if err != nil {
return dirent{}, err
}
return r.direntAt(binary.LittleEndian.Uint64(pb))
}
func (r *Reader) direntAt(off uint64) (dirent, error) {
// Read a window large enough for the fixed head plus url and title; grow if
// either string is not terminated within it.
window := 512
for {
b, err := r.at(off, window)
if err != nil && len(b) == 0 {
return dirent{}, err
}
var d dirent
le := binary.LittleEndian
d.mimeIdx = le.Uint16(b[0:])
d.namespace = b[3]
var p int
if d.mimeIdx == redirectEntry {
d.redirect = true
d.targetIndex = le.Uint32(b[8:])
p = 12
} else {
d.cluster = le.Uint32(b[8:])
d.blob = le.Uint32(b[12:])
p = 16
}
url, n1, ok := readCString(b, p)
if !ok {
if window >= 1<<20 || off+uint64(window) >= uint64(r.size) {
return dirent{}, fmt.Errorf("zim: unterminated url at %d", off)
}
window *= 4
continue
}
title, _, ok := readCString(b, n1)
if !ok {
if window >= 1<<20 || off+uint64(window) >= uint64(r.size) {
return dirent{}, fmt.Errorf("zim: unterminated title at %d", off)
}
window *= 4
continue
}
d.url, d.title = url, title
return d, nil
}
}
// blobData returns one blob's bytes, decompressing and caching its cluster.
func (r *Reader) blobData(cluster, blob uint32) ([]byte, error) {
data, extended, err := r.clusterData(cluster)
if err != nil {
return nil, err
}
w := uint32(4)
if extended {
w = 8
}
need := int((blob + 2) * w)
if need > len(data) {
return nil, fmt.Errorf("zim: blob %d out of range in cluster %d", blob, cluster)
}
o0 := readUint(data[blob*w:], w)
o1 := readUint(data[(blob+1)*w:], w)
if o0 > o1 || int(o1) > len(data) {
return nil, fmt.Errorf("zim: bad blob offsets in cluster %d", cluster)
}
out := make([]byte, o1-o0)
copy(out, data[o0:o1])
return out, nil
}
func (r *Reader) clusterData(cluster uint32) (data []byte, extended bool, err error) {
r.mu.Lock()
if c, ok := r.cache[cluster]; ok {
r.mu.Unlock()
// extended-ness is recoverable from the info byte, but the cache stores
// already-decoded data whose offsets we re-read with the recorded width.
return c, r.cacheExtended[cluster], nil
}
r.mu.Unlock()
start, err := r.clusterOffset(cluster)
if err != nil {
return nil, false, err
}
end := r.hdr.checksumPos
if cluster+1 < r.hdr.clusterCount {
if end, err = r.clusterOffset(cluster + 1); err != nil {
return nil, false, err
}
}
if start >= end || end > uint64(r.size) {
return nil, false, fmt.Errorf("zim: bad cluster bounds for %d", cluster)
}
raw, err := r.at(start, int(end-start))
if err != nil {
return nil, false, err
}
info := raw[0]
comp := info & 0x0f
extended = info&extendedFlag != 0
body := raw[1:]
switch comp {
case compNone:
data = body
case compZstd:
if data, err = zstdDecode(body); err != nil {
return nil, false, fmt.Errorf("zim: zstd cluster %d: %w", cluster, err)
}
case compXZ:
return nil, false, fmt.Errorf("zim: xz clusters are not supported for reading")
default:
return nil, false, fmt.Errorf("zim: unknown compression %d in cluster %d", comp, cluster)
}
r.mu.Lock()
r.cache[cluster] = data
if r.cacheExtended == nil {
r.cacheExtended = map[uint32]bool{}
}
r.cacheExtended[cluster] = extended
r.mu.Unlock()
return data, extended, nil
}
func (r *Reader) clusterOffset(cluster uint32) (uint64, error) {
b, err := r.at(r.hdr.clusterPtrPos+8*uint64(cluster), 8)
if err != nil {
return 0, err
}
return binary.LittleEndian.Uint64(b), nil
}
// at reads n bytes at off, clamped to the file size.
func (r *Reader) at(off uint64, n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("zim: negative read length")
}
if off > uint64(r.size) {
return nil, io.EOF
}
if off+uint64(n) > uint64(r.size) {
n = int(uint64(r.size) - off)
}
b := make([]byte, n)
if n == 0 {
return b, nil
}
_, err := r.ra.ReadAt(b, int64(off))
return b, err
}
func readCString(b []byte, start int) (string, int, bool) {
if start > len(b) {
return "", start, false
}
i := bytes.IndexByte(b[start:], 0)
if i < 0 {
return "", start, false
}
return string(b[start : start+i]), start + i + 1, true
}
func readUint(b []byte, width uint32) uint32 {
if width == 8 {
return uint32(binary.LittleEndian.Uint64(b))
}
return binary.LittleEndian.Uint32(b)
}
+392
View File
@@ -0,0 +1,392 @@
package zim
import (
"crypto/md5"
"encoding/binary"
"fmt"
"io"
"sort"
)
// maxClusterContent caps how much blob content accumulates in one cluster
// before a new one is started, balancing compression ratio against the cost of
// decompressing a whole cluster to read one small blob.
const maxClusterContent = 2 << 20 // 2 MiB
// Writer accumulates entries and serialises them as a ZIM file. Build it with
// NewWriter, add content/redirects/metadata, optionally set a main page, then
// call WriteTo. The writer holds entries in memory; a kage mirror comfortably
// fits, and packing is a one-shot batch job.
type Writer struct {
entries []*entry
byKey map[string]*entry
mainKey string
noCompress bool
}
type entry struct {
namespace byte
url string
title string
mime string
data []byte
redirect bool
targetKey string // "<ns><url>" of the redirect target
// assigned during planning
mimeIdx uint16
cluster uint32
blob uint32
targetIndex uint32
urlIndex uint32
position uint64
}
func key(ns byte, url string) string { return string(ns) + url }
// NewWriter returns an empty Writer.
func NewWriter() *Writer {
return &Writer{byKey: map[string]*entry{}}
}
// SetNoCompress stores every cluster uncompressed. Useful when the input is
// already compressed or when a reader without zstd must open the file.
func (w *Writer) SetNoCompress(v bool) { w.noCompress = v }
// AddContent adds a content entry. A later add with the same namespace and url
// replaces the earlier one. An empty title defaults to the url.
func (w *Writer) AddContent(namespace byte, url, title, mime string, data []byte) {
if title == "" {
title = url
}
if mime == "" {
mime = "application/octet-stream"
}
w.put(&entry{namespace: namespace, url: url, title: title, mime: mime, data: data})
}
// AddMetadata adds an 'M' namespace text entry, e.g. AddMetadata("Title", "...").
func (w *Writer) AddMetadata(name, value string) {
w.put(&entry{namespace: NamespaceMetadata, url: name, title: name, mime: "text/plain", data: []byte(value)})
}
// AddRedirect adds a redirect from (namespace,url) to (targetNamespace,targetURL).
func (w *Writer) AddRedirect(namespace byte, url, title string, targetNamespace byte, targetURL string) {
if title == "" {
title = url
}
w.put(&entry{namespace: namespace, url: url, title: title, redirect: true, targetKey: key(targetNamespace, targetURL)})
}
// SetMainPage marks an entry as the archive's entry point.
func (w *Writer) SetMainPage(namespace byte, url string) { w.mainKey = key(namespace, url) }
func (w *Writer) put(e *entry) {
k := key(e.namespace, e.url)
if old, ok := w.byKey[k]; ok {
*old = *e // replace in place, keep slice order
return
}
w.byKey[k] = e
w.entries = append(w.entries, e)
}
// plan holds the prebuilt sections of the file, ready to emit in order.
type plan struct {
hdr header
mimeList []byte
urlPtrs []byte
titlePtrs []byte
clusterPtrs []byte
dirents [][]byte // URL order
clusters [][]byte
}
// WriteTo serialises the archive to out and returns the number of bytes written.
func (w *Writer) WriteTo(out io.Writer) (int64, error) {
p, err := w.buildPlan()
if err != nil {
return 0, err
}
sum := md5.New()
mw := io.MultiWriter(out, sum)
var n int64
write := func(b []byte) error {
m, err := mw.Write(b)
n += int64(m)
return err
}
for _, section := range append([][]byte{
p.hdr.marshal(), p.mimeList, p.urlPtrs, p.titlePtrs, p.clusterPtrs,
}, append(p.dirents, p.clusters...)...) {
if err := write(section); err != nil {
return n, err
}
}
// The MD5 covers everything before it and is not itself hashed.
m, err := out.Write(sum.Sum(nil))
n += int64(m)
return n, err
}
func (w *Writer) buildPlan() (plan, error) {
var p plan
// 1. URL order: sort by <namespace><url>, assign indices.
ents := make([]*entry, len(w.entries))
copy(ents, w.entries)
sort.Slice(ents, func(i, j int) bool {
return key(ents[i].namespace, ents[i].url) < key(ents[j].namespace, ents[j].url)
})
index := make(map[string]uint32, len(ents))
for i, e := range ents {
e.urlIndex = uint32(i)
index[key(e.namespace, e.url)] = uint32(i)
}
// 2. Resolve redirect targets.
for _, e := range ents {
if !e.redirect {
continue
}
ti, ok := index[e.targetKey]
if !ok {
return p, fmt.Errorf("zim: redirect %q points at missing target %q", key(e.namespace, e.url), e.targetKey)
}
e.targetIndex = ti
}
// 3. MIME list (first-seen order over content entries).
var mimes []string
mimeIndex := map[string]uint16{}
for _, e := range ents {
if e.redirect {
continue
}
if _, ok := mimeIndex[e.mime]; !ok {
mimeIndex[e.mime] = uint16(len(mimes))
mimes = append(mimes, e.mime)
}
e.mimeIdx = mimeIndex[e.mime]
}
p.mimeList = encodeMimeList(mimes)
// 4. Cluster packing: split text vs binary, cap each cluster, assign blobs.
clusters := w.packClusters(ents)
p.clusters = make([][]byte, len(clusters))
for i, c := range clusters {
p.clusters[i] = c.encode(w.noCompress)
}
// 5. Directory entry bytes (URL order).
p.dirents = make([][]byte, len(ents))
for i, e := range ents {
p.dirents[i] = e.encodeDirent()
}
// 6. Layout: assign absolute positions.
count := uint32(len(ents))
pos := uint64(headerLen)
mimeListPos := pos
pos += uint64(len(p.mimeList))
urlPtrPos := pos
pos += 8 * uint64(count)
titlePtrPos := pos
pos += 4 * uint64(count)
clusterPtrPos := pos
pos += 8 * uint64(len(p.clusters))
for i, e := range ents {
e.position = pos
pos += uint64(len(p.dirents[i]))
}
clusterPos := make([]uint64, len(p.clusters))
for i := range p.clusters {
clusterPos[i] = pos
pos += uint64(len(p.clusters[i]))
}
checksumPos := pos
// 7. Pointer lists.
p.urlPtrs = make([]byte, 8*count)
for i, e := range ents {
binary.LittleEndian.PutUint64(p.urlPtrs[8*i:], e.position)
}
p.clusterPtrs = make([]byte, 8*len(clusterPos))
for i, cp := range clusterPos {
binary.LittleEndian.PutUint64(p.clusterPtrs[8*i:], cp)
}
p.titlePtrs = encodeTitlePtrs(ents)
// 8. Header.
p.hdr = header{
uuid: deriveUUID(ents),
articleCount: count,
clusterCount: uint32(len(p.clusters)),
urlPtrPos: urlPtrPos,
titlePtrPos: titlePtrPos,
clusterPtrPos: clusterPtrPos,
mimeListPos: mimeListPos,
mainPage: noMainPage,
layoutPage: noMainPage,
checksumPos: checksumPos,
}
if w.mainKey != "" {
if mi, ok := index[w.mainKey]; ok {
p.hdr.mainPage = mi
}
}
return p, nil
}
// clusterBuf accumulates blobs destined for one cluster.
type clusterBuf struct {
comp uint8
blobs [][]byte
size int
}
func (w *Writer) packClusters(ents []*entry) []*clusterBuf {
var clusters []*clusterBuf
var curText, curBin *clusterBuf
closeIf := func(c **clusterBuf) {
if *c != nil && (*c).size >= maxClusterContent {
*c = nil
}
}
add := func(cur **clusterBuf, comp uint8, e *entry) {
if *cur == nil {
*cur = &clusterBuf{comp: comp}
clusters = append(clusters, *cur)
}
c := *cur
e.cluster = uint32(indexOf(clusters, c))
e.blob = uint32(len(c.blobs))
c.blobs = append(c.blobs, e.data)
c.size += len(e.data)
}
for _, e := range ents {
if e.redirect {
continue
}
if isTextMime(e.mime) {
add(&curText, compZstd, e)
closeIf(&curText)
} else {
add(&curBin, compNone, e)
closeIf(&curBin)
}
}
return clusters
}
func indexOf(cs []*clusterBuf, c *clusterBuf) int {
for i := range cs {
if cs[i] == c {
return i
}
}
return -1
}
// encode renders a cluster: an info byte followed by the (optionally zstd)
// data section, which is an offset table of (N+1) uint32 values then the N
// concatenated blobs. Offsets are relative to the start of the data section.
func (c *clusterBuf) encode(noCompress bool) []byte {
n := len(c.blobs)
tableLen := 4 * (n + 1)
total := tableLen
for _, b := range c.blobs {
total += len(b)
}
data := make([]byte, tableLen, total)
off := uint32(tableLen)
binary.LittleEndian.PutUint32(data[0:], off)
for i, b := range c.blobs {
off += uint32(len(b))
binary.LittleEndian.PutUint32(data[4*(i+1):], off)
}
for _, b := range c.blobs {
data = append(data, b...)
}
comp := c.comp
if noCompress {
comp = compNone
}
payload := data
if comp == compZstd {
payload = zstdEncode(data)
} else {
comp = compNone
}
out := make([]byte, 0, len(payload)+1)
out = append(out, comp) // non-extended: bit 4 clear, uint32 offsets
return append(out, payload...)
}
func (e *entry) encodeDirent() []byte {
le := binary.LittleEndian
var head []byte
if e.redirect {
head = make([]byte, 12)
le.PutUint16(head[0:], redirectEntry)
head[3] = e.namespace
le.PutUint32(head[8:], e.targetIndex)
} else {
head = make([]byte, 16)
le.PutUint16(head[0:], e.mimeIdx)
head[3] = e.namespace
le.PutUint32(head[8:], e.cluster)
le.PutUint32(head[12:], e.blob)
}
out := append(head, e.url...)
out = append(out, 0)
out = append(out, e.title...)
return append(out, 0)
}
func encodeMimeList(mimes []string) []byte {
var b []byte
for _, m := range mimes {
b = append(b, m...)
b = append(b, 0)
}
return append(b, 0) // terminating empty string
}
func encodeTitlePtrs(ents []*entry) []byte {
order := make([]*entry, len(ents))
copy(order, ents)
sort.Slice(order, func(i, j int) bool {
ti := string(order[i].namespace) + order[i].title
tj := string(order[j].namespace) + order[j].title
if ti != tj {
return ti < tj
}
return order[i].urlIndex < order[j].urlIndex
})
b := make([]byte, 4*len(order))
for i, e := range order {
binary.LittleEndian.PutUint32(b[4*i:], e.urlIndex)
}
return b
}
// deriveUUID makes the file deterministic: identical input yields an identical
// archive. It hashes every entry's key and content, so repacking the same
// mirror is idempotent and diffable.
func deriveUUID(ents []*entry) [16]byte {
h := md5.New()
var n [8]byte
for _, e := range ents {
h.Write([]byte(key(e.namespace, e.url)))
binary.LittleEndian.PutUint64(n[:], uint64(len(e.data)))
h.Write(n[:])
h.Write(e.data)
}
var u [16]byte
copy(u[:], h.Sum(nil))
return u
}
+136
View File
@@ -0,0 +1,136 @@
package zim
import (
"bytes"
"crypto/md5"
"strings"
"testing"
)
// buildSample writes a small archive exercising text, binary, metadata, a
// redirect, and a main page, and returns its bytes.
func buildSample(t *testing.T, noCompress bool) []byte {
t.Helper()
w := NewWriter()
w.SetNoCompress(noCompress)
w.AddContent(NamespaceContent, "index.html", "Home", "text/html",
[]byte("<h1>Home</h1>"+strings.Repeat(" word", 500)))
w.AddContent(NamespaceContent, "about/index.html", "About", "text/html",
[]byte("<h1>About</h1>"))
w.AddContent(NamespaceContent, "_kage/h/logo.png", "", "image/png",
[]byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3, 4, 5})
w.AddMetadata("Title", "Sample")
w.AddMetadata("Language", "eng")
w.AddRedirect(NamespaceWellKnown, "mainPage", "Main", NamespaceContent, "index.html")
w.SetMainPage(NamespaceContent, "index.html")
var buf bytes.Buffer
n, err := w.WriteTo(&buf)
if err != nil {
t.Fatalf("WriteTo: %v", err)
}
if int(n) != buf.Len() {
t.Fatalf("WriteTo reported %d bytes, buffer has %d", n, buf.Len())
}
return buf.Bytes()
}
func TestRoundTrip(t *testing.T) {
for _, noCompress := range []bool{false, true} {
data := buildSample(t, noCompress)
r, err := NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("NewReader (noCompress=%v): %v", noCompress, err)
}
// Content round-trips with the right mime.
home, err := r.Get(NamespaceContent, "index.html")
if err != nil {
t.Fatalf("Get home: %v", err)
}
if !strings.HasPrefix(string(home.Data), "<h1>Home</h1>") {
t.Errorf("home content wrong: %.20q", home.Data)
}
if home.MimeType != "text/html" {
t.Errorf("home mime = %q", home.MimeType)
}
// Binary blob survives byte-for-byte.
logo, err := r.Get(NamespaceContent, "_kage/h/logo.png")
if err != nil {
t.Fatalf("Get logo: %v", err)
}
if !bytes.Equal(logo.Data, []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3, 4, 5}) {
t.Errorf("logo bytes wrong: %v", logo.Data)
}
if logo.MimeType != "image/png" {
t.Errorf("logo mime = %q", logo.MimeType)
}
// Metadata.
meta, err := r.Get(NamespaceMetadata, "Title")
if err != nil || string(meta.Data) != "Sample" {
t.Errorf("metadata Title = %q, %v", meta.Data, err)
}
// Redirect resolves to the target's content.
red, err := r.Get(NamespaceWellKnown, "mainPage")
if err != nil {
t.Fatalf("Get redirect: %v", err)
}
if !strings.HasPrefix(string(red.Data), "<h1>Home</h1>") {
t.Errorf("redirect did not resolve to home: %.20q", red.Data)
}
// Main page.
mp, err := r.MainPage()
if err != nil {
t.Fatalf("MainPage: %v", err)
}
if !strings.HasPrefix(string(mp.Data), "<h1>Home</h1>") {
t.Errorf("main page wrong: %.20q", mp.Data)
}
// Misses error.
if _, err := r.Get(NamespaceContent, "nope.html"); err == nil {
t.Error("expected miss to error")
}
}
}
func TestChecksum(t *testing.T) {
data := buildSample(t, false)
if len(data) < 16 {
t.Fatal("archive too short")
}
body, sum := data[:len(data)-16], data[len(data)-16:]
want := md5.Sum(body)
if !bytes.Equal(sum, want[:]) {
t.Errorf("trailing MD5 does not match body hash")
}
}
func TestDeterministic(t *testing.T) {
a := buildSample(t, false)
b := buildSample(t, false)
if !bytes.Equal(a, b) {
t.Error("same input produced different archives; packing is not deterministic")
}
}
func TestMagicAndHeader(t *testing.T) {
data := buildSample(t, false)
h, err := parseHeader(data[:headerLen])
if err != nil {
t.Fatalf("parseHeader: %v", err)
}
if h.checksumPos != uint64(len(data)-16) {
t.Errorf("checksumPos = %d, want %d", h.checksumPos, len(data)-16)
}
if h.articleCount != 6 {
t.Errorf("articleCount = %d, want 6", h.articleCount)
}
if h.mainPage == noMainPage {
t.Error("main page not set in header")
}
}