ffdb4ca969
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.
340 lines
8.4 KiB
Go
340 lines
8.4 KiB
Go
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)
|
|
}
|