cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / fileio / xlsxpkg.go
xlsxpkg.go 8.0K
1package fileio23import (4 "archive/zip"5 "encoding/xml"6 "errors"7 "fmt"8 "io"9 "io/fs"10 "path"11 "strings"12)1314// An XLSX file is a zip of XML parts (SpreadsheetML in an Open Packaging15// Conventions package). 012 reads it with archive/zip and encoding/xml,16// streaming each part a token at a time, so a worksheet is never held in17// memory. The file is untrusted: xlsxLimits bound what it can make the18// reader do.1920// xlsxLimits bound the work a file can cause. encoding/xml expands no21// external or custom entities, so entity bombs can't happen; these cap22// zip bombs, deep nesting and huge single tokens.23type xlsxLimits struct {24 entries int // files in the zip25 part int64 // uncompressed bytes of one part26 total int64 // uncompressed bytes of all parts read27 ratio int64 // uncompressed / compressed, past slack bytes28 slack int6429 depth int // XML nesting30 token int64 // bytes of one XML token (a tag or a run of text)31 strings int // shared strings32 styles int // cell formats, fonts and number formats, each33 sheets int34 shared int64 // bytes of shared formulas expanded35}3637var defaultXLSXLimits = xlsxLimits{38 entries: 10_000,39 part: 1 << 30,40 total: 2 << 30,41 ratio: 250,42 slack: 16 << 20,43 depth: 256,44 token: 32 << 20,45 strings: 1 << 24,46 styles: 1 << 16,47 sheets: 4096,48 shared: 256 << 20,49}5051// errXLSXLimit is wrapped by every error for a file past a limit.52var errXLSXLimit = errors.New("past the reader's limits")5354// xlsxPackage is an open XLSX zip: its parts by lower-case name (part55// names are case-insensitive) and what has been read of them so far.56type xlsxPackage struct {57 parts map[string]*zip.File58 lim xlsxLimits59 read int64 // uncompressed bytes read, all parts60}6162func openXLSXPackage(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxPackage, error) {63 zr, err := zip.NewReader(r, size)64 if err != nil && !errors.Is(err, zip.ErrInsecurePath) {65 return nil, fmt.Errorf("not an Excel workbook, or an encrypted one (%w)", err)66 }67 if len(zr.File) > lim.entries {68 return nil, fmt.Errorf("the workbook holds %d files, more than %d: %w", len(zr.File), lim.entries, errXLSXLimit)69 }70 p := &xlsxPackage{parts: make(map[string]*zip.File, len(zr.File)), lim: lim}71 for _, f := range zr.File {72 name := f.Name73 if strings.HasSuffix(name, "/") {74 continue // a directory75 }76 if !fs.ValidPath(name) || strings.ContainsRune(name, '\\') {77 return nil, fmt.Errorf("the workbook holds a file with an unsafe name, %q", name)78 }79 key := strings.ToLower(name)80 if _, dup := p.parts[key]; dup {81 return nil, fmt.Errorf("the workbook holds %s twice", name)82 }83 p.parts[key] = f84 }85 return p, nil86}8788// has reports whether the package holds a part.89func (p *xlsxPackage) has(name string) bool {90 _, ok := p.parts[strings.ToLower(name)]91 return ok92}9394// open starts streaming a part's XML, or returns nil, nil when there is95// no such part.96func (p *xlsxPackage) open(name string) (*xmlStream, error) {97 f, ok := p.parts[strings.ToLower(name)]98 if !ok {99 return nil, nil100 }101 rc, err := f.Open()102 if err != nil {103 return nil, fmt.Errorf("%s: %w", name, err)104 }105 g := &guardReader{r: rc, c: rc, p: p, name: name, comp: int64(f.CompressedSize64)}106 d := xml.NewDecoder(g)107 return &xmlStream{d: d, g: g, name: name, maxDepth: p.lim.depth}, nil108}109110// guardReader counts a part's uncompressed bytes as they are read and111// fails past the limits, so a zip bomb stops early whatever its headers112// claim.113type guardReader struct {114 r io.Reader115 c io.Closer116 p *xlsxPackage117 name string118 comp int64 // compressed size119 n int64 // uncompressed bytes read120 tok int64 // n when the current XML token began121}122123func (g *guardReader) Read(b []byte) (int, error) {124 k, err := g.r.Read(b)125 g.n += int64(k)126 g.p.read += int64(k)127 lim := g.p.lim128 switch {129 case g.n > lim.part:130 return k, fmt.Errorf("%s unpacks to more than %s: %w", g.name, mb(lim.part), errXLSXLimit)131 case g.p.read > lim.total:132 return k, fmt.Errorf("the workbook unpacks to more than %s: %w", mb(lim.total), errXLSXLimit)133 case g.n > lim.slack && g.n/max(g.comp, 1) > lim.ratio:134 return k, fmt.Errorf("%s is compressed over %d to 1, like a zip bomb: %w", g.name, lim.ratio, errXLSXLimit)135 case g.n-g.tok > lim.token:136 return k, fmt.Errorf("%s has a tag or text over %s: %w", g.name, mb(lim.token), errXLSXLimit)137 }138 return k, err139}140141func mb(n int64) string { return fmt.Sprintf("%d MB", n>>20) }142143// xmlStream is a part's XML as tokens, checking nesting depth and the144// size of each token.145type xmlStream struct {146 d *xml.Decoder147 g *guardReader148 name string149 depth int150 maxDepth int151}152153// next returns the next token, or io.EOF at the end of the part. Char154// data is only valid until the following call.155func (x *xmlStream) next() (xml.Token, error) {156 x.g.tok = x.g.n157 t, err := x.d.Token()158 if err != nil {159 if err == io.EOF {160 return nil, err161 }162 return nil, fmt.Errorf("%s: %w", x.name, err)163 }164 switch t.(type) {165 case xml.StartElement:166 x.depth++167 if x.depth > x.maxDepth {168 return nil, fmt.Errorf("%s nests elements more than %d deep: %w", x.name, x.maxDepth, errXLSXLimit)169 }170 case xml.EndElement:171 x.depth--172 }173 return t, nil174}175176// skip passes over the rest of the element just started.177func (x *xmlStream) skip() error {178 for depth := x.depth; ; {179 t, err := x.next()180 if err != nil {181 return eofAsUnexpected(err)182 }183 if _, ok := t.(xml.EndElement); ok && x.depth < depth {184 return nil185 }186 }187}188189// text returns the text of the element just started, up to its end.190func (x *xmlStream) text(buf []byte) ([]byte, error) {191 for depth := x.depth; ; {192 t, err := x.next()193 if err != nil {194 return buf, eofAsUnexpected(err)195 }196 switch t := t.(type) {197 case xml.CharData:198 buf = append(buf, t...)199 case xml.EndElement:200 if x.depth < depth {201 return buf, nil202 }203 }204 }205}206207func (x *xmlStream) close() error { return x.g.c.Close() }208209func eofAsUnexpected(err error) error {210 if err == io.EOF {211 return io.ErrUnexpectedEOF212 }213 return err214}215216// attr returns the value of the attribute with local name name.217func attr(se xml.StartElement, name string) (string, bool) {218 for _, a := range se.Attr {219 if a.Name.Local == name {220 return a.Value, true221 }222 }223 return "", false224}225226// relAttr returns the r:id of an element: the id attribute in the227// relationships namespace (transitional or strict).228func relAttr(se xml.StartElement) string {229 for _, a := range se.Attr {230 if a.Name.Local == "id" && strings.HasSuffix(a.Name.Space, "/relationships") {231 return a.Value232 }233 }234 return ""235}236237// xlsxRel is a relationship from a part to another.238type xlsxRel struct {239 typ string // the last element of the type URI, e.g. "worksheet"240 target string // the part's name, resolved241}242243// rels reads the relationships of part (from its _rels part), resolving244// targets to part names. Targets outside the package are left out.245func (p *xlsxPackage) rels(part string) (map[string]xlsxRel, error) {246 dir, base := path.Split(part)247 x, err := p.open(dir + "_rels/" + base + ".rels")248 if x == nil || err != nil {249 return nil, err250 }251 defer x.close()252 out := map[string]xlsxRel{}253 for {254 t, err := x.next()255 if err == io.EOF {256 return out, nil257 }258 if err != nil {259 return nil, err260 }261 se, ok := t.(xml.StartElement)262 if !ok || se.Name.Local != "Relationship" {263 continue264 }265 if mode, _ := attr(se, "TargetMode"); strings.EqualFold(mode, "External") {266 continue267 }268 id, _ := attr(se, "Id")269 typ, _ := attr(se, "Type")270 target, _ := attr(se, "Target")271 if name, ok := resolvePart(dir, target); ok && len(out) < p.lim.entries {272 out[id] = xlsxRel{typ: typ[strings.LastIndexByte(typ, '/')+1:], target: name}273 }274 }275}276277// resolvePart resolves a relationship's target against the directory of278// its source part: /xl/x.xml is from the root, x.xml from dir. A target279// that climbs out of the package is refused.280func resolvePart(dir, target string) (string, bool) {281 if target == "" {282 return "", false283 }284 if strings.HasPrefix(target, "/") {285 target = target[1:]286 } else {287 target = dir + target288 }289 name := path.Clean(target)290 if !fs.ValidPath(name) || name == "." {291 return "", false292 }293 return name, true294}