108b80f2fb
Compressing clusters with zstd is the slow part of packing a large mirror. When a mirror is re-packed after a small change, most clusters are byte-identical and there is no reason to compress them again. pack --incremental keeps a content-addressed cache of compressed clusters in a sidecar next to the output. On the next pack, a cluster whose uncompressed bytes match the cache is served from it instead of being recompressed; only new or changed clusters go through zstd. A cache hit returns exactly what a fresh compression would, so the archive stays deterministic and byte-identical to a cold pack. The zim writer gains a settable cluster compressor so the cache can hook in without changing the format. The cache only writes back the clusters it touched this run, so clusters that left the mirror drop out and it cannot grow without bound.
422 lines
11 KiB
Go
422 lines
11 KiB
Go
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
|
|
// compress turns a cluster's uncompressed data section into its stored bytes.
|
|
// It defaults to the built-in zstd codec; a caller can replace it with a
|
|
// caching compressor so a re-pack reuses the compression of unchanged
|
|
// clusters instead of running zstd again.
|
|
compress func([]byte) []byte
|
|
}
|
|
|
|
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{}, compress: zstdEncode}
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// SetCompress replaces the cluster compressor. The function must return
|
|
// zstd-compressed bytes (the writer marks those clusters as zstd), so a caching
|
|
// wrapper can short-circuit unchanged clusters while still producing a valid,
|
|
// byte-identical archive. A nil function restores the built-in codec.
|
|
func (w *Writer) SetCompress(f func([]byte) []byte) {
|
|
if f == nil {
|
|
f = zstdEncode
|
|
}
|
|
w.compress = f
|
|
}
|
|
|
|
// 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)})
|
|
}
|
|
|
|
// AddMetadataBytes adds an 'M' namespace entry with an explicit MIME, for binary
|
|
// metadata such as Illustrator_48x48@1, the 48x48 PNG favicon Kiwix shows as the
|
|
// archive's icon.
|
|
func (w *Writer) AddMetadataBytes(name, mime string, data []byte) {
|
|
if mime == "" {
|
|
mime = "application/octet-stream"
|
|
}
|
|
w.put(&entry{namespace: NamespaceMetadata, url: name, title: name, mime: mime, data: data})
|
|
}
|
|
|
|
// 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, w.compress)
|
|
}
|
|
|
|
// 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, compress func([]byte) []byte) []byte {
|
|
if compress == nil {
|
|
compress = zstdEncode
|
|
}
|
|
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 = compress(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
|
|
}
|