Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
Laurence Withers
5b8873a4f5 Handler: add NewMapped, AddMapped
The Handler now supports serving multiple packfiles, possibly with
different prefixes. Precedence is given by order in which packfiles are
registered.
2026-06-06 11:54:39 +01:00
Laurence Withers
5bece326fb Handler: support zstd encoding 2026-06-06 11:37:58 +01:00
Laurence Withers
0016a5e4fd packed: add zstd compressed field 2026-06-06 11:02:47 +01:00
Laurence Withers
351d279a22 packed: switch gogo/proto → google 2026-06-06 11:02:42 +01:00
Laurence Withers
203138bc05 Handler: use packed.MapAndLoad 2026-06-06 10:19:50 +01:00
Laurence Withers
cd9c8b600e packed: add ability to load from memory, add MapAndLoad
The ability to load a packfile from memory means that it becomes easy to
use the "embed" package to embed a small packfile.

The refactor to allow loading from memory also makes it easy to add a
MapAndLoad implementation, which means the file only needs to be opened
/ mmap()ed once.
2026-06-06 10:16:40 +01:00
Laurence Withers
5412b97411 Update Go version to 1.25, update golang.org/x/sys 2026-06-06 10:16:03 +01:00
10 changed files with 671 additions and 1200 deletions

11
go.mod
View file

@ -1,8 +1,9 @@
module src.lwithers.me.uk/go/htpack module src.lwithers.me.uk/go/htpack
require ( go 1.25.0
github.com/gogo/protobuf v1.3.2
golang.org/x/sys v0.22.0
)
go 1.22 require (
github.com/google/go-cmp v0.7.0
golang.org/x/sys v0.45.0
google.golang.org/protobuf v1.36.11
)

39
go.sum
View file

@ -1,33 +1,6 @@
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -3,49 +3,39 @@ package htpack
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"golang.org/x/sys/unix"
"src.lwithers.me.uk/go/htpack/packed" "src.lwithers.me.uk/go/htpack/packed"
) )
const ( const (
encodingGzip = "gzip" encodingGzip = "gzip"
encodingBrotli = "br" encodingBrotli = "br"
encodingZstd = "zstd"
) )
// New returns a new handler. Standard security headers are set. // New returns a new handler. Standard security headers are set.
func New(packfile string) (*Handler, error) { func New(packfile string) (*Handler, error) {
f, err := os.Open(packfile) mapped, err := packed.MapAndLoad(packfile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close() return NewMapped("/", mapped), nil
}
fi, err := f.Stat()
if err != nil {
return nil, err
}
mapped, err := unix.Mmap(int(f.Fd()), 0, int(fi.Size()),
unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
return nil, err
}
_, dir, err := packed.Load(f)
if err != nil {
unix.Munmap(mapped)
return nil, err
}
// NewMapped returns a new handler, given an already-mapped datafile. Standard
// security headers are set.
//
// Files are served under the directory prefix given by path, which may be
// empty or "/" for the root path. Note the [Handler.ServeMapped] method may specify
// additional files to be served under different paths, not affected by this
// path.
func NewMapped(path string, mapped *packed.Mapped) *Handler {
h := &Handler{ h := &Handler{
mapped: mapped, files: make(map[string]handlerFile),
dir: dir.Files,
headers: make(map[string]string), headers: make(map[string]string),
startTime: time.Now(), startTime: time.Now(),
} }
@ -56,16 +46,24 @@ func New(packfile string) (*Handler, error) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
h.SetHeader("X-Content-Type-Options", "nosniff") h.SetHeader("X-Content-Type-Options", "nosniff")
return h, nil h.addFiles(path, mapped)
return h
} }
// Handler implements http.Handler and allows options to be set. // Handler implements http.Handler and allows options to be set.
type Handler struct { type Handler struct {
mapped []byte files map[string]handlerFile
dir map[string]*packed.File
headers map[string]string headers map[string]string
startTime time.Time startTime time.Time
notFound *packed.File notFound *handlerFile
}
// AddMapped serves up additional files from the given mapped packfile. The
// given path is taken as a serving prefix. If a file for a given path already
// exists, the existing file takes precedence.
func (h *Handler) AddMapped(path string, mapped *packed.Mapped) {
h.addFiles(path, mapped)
} }
// SetHeader allows a custom header to be set on HTTP responses. These are // SetHeader allows a custom header to be set on HTTP responses. These are
@ -94,11 +92,11 @@ func (h *Handler) SetHeader(key, value string) {
// multiple times with different filenames (noting later calls would not // multiple times with different filenames (noting later calls would not
// overwrite files matching earlier calls). // overwrite files matching earlier calls).
func (h *Handler) SetIndex(filename string) { func (h *Handler) SetIndex(filename string) {
for k, v := range h.dir { for k, v := range h.files {
if filepath.Base(k) == filename { if path.Base(k) == filename {
routeToAdd := filepath.Dir(k) routeToAdd := path.Dir(k)
if _, exists := h.dir[routeToAdd]; !exists { if _, exists := h.files[routeToAdd]; !exists {
h.dir[routeToAdd] = v h.files[routeToAdd] = v
} }
} }
} }
@ -118,14 +116,62 @@ func (h *Handler) SetNotFound(notFound string) error {
} }
notFound = path.Clean(notFound) notFound = path.Clean(notFound)
dir := h.dir[path.Clean(notFound)] f, ok := h.files[path.Clean(notFound)]
if dir == nil { if !ok {
return fmt.Errorf("no such resource %q", notFound) return fmt.Errorf("no such resource %q", notFound)
} }
h.notFound = dir h.notFound = &f
return nil return nil
} }
type handlerFile struct {
contentType, etag string
uncompressed, gzip, brotli, zstd []byte
}
func (h *Handler) addFiles(prefix string, mapped *packed.Mapped) {
// our directory contains files like "/index.html". We will add
// a prefix, which might be "" or "/foo".
prefix = path.Clean(prefix)
prefix = strings.TrimSuffix(prefix, "/")
switch {
case prefix == "":
case prefix == ".":
prefix = ""
case !strings.HasPrefix(prefix, "/"):
prefix = "/" + prefix
}
for filePath, file := range mapped.Directory.Files {
handlerPath := prefix + filePath
if _, exists := h.files[handlerPath]; exists {
// don't overwrite existing entries
continue
}
h.files[handlerPath] = buildHandlerFile(mapped.Data, file)
}
}
func buildHandlerFile(data []byte, file *packed.File) handlerFile {
byteSlice := func(fileData *packed.FileData) []byte {
if fileData == nil {
return nil
}
start := fileData.Offset
end := start + fileData.Length
return data[start:end]
}
hf := handlerFile{
contentType: file.ContentType,
etag: file.Etag,
}
hf.uncompressed = byteSlice(file.Uncompressed)
hf.gzip = byteSlice(file.Gzip)
hf.brotli = byteSlice(file.Brotli)
hf.zstd = byteSlice(file.Zstd)
return hf
}
// ServeHTTP handles requests for files. It supports GET and HEAD methods, with // ServeHTTP handles requests for files. It supports GET and HEAD methods, with
// anything else returning a 405. Exact path matches are required, else a 404 is // anything else returning a 405. Exact path matches are required, else a 404 is
// returned. // returned.
@ -144,44 +190,48 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return return
} }
info := h.dir[path.Clean(req.URL.Path)] info, ok := h.files[path.Clean(req.URL.Path)]
if info == nil { if !ok {
if h.notFound == nil { if h.notFound == nil {
http.NotFound(w, req) http.NotFound(w, req)
return return
} }
info = h.notFound info = *h.notFound
} }
// set standard headers // set standard headers
w.Header().Set("Vary", "Accept-Encoding") w.Header().Set("Vary", "Accept-Encoding")
w.Header().Set("Etag", info.Etag) w.Header().Set("Etag", info.etag)
w.Header().Set("Content-Type", info.ContentType) w.Header().Set("Content-Type", info.contentType)
w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Accept-Ranges", "bytes")
// process etag / modtime // process etag / modtime
if clientHasCachedVersion(info.Etag, h.startTime, req) { if clientHasCachedVersion(info.etag, h.startTime, req) {
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return return
} }
// select compression // select compression
data := info.Uncompressed data := info.uncompressed
gzip, brotli := acceptedEncodings(req) gzip, brotli, zstd := acceptedEncodings(req)
if brotli && info.Brotli != nil { switch {
data = info.Brotli case zstd && info.zstd != nil:
data = info.zstd
w.Header().Set("Content-Encoding", encodingZstd)
case brotli && info.brotli != nil:
data = info.brotli
w.Header().Set("Content-Encoding", encodingBrotli) w.Header().Set("Content-Encoding", encodingBrotli)
} else if gzip && info.Gzip != nil { case gzip && info.gzip != nil:
data = info.Gzip data = info.gzip
w.Header().Set("Content-Encoding", encodingGzip) w.Header().Set("Content-Encoding", encodingGzip)
} }
// range support (single-part ranges only) // range support (single-part ranges only)
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests#Single_part_ranges // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests#Single_part_ranges
offset, length, isPartial := getFileRange(data, req) offset, length, isPartial := getFileRange(uint64(len(data)), req)
if isPartial { if isPartial {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d",
offset, offset+length-1, data.Length)) offset, offset+length-1, len(data)))
} }
// now we know exactly what we're writing, finalise HTTP header // now we know exactly what we're writing, finalise HTTP header
@ -197,11 +247,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return return
} }
offset += data.Offset w.Write(data[offset : offset+length])
w.Write(h.mapped[offset : offset+length])
} }
func acceptedEncodings(req *http.Request) (gzip, brotli bool) { func acceptedEncodings(req *http.Request) (gzip, brotli, zstd bool) {
encodings := req.Header.Get("Accept-Encoding") encodings := req.Header.Get("Accept-Encoding")
for _, enc := range strings.Split(encodings, ",") { for _, enc := range strings.Split(encodings, ",") {
switch strings.TrimSpace(enc) { switch strings.TrimSpace(enc) {
@ -244,8 +293,8 @@ func clientHasCachedVersion(etag string, startTime time.Time, req *http.Request,
// getFileRange returns the byte offset and length of the file to serve, along // getFileRange returns the byte offset and length of the file to serve, along
// with whether or not it's partial content. // with whether or not it's partial content.
func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint64, isPartial bool) { func getFileRange(dataLength uint64, req *http.Request) (offset, length uint64, isPartial bool) {
length = data.Length length = dataLength
// only accept "Range: bytes=…" // only accept "Range: bytes=…"
r := req.Header.Get("Range") r := req.Header.Get("Range")
@ -272,8 +321,8 @@ func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint
// validate the interval lies within the file // validate the interval lies within the file
switch { switch {
case from > to, case from > to,
from >= data.Length, from >= dataLength,
to >= data.Length: to >= dataLength:
return return
} }

49
handler_test.go Normal file
View file

@ -0,0 +1,49 @@
package htpack
import (
"bytes"
"io"
"net/http/httptest"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"src.lwithers.me.uk/go/htpack/packed"
)
func TestHandler(t *testing.T) {
mapped, err := packed.MapAndLoad("packed/testdata/helloworld.pack")
if err != nil {
t.Fatal("loading test packfile:", err)
}
defer mapped.Close()
orig, err := os.ReadFile("packed/testdata/helloworld.txt")
if err != nil {
t.Fatal("loading test textfile:", err)
}
h := NewMapped("", mapped)
h.AddMapped("/foo", mapped)
testsv := httptest.NewServer(h)
defer testsv.Close()
cl := testsv.Client()
url := testsv.URL + "/helloworld.txt"
resp, err := cl.Get(url)
if err != nil {
t.Fatal("executing GET request:", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal("reading response body:", err)
}
if !bytes.Equal(body, orig) {
t.Error("difference in response (- got, + expected):")
t.Error(cmp.Diff(string(body), string(orig)))
}
}

View file

@ -2,9 +2,13 @@ package packed
import ( import (
fmt "fmt" fmt "fmt"
"io/fs"
"os" "os"
"path" "path"
"strings" "strings"
"golang.org/x/sys/unix"
"google.golang.org/protobuf/proto"
) )
const ( const (
@ -17,14 +21,14 @@ const (
VersionInitial = 1 VersionInitial = 1
) )
// Load a ready-packed file. // Read a ready-packed file.
func Load(f *os.File) (*Header, *Directory, error) { func Read(f []byte) (*Header, *Directory, error) {
hdr, err := loadHeader(f) hdr, err := readHeader(f)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
dir, err := loadDirectory(f, hdr) dir, err := readDirectory(f, hdr)
if le, ok := err.(*LoadError); ok { if le, ok := err.(*LoadError); ok {
// augment error // augment error
le.Magic = hdr.Magic le.Magic = hdr.Magic
@ -33,20 +37,101 @@ func Load(f *os.File) (*Header, *Directory, error) {
return hdr, dir, err // we may have a partial dir return hdr, dir, err // we may have a partial dir
} }
// loadHeader retrieves and decodes the header from the start of the file. It // Mapped holds an mmap()ed packfile.
// ensures the magic number and the version number match. Errors are returned type Mapped struct {
// as type LoadError. Data []byte
func loadHeader(f *os.File) (*Header, error) { Header *Header
raw := make([]byte, 36) Directory *Directory
if _, err := f.ReadAt(raw, 0); err != nil { }
return nil, &LoadError{
Cause: IOError, func (m *Mapped) Close() error {
Underlying: err, return unix.Munmap(m.Data)
}
// MapAndLoad mmaps the named file and reads the pack data.
func MapAndLoad(fname string) (*Mapped, error) {
f, err := os.Open(fname)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, &fs.PathError{
Path: fname,
Op: "stat",
Err: err,
} }
} }
data, err := unix.Mmap(int(f.Fd()), 0, int(st.Size()), unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
return nil, &fs.PathError{
Path: fname,
Op: "mmap",
Err: err,
}
}
hdr, dir, err := Read(data)
if err != nil {
unix.Munmap(data)
return nil, err
}
return &Mapped{
Data: data,
Header: hdr,
Directory: dir,
}, nil
}
// Load a ready-packed file.
//
// Deprecated: use [Read] or [MapAndLoad].
func Load(f *os.File) (*Header, *Directory, error) {
// we need to know the size of the file in order to mmap it
st, err := f.Stat()
if err != nil {
return nil, nil, &fs.PathError{
Path: f.Name(),
Op: "stat",
Err: err,
}
}
// compatibility: old code may have worked with non-zero offset
offset, err := f.Seek(0, os.SEEK_CUR)
if err != nil {
return nil, nil, &fs.PathError{
Path: f.Name(),
Op: "seek",
Err: err,
}
}
// mmap the file
data, err := unix.Mmap(int(f.Fd()), 0, int(st.Size()), unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
return nil, nil, &fs.PathError{
Path: f.Name(),
Op: "mmap",
Err: err,
}
}
defer unix.Munmap(data)
// read as though it's from memory
return Read(data[offset:])
}
// readHeader retrieves and decodes the header from the start of the file. It
// ensures the magic number and the version number match. Errors are returned
// as type LoadError.
func readHeader(f []byte) (*Header, error) {
hdr := new(Header) hdr := new(Header)
if err := hdr.Unmarshal(raw); err != nil { if err := proto.Unmarshal(f[:36], hdr); err != nil {
return nil, &LoadError{ return nil, &LoadError{
Cause: HeaderUnmarshalError, Cause: HeaderUnmarshalError,
Underlying: err, Underlying: err,
@ -77,35 +162,22 @@ func loadHeader(f *os.File) (*Header, error) {
return hdr, nil return hdr, nil
} }
// loadDirectory reads the directory from a file. The directory is checked // readDirectory reads the directory from a file. The directory is checked
// for consistency (offsets, filenames) but not integrity (file data is not // for consistency (offsets, filenames) but not integrity (file data is not
// read/checksummed). // read/checksummed).
func loadDirectory(f *os.File, hdr *Header) (*Directory, error) { func readDirectory(f []byte, hdr *Header) (*Directory, error) {
fi, err := f.Stat() fileSize := uint64(len(f))
if err != nil { start := hdr.DirectoryOffset
return nil, &LoadError{ end := start + hdr.DirectoryLength
Cause: IOError,
Underlying: err,
}
}
fileSize := uint64(fi.Size())
if hdr.DirectoryOffset+hdr.DirectoryLength > fileSize { if end > fileSize {
return nil, &LoadError{ return nil, &LoadError{
Cause: BadOffsetError, Cause: BadOffsetError,
} }
} }
raw := make([]byte, hdr.DirectoryLength)
if _, err := f.ReadAt(raw, int64(hdr.DirectoryOffset)); err != nil {
return nil, &LoadError{
Cause: IOError,
Underlying: err,
}
}
dir := new(Directory) dir := new(Directory)
if err := dir.Unmarshal(raw); err != nil { if err := proto.Unmarshal(f[start:end], dir); err != nil {
return nil, &LoadError{ return nil, &LoadError{
Cause: DirectoryUnmarshalError, Cause: DirectoryUnmarshalError,
Underlying: err, Underlying: err,

39
packed/load_test.go Normal file
View file

@ -0,0 +1,39 @@
package packed
import (
"bytes"
"os"
"testing"
)
func TestMapAndLoad(t *testing.T) {
rawPack, err := os.ReadFile("testdata/helloworld.pack")
if err != nil {
t.Fatal(err)
}
rawText, err := os.ReadFile("testdata/helloworld.txt")
if err != nil {
t.Fatal(err)
}
mapped, err := MapAndLoad("testdata/helloworld.pack")
if err != nil {
t.Fatal(err)
}
defer mapped.Close()
if !bytes.Equal(mapped.Data, rawPack) {
t.Error("mapped data does not match raw file")
}
file := mapped.Directory.Files["/helloworld.txt"]
if file == nil {
t.Fatal("directory does not contain /helloworld.txt")
}
start := file.Uncompressed.Offset
end := start + file.Uncompressed.Length
if !bytes.Equal(rawText, mapped.Data[start:end]) {
t.Error("uncompressed file does not match testdata/helloworld.txt")
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package packed; package lwithers.htpack.packed;
option go_package = "src.lwithers.me.uk/htpack/packed";
// Header at start of file. This must be a fixed, known size. Fields cannot // Header at start of file. This must be a fixed, known size. Fields cannot
// be zero. // be zero.
@ -46,6 +47,9 @@ message File {
// Brotli compressed version of the file. // Brotli compressed version of the file.
FileData brotli = 5; FileData brotli = 5;
// Zstd compressed version of the file.
FileData zstd = 6;
} }
// FileData records the position of the file data within the pack. // FileData records the position of the file data within the pack.
@ -54,6 +58,6 @@ message FileData {
// the pack. // the pack.
fixed64 offset = 1; fixed64 offset = 1;
// Length is the // Length of the (possibly compressed) file data, in bytes.
fixed64 length = 2; fixed64 length = 2;
} }

BIN
packed/testdata/helloworld.pack vendored Normal file

Binary file not shown.

13
packed/testdata/helloworld.txt vendored Normal file
View file

@ -0,0 +1,13 @@
Hello, world
This file contains a few lines of plain text, really just enough
to trigger the "compression worthwhile" heuristic.
This file contains a few lines of plain text, really just enough
to trigger the "compression worthwhile" heuristic.
This file contains a few lines of plain text, really just enough
to trigger the "compression worthwhile" heuristic.
This file contains a few lines of plain text, really just enough
to trigger the "compression worthwhile" heuristic.