Files
nexus/internal/leviathan/squashfs/squashfs.go
T
2026-06-16 11:51:07 +01:00

783 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package squashfs implements a read-only SquashFS 4.0 extractor.
//
// Scope is deliberately pragmatic: gzip, xz and zstd-compressed images,
// basic and extended file and directory inodes, symlink inodes. Other
// compressors (lzma/lzo/lz4) return a structured error so the UI can show
// what's missing.
//
// The module never trusts its input: every table offset and length is
// bounded against the image size and a hard cap on uncompressed metadata
// block size. A malformed or truncated image returns an error rather
// than causing a wild read.
package squashfs
import (
"bytes"
"compress/zlib"
"encoding/binary"
"errors"
"fmt"
"io"
"path"
"sort"
"github.com/andydixon/nexus/internal/leviathan/document"
"github.com/klauspost/compress/zstd"
"github.com/ulikunitz/xz"
)
const (
magicLE uint32 = 0x73717368 // "hsqs"
metadataBlockSize = 8192
dataBlockMax = 1 << 20 // 1 MiB guard for a single data block
maxEntries = 1 << 20 // total files + dirs we're willing to walk
)
// Compression IDs from the spec.
const (
compGZIP = 1
compLZMA = 2
compLZO = 3
compXZ = 4
compLZ4 = 5
compZSTD = 6
)
// Inode type codes.
const (
itBasicDir = 1
itBasicFile = 2
itBasicSymlink = 3
itBasicBlockDev = 4
itBasicCharDev = 5
itBasicFifo = 6
itBasicSocket = 7
itExtDir = 8
itExtFile = 9
itExtSymlink = 10
itExtBlockDev = 11
itExtCharDev = 12
itExtFifo = 13
itExtSocket = 14
)
// Superblock is the parsed 96-byte header.
type Superblock struct {
Magic uint32
InodeCount uint32
ModTime uint32
BlockSize uint32
FragCount uint32
CompressionID uint16
BlockLog uint16
Flags uint16
IDCount uint16
VersionMajor uint16
VersionMinor uint16
RootInodeRef uint64
BytesUsed uint64
IDTableStart uint64
XattrTableStart uint64
InodeTable uint64
DirTable uint64
FragTable uint64
ExportTable uint64
}
// Entry describes a filesystem entry discovered by the walker.
type Entry struct {
Path string
Type string // "file", "dir", "symlink", "special"
Size int64
Mode uint16
UID uint16 // id_idx (not resolved)
GID uint16
Mtime uint32
InodeNumber uint32
InodeRef uint64 // 64-bit metadata reference (for targeted extraction)
LinkTarget string // for symlinks
// For file extraction; zero for non-files.
blocksStart uint64
fileSize int64
fragIndex uint32
fragOffset uint32
blockSizes []uint32 // raw (top bit=uncompressed)
}
// FS is the opened image handle.
type FS struct {
d *document.Document
sb Superblock
}
// Open validates the superblock and returns an FS for further queries.
func Open(d *document.Document) (*FS, error) {
head, err := d.Range(0, 96)
if err != nil {
return nil, fmt.Errorf("read superblock: %w", err)
}
sb, err := parseSuperblock(head)
if err != nil {
return nil, err
}
if sb.VersionMajor != 4 {
return nil, fmt.Errorf("unsupported squashfs version %d.%d (only 4.x)", sb.VersionMajor, sb.VersionMinor)
}
if sb.BytesUsed > uint64(d.Size()) {
return nil, fmt.Errorf("superblock bytes_used=%d exceeds document size=%d", sb.BytesUsed, d.Size())
}
return &FS{d: d, sb: sb}, nil
}
// Superblock returns the parsed header (copy).
func (f *FS) Superblock() Superblock { return f.sb }
// CompressionName returns the human name of the image compressor.
func (f *FS) CompressionName() string {
return compressionName(f.sb.CompressionID)
}
func compressionName(id uint16) string {
switch id {
case compGZIP:
return "gzip"
case compLZMA:
return "lzma"
case compLZO:
return "lzo"
case compXZ:
return "xz"
case compLZ4:
return "lz4"
case compZSTD:
return "zstd"
}
return fmt.Sprintf("unknown(%d)", id)
}
func parseSuperblock(b []byte) (Superblock, error) {
var s Superblock
if len(b) < 96 {
return s, errors.New("superblock: short read")
}
s.Magic = binary.LittleEndian.Uint32(b[0:4])
if s.Magic != magicLE {
return s, fmt.Errorf("bad magic 0x%08x (want 0x%08x)", s.Magic, magicLE)
}
s.InodeCount = binary.LittleEndian.Uint32(b[4:8])
s.ModTime = binary.LittleEndian.Uint32(b[8:12])
s.BlockSize = binary.LittleEndian.Uint32(b[12:16])
s.FragCount = binary.LittleEndian.Uint32(b[16:20])
s.CompressionID = binary.LittleEndian.Uint16(b[20:22])
s.BlockLog = binary.LittleEndian.Uint16(b[22:24])
s.Flags = binary.LittleEndian.Uint16(b[24:26])
s.IDCount = binary.LittleEndian.Uint16(b[26:28])
s.VersionMajor = binary.LittleEndian.Uint16(b[28:30])
s.VersionMinor = binary.LittleEndian.Uint16(b[30:32])
s.RootInodeRef = binary.LittleEndian.Uint64(b[32:40])
s.BytesUsed = binary.LittleEndian.Uint64(b[40:48])
s.IDTableStart = binary.LittleEndian.Uint64(b[48:56])
s.XattrTableStart = binary.LittleEndian.Uint64(b[56:64])
s.InodeTable = binary.LittleEndian.Uint64(b[64:72])
s.DirTable = binary.LittleEndian.Uint64(b[72:80])
s.FragTable = binary.LittleEndian.Uint64(b[80:88])
s.ExportTable = binary.LittleEndian.Uint64(b[88:96])
return s, nil
}
// ---------- metadata block reader ----------
func (f *FS) decompress(buf []byte) ([]byte, error) {
return f.decompressWithLimit(buf, metadataBlockSize+16)
}
func (f *FS) decompressWithLimit(buf []byte, limit int64) ([]byte, error) {
var r io.ReadCloser
switch f.sb.CompressionID {
case compGZIP:
zr, err := zlib.NewReader(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("zlib init: %w", err)
}
r = zr
case compXZ:
xr, err := xz.NewReader(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("xz init: %w", err)
}
r = io.NopCloser(xr)
case compZSTD:
zr, err := zstd.NewReader(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("zstd init: %w", err)
}
r = zr.IOReadCloser()
default:
return nil, fmt.Errorf("compressor %q not implemented in this build", compressionName(f.sb.CompressionID))
}
defer r.Close()
return readLimited(r, limit)
}
// readMetadataBlock reads one metadata block starting at absolute offset
// `off` and returns (data, bytesConsumed, error). bytesConsumed includes
// the 2-byte header.
func (f *FS) readMetadataBlock(off int64) ([]byte, int64, error) {
if off < 0 || off+2 > f.d.Size() {
return nil, 0, fmt.Errorf("metadata block offset %d out of range", off)
}
hdrBuf, err := f.d.Range(off, 2)
if err != nil {
return nil, 0, err
}
hdr := binary.LittleEndian.Uint16(hdrBuf)
compressed := hdr&0x8000 == 0
size := int64(hdr & 0x7fff)
if size == 0 || size > metadataBlockSize {
return nil, 0, fmt.Errorf("metadata block size %d invalid", size)
}
body, err := f.d.Range(off+2, size)
if err != nil {
return nil, 0, err
}
if !compressed {
return append([]byte(nil), body...), size + 2, nil
}
out, err := f.decompress(body)
if err != nil {
return nil, 0, err
}
return out, size + 2, nil
}
// metaCursor streams uncompressed data out of a chain of metadata blocks.
// table[i] is the absolute file offset of the i-th metadata block, plus
// a running byte cursor within the uncompressed stream.
type metaCursor struct {
f *FS
base int64 // absolute file offset of first metadata block
blkAbs int64 // absolute file offset of *current* metadata block
inBlk int // index within current uncompressed block
buffer []byte // current decompressed block
}
func (f *FS) metaCursorAt(base int64, blockOffset uint32, inBlk uint16) (*metaCursor, error) {
mc := &metaCursor{f: f, base: base, blkAbs: base + int64(blockOffset)}
if err := mc.load(); err != nil {
return nil, err
}
if int(inBlk) > len(mc.buffer) {
return nil, fmt.Errorf("inode intra-block offset %d > block length %d", inBlk, len(mc.buffer))
}
mc.inBlk = int(inBlk)
return mc, nil
}
func (mc *metaCursor) load() error {
data, _, err := mc.f.readMetadataBlock(mc.blkAbs)
if err != nil {
return err
}
mc.buffer = data
mc.inBlk = 0
return nil
}
// advance one metadata block. The on-disk size is the consumed length of
// the current compressed block, which we must read from the header again.
func (mc *metaCursor) nextBlock() error {
// Re-read just the 2-byte header of current block to find its length.
hdrBuf, err := mc.f.d.Range(mc.blkAbs, 2)
if err != nil {
return err
}
hdr := binary.LittleEndian.Uint16(hdrBuf)
size := int64(hdr & 0x7fff)
mc.blkAbs += 2 + size
return mc.load()
}
// readN returns n uncompressed bytes, crossing metadata-block boundaries
// as required.
func (mc *metaCursor) readN(n int) ([]byte, error) {
if n <= 0 {
return nil, nil
}
out := make([]byte, 0, n)
for n > 0 {
if mc.inBlk >= len(mc.buffer) {
if err := mc.nextBlock(); err != nil {
return nil, err
}
}
take := len(mc.buffer) - mc.inBlk
if take > n {
take = n
}
out = append(out, mc.buffer[mc.inBlk:mc.inBlk+take]...)
mc.inBlk += take
n -= take
}
return out, nil
}
// ---------- inode ----------
type inodeHeader struct {
Type uint16
Mode uint16
UID uint16
GID uint16
Mtime uint32
Num uint32
}
func readInodeHeader(b []byte) (inodeHeader, error) {
var h inodeHeader
if len(b) < 16 {
return h, errors.New("inode header short")
}
h.Type = binary.LittleEndian.Uint16(b[0:2])
h.Mode = binary.LittleEndian.Uint16(b[2:4])
h.UID = binary.LittleEndian.Uint16(b[4:6])
h.GID = binary.LittleEndian.Uint16(b[6:8])
h.Mtime = binary.LittleEndian.Uint32(b[8:12])
h.Num = binary.LittleEndian.Uint32(b[12:16])
return h, nil
}
// readInodeAt reads the inode described by a 64-bit ref and returns the
// filled Entry plus the metadata location of the directory contents (if
// it's a directory).
type inodeContent struct {
entry Entry
dirStart uint32 // offset into DirTable (bytes from sb.DirTable)
dirBlockOff uint16 // intra-metadata-block offset for first dir header
dirSize uint32 // directory listing size, real value (file_size - 3)
parentInode uint32
}
func (f *FS) readInode(ref uint64) (*inodeContent, error) {
blkOff := uint32(ref >> 16)
inBlk := uint16(ref & 0xffff)
mc, err := f.metaCursorAt(int64(f.sb.InodeTable), blkOff, inBlk)
if err != nil {
return nil, err
}
hdrBytes, err := mc.readN(16)
if err != nil {
return nil, err
}
h, err := readInodeHeader(hdrBytes)
if err != nil {
return nil, err
}
ic := &inodeContent{
entry: Entry{
Mode: h.Mode,
UID: h.UID,
GID: h.GID,
Mtime: h.Mtime,
InodeNumber: h.Num,
InodeRef: ref,
},
}
switch h.Type {
case itBasicDir:
body, err := mc.readN(16)
if err != nil {
return nil, err
}
ic.entry.Type = "dir"
ic.dirStart = binary.LittleEndian.Uint32(body[0:4])
// nlink := binary.LittleEndian.Uint32(body[4:8])
fileSize := binary.LittleEndian.Uint16(body[8:10])
ic.dirBlockOff = binary.LittleEndian.Uint16(body[10:12])
ic.parentInode = binary.LittleEndian.Uint32(body[12:16])
// file_size in dir inodes is real dir bytes + 3. Zero-size (3) → empty dir.
if fileSize >= 3 {
ic.dirSize = uint32(fileSize) - 3
}
case itExtDir:
body, err := mc.readN(24)
if err != nil {
return nil, err
}
ic.entry.Type = "dir"
// nlink := binary.LittleEndian.Uint32(body[0:4])
fileSize := binary.LittleEndian.Uint32(body[4:8])
ic.dirStart = binary.LittleEndian.Uint32(body[8:12])
ic.parentInode = binary.LittleEndian.Uint32(body[12:16])
indexCount := binary.LittleEndian.Uint16(body[16:18])
ic.dirBlockOff = binary.LittleEndian.Uint16(body[18:20])
// xattr := binary.LittleEndian.Uint32(body[20:24])
if fileSize >= 3 {
ic.dirSize = fileSize - 3
}
// Skip index entries: indexCount × (4 + 4 + 4 + name[...variable]).
for i := uint16(0); i < indexCount; i++ {
hdr, err := mc.readN(12)
if err != nil {
return nil, err
}
nameSize := binary.LittleEndian.Uint32(hdr[8:12])
if nameSize > 256 {
return nil, fmt.Errorf("dir index name_size too large: %d", nameSize)
}
if _, err := mc.readN(int(nameSize) + 1); err != nil {
return nil, err
}
}
case itBasicFile:
body, err := mc.readN(16)
if err != nil {
return nil, err
}
ic.entry.Type = "file"
ic.entry.blocksStart = uint64(binary.LittleEndian.Uint32(body[0:4]))
ic.entry.fragIndex = binary.LittleEndian.Uint32(body[4:8])
ic.entry.fragOffset = binary.LittleEndian.Uint32(body[8:12])
fileSize := binary.LittleEndian.Uint32(body[12:16])
ic.entry.fileSize = int64(fileSize)
ic.entry.Size = int64(fileSize)
blocks, err := readBlockSizes(mc, fileSize, ic.entry.fragIndex, f.sb.BlockSize)
if err != nil {
return nil, err
}
ic.entry.blockSizes = blocks
case itExtFile:
body, err := mc.readN(40)
if err != nil {
return nil, err
}
ic.entry.Type = "file"
ic.entry.blocksStart = binary.LittleEndian.Uint64(body[0:8])
fileSize := binary.LittleEndian.Uint64(body[8:16])
// sparse := binary.LittleEndian.Uint64(body[16:24])
// nlink := binary.LittleEndian.Uint32(body[24:28])
ic.entry.fragIndex = binary.LittleEndian.Uint32(body[28:32])
ic.entry.fragOffset = binary.LittleEndian.Uint32(body[32:36])
// xattr := binary.LittleEndian.Uint32(body[36:40])
ic.entry.fileSize = int64(fileSize)
ic.entry.Size = int64(fileSize)
if fileSize > uint64(1)<<48 {
return nil, fmt.Errorf("unreasonable file size %d", fileSize)
}
blocks, err := readBlockSizes(mc, uint32(fileSize), ic.entry.fragIndex, f.sb.BlockSize)
if err != nil {
return nil, err
}
ic.entry.blockSizes = blocks
case itBasicSymlink, itExtSymlink:
body, err := mc.readN(8)
if err != nil {
return nil, err
}
// nlink := binary.LittleEndian.Uint32(body[0:4])
target := binary.LittleEndian.Uint32(body[4:8])
if target > 4096 {
return nil, fmt.Errorf("symlink target length %d too large", target)
}
link, err := mc.readN(int(target))
if err != nil {
return nil, err
}
ic.entry.Type = "symlink"
ic.entry.LinkTarget = string(link)
ic.entry.Size = int64(target)
if h.Type == itExtSymlink {
// trailing xattr u32
if _, err := mc.readN(4); err != nil {
return nil, err
}
}
default:
ic.entry.Type = "special"
}
return ic, nil
}
func readBlockSizes(mc *metaCursor, fileSize uint32, fragIdx uint32, blockSize uint32) ([]uint32, error) {
// If there is a fragment (fragIdx != 0xFFFFFFFF), the tail of the file
// (< blockSize) is stored there — do not count it as a full block.
full := fileSize / blockSize
if fragIdx == 0xFFFFFFFF && fileSize%blockSize != 0 {
full++ // final partial block stored on disk as a block
}
if full > (1 << 20) {
return nil, fmt.Errorf("unreasonable block count %d", full)
}
out := make([]uint32, full)
for i := range out {
b, err := mc.readN(4)
if err != nil {
return nil, err
}
out[i] = binary.LittleEndian.Uint32(b)
}
return out, nil
}
// ---------- directory walking ----------
// readDirEntries returns all (name, inode-ref) tuples in a directory.
func (f *FS) readDirEntries(parent *inodeContent) ([]dirEntry, error) {
if parent.dirSize == 0 {
return nil, nil
}
// dirStart is an offset into the dir table (relative to sb.DirTable).
// dirBlockOff is the intra-block start.
mc, err := f.metaCursorAt(int64(f.sb.DirTable), parent.dirStart, parent.dirBlockOff)
if err != nil {
return nil, err
}
remaining := int(parent.dirSize)
var out []dirEntry
for remaining > 0 {
hdr, err := mc.readN(12)
if err != nil {
return nil, err
}
remaining -= 12
count := binary.LittleEndian.Uint32(hdr[0:4]) + 1 // stored as count-1
startBlock := binary.LittleEndian.Uint32(hdr[4:8])
baseInode := binary.LittleEndian.Uint32(hdr[8:12])
for i := uint32(0); i < count; i++ {
entHdr, err := mc.readN(8)
if err != nil {
return nil, err
}
remaining -= 8
entOff := binary.LittleEndian.Uint16(entHdr[0:2])
iDelta := int16(binary.LittleEndian.Uint16(entHdr[2:4]))
entType := binary.LittleEndian.Uint16(entHdr[4:6])
nameSize := binary.LittleEndian.Uint16(entHdr[6:8])
if nameSize+1 > 256 {
return nil, fmt.Errorf("directory entry name_size %d too large", nameSize)
}
name, err := mc.readN(int(nameSize) + 1) // stored as name_size-1
if err != nil {
return nil, err
}
remaining -= int(nameSize) + 1
ref := (uint64(startBlock) << 16) | uint64(entOff)
out = append(out, dirEntry{
name: string(name),
ref: ref,
inodeNum: uint32(int64(baseInode) + int64(iDelta)),
entType: entType,
})
if len(out) > maxEntries {
return nil, fmt.Errorf("directory entry cap %d exceeded", maxEntries)
}
}
}
return out, nil
}
type dirEntry struct {
name string
ref uint64
inodeNum uint32
entType uint16
}
// Walk walks the entire filesystem from the root, emitting every Entry.
// The returned list is sorted by path. Walk is bounded by maxEntries
// and by recursion depth.
func (f *FS) Walk() ([]Entry, error) {
root, err := f.readInode(f.sb.RootInodeRef)
if err != nil {
return nil, fmt.Errorf("root inode: %w", err)
}
if root.entry.Type != "dir" {
return nil, fmt.Errorf("root is not a directory (type %q)", root.entry.Type)
}
out := []Entry{}
root.entry.Path = "/"
out = append(out, root.entry)
if err := f.walk(root, "/", &out, 0); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
func (f *FS) walk(node *inodeContent, prefix string, out *[]Entry, depth int) error {
if depth > 64 {
return errors.New("directory depth exceeds 64 — possible cycle")
}
entries, err := f.readDirEntries(node)
if err != nil {
return err
}
for _, de := range entries {
child, err := f.readInode(de.ref)
if err != nil {
return fmt.Errorf("%s/%s: %w", prefix, de.name, err)
}
full := path.Join(prefix, de.name)
child.entry.Path = full
*out = append(*out, child.entry)
if len(*out) > maxEntries {
return fmt.Errorf("entry cap %d exceeded", maxEntries)
}
if child.entry.Type == "dir" {
if err := f.walk(child, full, out, depth+1); err != nil {
return err
}
}
}
return nil
}
// ---------- file extraction ----------
// fragmentEntry describes one entry in the fragment table.
type fragmentEntry struct {
Start uint64
Size uint32 // top bit = uncompressed
Compressed bool
}
// fragmentTable reads the entire fragment lookup table and returns the
// flat list of entries by index. Result is cached on first call.
func (f *FS) fragmentTable() ([]fragmentEntry, error) {
if f.sb.FragCount == 0 {
return nil, nil
}
// The fragment_table_start points at a u64[] of metadata-block offsets,
// one per 512 fragments (ceil).
indirCount := (int(f.sb.FragCount) + 511) / 512
indirBytes, err := f.d.Range(int64(f.sb.FragTable), int64(indirCount*8))
if err != nil {
return nil, fmt.Errorf("frag indirection: %w", err)
}
out := make([]fragmentEntry, 0, f.sb.FragCount)
remaining := int(f.sb.FragCount)
for i := 0; i < indirCount && remaining > 0; i++ {
absOff := int64(binary.LittleEndian.Uint64(indirBytes[i*8 : i*8+8]))
block, _, err := f.readMetadataBlock(absOff)
if err != nil {
return nil, err
}
// Each fragment entry is 16 bytes: u64 start, u32 size, u32 unused.
take := remaining
if take > 512 {
take = 512
}
if len(block) < take*16 {
return nil, fmt.Errorf("fragment block short: have %d need %d", len(block), take*16)
}
for j := 0; j < take; j++ {
off := j * 16
sz := binary.LittleEndian.Uint32(block[off+8 : off+12])
out = append(out, fragmentEntry{
Start: binary.LittleEndian.Uint64(block[off : off+8]),
Size: sz & 0x00ffffff, // documented size field occupies low 24 bits
Compressed: sz&0x01000000 == 0,
})
}
remaining -= take
}
return out, nil
}
// ExtractFile returns the full decompressed contents of the file at the
// given InodeRef. Returns an error if the inode is not a file.
func (f *FS) ExtractFile(ref uint64) ([]byte, error) {
ic, err := f.readInode(ref)
if err != nil {
return nil, err
}
if ic.entry.Type != "file" {
return nil, fmt.Errorf("inode %d is not a file (%s)", ic.entry.InodeNumber, ic.entry.Type)
}
out := make([]byte, 0, ic.entry.fileSize)
cursor := int64(ic.entry.blocksStart)
for i, bs := range ic.entry.blockSizes {
size := int64(bs & 0x00ffffff)
compressed := bs&0x01000000 == 0
if size == 0 {
// sparse hole: emit blockSize zeroes (or trailing partial)
remaining := ic.entry.fileSize - int64(len(out))
n := int64(f.sb.BlockSize)
if remaining < n {
n = remaining
}
out = append(out, make([]byte, n)...)
continue
}
if size > dataBlockMax {
return nil, fmt.Errorf("data block %d size %d exceeds cap", i, size)
}
body, err := f.d.Range(cursor, size)
if err != nil {
return nil, fmt.Errorf("data block %d: %w", i, err)
}
cursor += size
if !compressed {
out = append(out, body...)
continue
}
dec, err := f.decompressBlock(body)
if err != nil {
return nil, fmt.Errorf("data block %d: %w", i, err)
}
out = append(out, dec...)
}
if ic.entry.fragIndex != 0xFFFFFFFF {
frags, err := f.fragmentTable()
if err != nil {
return nil, err
}
if int(ic.entry.fragIndex) >= len(frags) {
return nil, fmt.Errorf("fragment index %d out of range (%d entries)", ic.entry.fragIndex, len(frags))
}
fe := frags[ic.entry.fragIndex]
fragBody, err := f.d.Range(int64(fe.Start), int64(fe.Size))
if err != nil {
return nil, fmt.Errorf("fragment data: %w", err)
}
var payload []byte
if fe.Compressed {
payload, err = f.decompressBlock(fragBody)
if err != nil {
return nil, err
}
} else {
payload = fragBody
}
tailLen := int(ic.entry.fileSize) - len(out)
if int(ic.entry.fragOffset)+tailLen > len(payload) {
return nil, fmt.Errorf("fragment slice out of range: off=%d len=%d payload=%d",
ic.entry.fragOffset, tailLen, len(payload))
}
out = append(out, payload[ic.entry.fragOffset:int(ic.entry.fragOffset)+tailLen]...)
}
if int64(len(out)) != ic.entry.fileSize {
return nil, fmt.Errorf("extraction length mismatch: got %d, want %d", len(out), ic.entry.fileSize)
}
return out, nil
}
// decompressBlock is like decompress but bounded by dataBlockMax; used for fragment payloads.
func (f *FS) decompressBlock(buf []byte) ([]byte, error) {
return f.decompressWithLimit(buf, dataBlockMax)
}
func readLimited(r io.Reader, limit int64) ([]byte, error) {
if limit <= 0 {
return nil, errors.New("invalid decompression limit")
}
lr := &io.LimitedReader{R: r, N: limit + 1}
out, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(out)) > limit {
return nil, fmt.Errorf("decompressed block exceeds cap %d", limit)
}
return out, nil
}