mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
513 lines
22 KiB
Go
513 lines
22 KiB
Go
package archive
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mholt/archives"
|
|
|
|
intFile "github.com/anchore/syft/internal/file"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/syft/cataloging"
|
|
)
|
|
|
|
// Extractor extracts archive contents to a destination directory.
|
|
type Extractor interface {
|
|
// CanExtract returns true if this extractor can handle the given archive content.
|
|
CanExtract(ctx context.Context, content Content) bool
|
|
|
|
// Extract writes the archive's entries into sink - one store per archive, not one file per entry -
|
|
// respecting the given limits. Returns how many entries were written and how many bytes that took.
|
|
Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error)
|
|
}
|
|
|
|
// EntrySink is where an extractor puts the entries it reads out of an archive.
|
|
//
|
|
// Two implementations exist because they store an archive differently and the difference is
|
|
// measurable: OverflowTar writes every entry into one tar on disk and is indexed by seek offset,
|
|
// while EntryStore holds entries in memory until a bound says otherwise and is indexed by the entries
|
|
// themselves. The extractors do not care which they were given, with one exception - see
|
|
// VerbatimSink.
|
|
type EntrySink interface {
|
|
// AddEntry stores one entry of the archive.
|
|
AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error
|
|
|
|
// Finish is called once the last entry has been offered, for whatever a sink has to write after
|
|
// its content.
|
|
Finish(result *ExtractionResult, limits ExtractionLimits)
|
|
}
|
|
|
|
// VerbatimSink is an EntrySink that stores archives in tar form, so a tar-family archive can be
|
|
// copied into it as it stands rather than decoded and re-encoded entry by entry.
|
|
//
|
|
// This is the one place the storage format shows through the extractor interface, and it is worth the
|
|
// leak: a tar.gz copied verbatim is one streamed decompression, where walking it costs a read per
|
|
// entry. A sink that does not store tars does not implement this and gets the entry-by-entry path.
|
|
type VerbatimSink interface {
|
|
EntrySink
|
|
CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error
|
|
}
|
|
|
|
// ExtractionLimits defines safety limits for extracting one archive.
|
|
type ExtractionLimits struct {
|
|
|
|
// Charge is this archive's draw on the scan's resource limiter. Entry bytes are charged to it as
|
|
// they land, never from the size the entry declared, and a refused charge truncates the
|
|
// extraction. A nil Charge enforces no byte bound.
|
|
//
|
|
// It is a handle rather than a remaining-bytes value because what it charges has to be given back
|
|
// when the archive is released, and a value copied into a call cannot be.
|
|
Charge *Charge
|
|
}
|
|
|
|
// TruncationReason names the configured bound that stopped an extraction early. The empty value
|
|
// means the archive was extracted in full.
|
|
type TruncationReason string
|
|
|
|
const (
|
|
// TruncatedByDiskLimit means placing more of this archive's content would have taken the scan's
|
|
// disk usage past the disk limit. Named rather than reported as a bare truncation because a breach
|
|
// has to be attributed to the archive that triggered it, and saying which bound was reached is
|
|
// half of that attribution.
|
|
TruncatedByDiskLimit TruncationReason = "disk limit"
|
|
|
|
// TruncatedByLimit means a bound stopped the extraction but the walker replaced the error that
|
|
// said which one. Defensive: both bounds record their own reason on the result before stopping.
|
|
TruncatedByLimit TruncationReason = "configured limit"
|
|
)
|
|
|
|
// errTruncated stops an in-progress archive walk when a configured limit is reached. It never
|
|
// escapes this package: the extractors convert it into a successful, truncated ExtractionResult.
|
|
var errTruncated = errors.New("archive extraction stopped at a configured limit")
|
|
|
|
// ExtractionResult holds the result of an extraction operation.
|
|
//
|
|
// Reaching a configured limit is NOT an error: it is a truncation. Whatever was written before the
|
|
// limit is left on disk and is usable, so callers can still catalog what was extracted rather than
|
|
// discarding the whole archive. Check Truncated to tell a complete extraction from a partial one;
|
|
// a non-nil error from Extract means the extraction failed and nothing usable was produced.
|
|
type ExtractionResult struct {
|
|
// FilesExtracted counts the entries this archive's filesystem holds - the records its overflow tar
|
|
// was indexed into: regular files, symlinks, and directory entries. It is the count after indexing
|
|
// rather than the count written, so an entry dropped by an exclusion pattern is not counted as
|
|
// present. Reported, not bounded.
|
|
FilesExtracted int
|
|
|
|
// BytesWritten is what this archive put on disk: every byte of the overflow tar, headers and block
|
|
// padding included, because that is what the disk limit was charged for.
|
|
BytesWritten int64
|
|
|
|
// Truncation names the limit that stopped extraction early, or "" when the archive was
|
|
// extracted in full.
|
|
Truncation TruncationReason
|
|
}
|
|
|
|
// Truncated reports whether a configured limit stopped this extraction before the end of the
|
|
// archive.
|
|
func (r ExtractionResult) Truncated() bool {
|
|
return r.Truncation != ""
|
|
}
|
|
|
|
// finishExtraction turns a walk that stopped at a configured limit into a successful, truncated
|
|
// result.
|
|
//
|
|
// The limit signal is carried on the result rather than only in the returned error because the
|
|
// underlying archive walkers are free to wrap or replace a handler's error, in which case
|
|
// errors.Is(err, errTruncated) would not match and a truncation would be reported as a failure -
|
|
// discarding an archive syft could have cataloged. The error is still checked, so a walker that
|
|
// passes it through cleanly is handled too.
|
|
func finishExtraction(result ExtractionResult, err error) (ExtractionResult, error) {
|
|
if result.Truncated() || errors.Is(err, errTruncated) {
|
|
if !result.Truncated() {
|
|
// the walker swallowed the reason but passed the sentinel through
|
|
result.Truncation = TruncatedByLimit
|
|
}
|
|
return result, nil
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
// DefaultExtractionLimits returns the per-archive limits from the given config.
|
|
//
|
|
// Nothing is read from the config: every bound this scan enforces is an in-use limit held by the
|
|
// scan's limiter, and the only per-archive field left is the Charge the caller draws against it.
|
|
// The parameter stays so the two Default* constructors are called the same way, and so a per-archive
|
|
// bound that is genuinely per-archive has somewhere to land.
|
|
func DefaultExtractionLimits(_ cataloging.ArchiveSearchConfig) ExtractionLimits {
|
|
return ExtractionLimits{}
|
|
}
|
|
|
|
// DefaultLimits returns the scan-wide in-use limits from the given config.
|
|
func DefaultLimits(cfg cataloging.ArchiveSearchConfig) Limits {
|
|
return Limits{
|
|
MaxMemoryBytes: cfg.MaxMemoryBytes,
|
|
MaxDiskBytes: cfg.MaxDiskBytes,
|
|
}
|
|
}
|
|
|
|
// DefaultExtractors returns the set of built-in archive extractors.
|
|
func DefaultExtractors() []Extractor {
|
|
return []Extractor{
|
|
&ZipExtractor{},
|
|
&TarExtractor{},
|
|
}
|
|
}
|
|
|
|
// FindExtractor finds an extractor that can handle the given archive content, or nil if none can.
|
|
func FindExtractor(ctx context.Context, extractors []Extractor, content Content) Extractor {
|
|
for _, ext := range extractors {
|
|
// every extractor sniffs from the start, whether the content is a file or a buffer
|
|
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
|
log.Tracef("unable to seek archive content for detection: %v", err)
|
|
return nil
|
|
}
|
|
if ext.CanExtract(ctx, content) {
|
|
return ext
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.).
|
|
type ZipExtractor struct{}
|
|
|
|
// CanExtract reports whether this content is a zip, asking two questions in an order that matters.
|
|
//
|
|
// First, what does the content say it is, reading from the start? A format that identifies itself at
|
|
// offset zero is what this content is, and if that format is not zip then it is not a zip - a tar
|
|
// holding one jar ends in that jar's end-of-central-directory record, and reading it as a zip
|
|
// instead of as a tar would lose everything else the tar holds.
|
|
//
|
|
// Only when nothing identifies from the head is the end-of-central-directory record consulted. That
|
|
// record is what defines a zip - a reader locates the central directory through it - and it is the
|
|
// only thing that can see an archive whose head is something else entirely. A self-extracting
|
|
// archive is a launcher stub with a zip concatenated onto it, so no magic at offset zero can find
|
|
// it; the canonical case is a Spring Boot executable jar, a shell script followed by a whole zip.
|
|
//
|
|
// Identification is intFile.IdentifyArchive, which matches on the filename as well as the content,
|
|
// and it is left that way. Dropping the name makes the answer content's alone, which reads better -
|
|
// but archives.Identify with no path has to try every format's content matcher against every
|
|
// candidate, and measured on a stock ruby image that took the scan from 17 seconds to 45. The name
|
|
// cannot admit a non-archive in practice: a file only reaches an extractor after its sniffed content
|
|
// type put it in the candidate set, so the prose named notes.zip that this would wrongly identify is
|
|
// never offered here at all. `#content-based-archive-detection` is upheld by the candidate set, and
|
|
// Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError is where that is asserted.
|
|
func (z *ZipExtractor) CanExtract(ctx context.Context, content Content) bool {
|
|
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
|
|
if err == nil {
|
|
_, isZip := format.(archives.Zip)
|
|
return isZip
|
|
}
|
|
|
|
if _, seekErr := content.Reader.Seek(0, io.SeekStart); seekErr != nil {
|
|
return false
|
|
}
|
|
return HasZipEndOfCentralDirectory(content.Reader)
|
|
}
|
|
|
|
func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
|
|
var result ExtractionResult
|
|
|
|
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
|
return result, fmt.Errorf("unable to seek zip archive %q: %w", content.Name, err)
|
|
}
|
|
|
|
// mholt/archives imposes no extraction target: the FileHandler is the caller's, so an entry can
|
|
// be written into a tar header rather than onto the filesystem. Nothing about the library had to
|
|
// be customized - the old code wrote files because the handler wrote files.
|
|
err := archives.Zip{}.Extract(ctx, content.Reader, func(_ context.Context, file archives.FileInfo) error {
|
|
return sink.AddEntry(file, &result, limits)
|
|
})
|
|
|
|
sink.Finish(&result, limits)
|
|
|
|
return finishExtraction(result, err)
|
|
}
|
|
|
|
// TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst).
|
|
type TarExtractor struct{}
|
|
|
|
func (t *TarExtractor) CanExtract(ctx context.Context, content Content) bool {
|
|
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// mholt/archives returns a compound type for tar+compression, check if it's an extractor but not a zip
|
|
if _, isZip := format.(archives.Zip); isZip {
|
|
return false
|
|
}
|
|
_, ok := format.(archives.Extractor)
|
|
return ok
|
|
}
|
|
|
|
func (t *TarExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
|
|
var result ExtractionResult
|
|
|
|
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
|
|
return result, fmt.Errorf("unable to seek tar archive %q: %w", content.Name, err)
|
|
}
|
|
|
|
format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
|
|
if err != nil {
|
|
return result, fmt.Errorf("unable to identify archive format for %q: %w", content.Name, err)
|
|
}
|
|
|
|
extractor, ok := format.(archives.Extractor)
|
|
if !ok {
|
|
return result, fmt.Errorf("file format does not support extraction: %s", content.Name)
|
|
}
|
|
|
|
// use the reader returned by IdentifyArchive since it may have consumed some bytes
|
|
var extractReader = readerAfterIdentify
|
|
if extractReader == nil {
|
|
extractReader = content.Reader
|
|
}
|
|
|
|
// a tar handed to a sink that stores tars is not walked at all: a compressed tar is decompressed
|
|
// once into the sink's file and a plain one is copied, and the entries are first looked at when
|
|
// the file is indexed. Where the sink stores entries rather than a tar there is nothing to copy
|
|
// into, so the tar is walked like any other archive.
|
|
if verbatim, isVerbatim := sink.(VerbatimSink); isVerbatim {
|
|
if plain, ok := tarStream(format, extractReader); ok {
|
|
err = verbatim.CopyVerbatim(plain, &result, limits)
|
|
if closeErr := plain.Close(); closeErr != nil {
|
|
log.WithFields("archive", content.Name, "error", closeErr).Trace("unable to close decompressed tar stream")
|
|
}
|
|
return finishExtraction(result, err)
|
|
}
|
|
}
|
|
|
|
// anything else - 7z, rar, or a tar going into a sink that does not store tars - is walked entry
|
|
// by entry
|
|
err = extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error {
|
|
return sink.AddEntry(file, &result, limits)
|
|
})
|
|
|
|
sink.Finish(&result, limits)
|
|
|
|
return finishExtraction(result, err)
|
|
}
|
|
|
|
// resolvesInsideRoot reports whether writing to path really lands inside root, following any symlink
|
|
// that already exists along the way the same way the filesystem will.
|
|
//
|
|
// The one filesystem write left in this package is the archive's own bytes overflowing into its work
|
|
// directory, under a name derived from where the archive was found - which inside another archive is
|
|
// attacker-controlled text. That is what this now guards; entries are tar headers and traverse no
|
|
// path at all. See overflowContent.
|
|
//
|
|
// It walks path one component at a time rather than calling filepath.EvalSymlinks on the whole
|
|
// thing, because the leaf of an entry being extracted does not exist yet, which makes EvalSymlinks
|
|
// fail rather than answer. The first component that does not exist ends the walk: nothing below it
|
|
// exists either, so whatever is created there is created fresh under a path already known to be
|
|
// inside the root.
|
|
func resolvesInsideRoot(root, path string) (bool, error) {
|
|
// the root itself may sit under a symlinked directory (a temp dir on macOS, for one), so
|
|
// containment is judged against its real location
|
|
realRoot, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to resolve extraction root: %w", err)
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !isWithin(filepath.Join(realRoot, rel), realRoot) {
|
|
return false, nil
|
|
}
|
|
|
|
current := realRoot
|
|
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
|
|
if part == "" || part == "." {
|
|
continue
|
|
}
|
|
current = filepath.Join(current, part)
|
|
|
|
info, err := os.Lstat(current)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
break
|
|
}
|
|
return false, err
|
|
}
|
|
if info.Mode()&fs.ModeSymlink == 0 {
|
|
continue
|
|
}
|
|
|
|
resolved, err := resolveLink(current)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !isWithin(resolved, realRoot) {
|
|
return false, nil
|
|
}
|
|
current = resolved
|
|
}
|
|
|
|
return isWithin(current, realRoot), nil
|
|
}
|
|
|
|
// resolveLink returns where a symlink really points, falling back to the lexically cleaned target
|
|
// when the target does not exist yet - a dangling link still redirects a write.
|
|
func resolveLink(path string) (string, error) {
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(filepath.Dir(path), target)
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(target)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
return filepath.Clean(target), nil
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
// isWithin reports whether path is root or sits beneath it, compared on a path boundary rather than
|
|
// as a string prefix so a sibling sharing the prefix does not pass.
|
|
func isWithin(path, root string) bool {
|
|
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
|
}
|
|
|
|
// writeSafeSymlink creates a symlink at destPath only if target is relative and really resolves,
|
|
// relative to destPath's directory, to a location inside destDir.
|
|
//
|
|
// Absolute targets are rejected outright: os.Symlink writes the literal target
|
|
// string, so an absolute target like "/etc/passwd" would resolve on the host
|
|
// filesystem when read, regardless of any safety check we performed against
|
|
// destDir at extraction time.
|
|
//
|
|
// A relative target is resolved against the REAL location of destPath's directory, not against its
|
|
// lexical one, because two cooperating entries can otherwise each pass on their own and together
|
|
// point outside: a link to "." followed by a link to ".." inside it resolves lexically to the root
|
|
// while really landing above it, and repeating the pair climbs a level each time. The parent of
|
|
// every write is checked the same way before the write happens, so a link that slipped through here
|
|
// still could not be written through - this is the earlier of two refusals, not the only one.
|
|
//
|
|
// It has no caller since an archive's entries became headers in one tar rather than files on disk: a
|
|
// symlink entry is now a header write, which creates nothing and traverses nothing, and where the
|
|
// link points is decided inside the archive's own filetree. It is kept rather than retired because
|
|
// that is a judgement about every path that could write a link, not about this one call site, and
|
|
// because the reasoning above is the expensive part to reconstruct.
|
|
//
|
|
//nolint:unused // retained deliberately; see the note above
|
|
func writeSafeSymlink(target, destPath, destDir string) error {
|
|
if target == "" {
|
|
return fmt.Errorf("empty link target")
|
|
}
|
|
if filepath.IsAbs(target) {
|
|
return fmt.Errorf("absolute symlink target not allowed")
|
|
}
|
|
|
|
realRoot, err := filepath.EvalSymlinks(destDir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to resolve extraction root: %w", err)
|
|
}
|
|
realParent, err := filepath.EvalSymlinks(filepath.Dir(destPath))
|
|
if err != nil {
|
|
return fmt.Errorf("unable to resolve link parent: %w", err)
|
|
}
|
|
if !isWithin(realParent, realRoot) {
|
|
return fmt.Errorf("symlink parent escapes extraction root")
|
|
}
|
|
|
|
resolved := filepath.Join(realParent, target)
|
|
if !isWithin(resolved, realRoot) {
|
|
return fmt.Errorf("symlink target escapes extraction root")
|
|
}
|
|
// the target itself may run through links planted by earlier entries
|
|
if existing, err := filepath.EvalSymlinks(resolved); err == nil && !isWithin(existing, realRoot) {
|
|
return fmt.Errorf("symlink target resolves outside extraction root")
|
|
}
|
|
|
|
return os.Symlink(target, destPath)
|
|
}
|
|
|
|
// zipEOCDSignature is the end-of-central-directory record's signature. A zip is defined by this
|
|
// record: it is what a reader seeks to in order to find the central directory, which is why a zip
|
|
// with something concatenated in front of it still opens.
|
|
var zipEOCDSignature = []byte{'P', 'K', 0x05, 0x06}
|
|
|
|
// maxZipEOCDSearch is how far back the record can be. The EOCD is 22 bytes and ends with a comment
|
|
// of up to 65535 bytes, so it starts no earlier than this many bytes from the end.
|
|
const maxZipEOCDSearch = 22 + 65535
|
|
|
|
// zipLocalFileHeaderSignature starts every entry in a zip. In a self-extracting archive the first
|
|
// one follows the stub, so it is what a bounded read of the head can find.
|
|
var zipLocalFileHeaderSignature = []byte{'P', 'K', 0x03, 0x04}
|
|
|
|
// MaxAppendedArchiveStubBytes is how far into a file a hidden archive is looked for. A Spring Boot
|
|
// launcher stub is about 9 KB, so this is generous by a wide margin; a stub longer than this hides
|
|
// its archive from the walk, which is the cost of not reading every candidate to the end.
|
|
const MaxAppendedArchiveStubBytes = 64 * 1024
|
|
|
|
// MayHideAnAppendedArchive reports whether the head of this stream contains a zip local file header,
|
|
// and returns a reader that replays what it consumed.
|
|
//
|
|
// It exists to make the candidate set affordable. A file whose head sniffs as a script, an
|
|
// executable, or as nothing recognizable may still be an archive wearing a stub, but almost none of
|
|
// them are - and the authoritative test, the end-of-central-directory record, is at the far end of
|
|
// the file, so applying it to every such candidate means reading every executable in a scan to its
|
|
// end. Measured on a stock ruby image that doubled the scan.
|
|
//
|
|
// So the head is checked first, cheaply and with a bound, and only a file that shows an entry
|
|
// signature there is read in full and put to the real test. This narrows what is examined; it never
|
|
// admits anything, because a file that passes here is still refused by the extractor unless it ends
|
|
// in a valid central directory.
|
|
func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) {
|
|
head := make([]byte, MaxAppendedArchiveStubBytes)
|
|
n, err := io.ReadFull(r, head)
|
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return io.MultiReader(bytes.NewReader(head[:n]), r), false
|
|
}
|
|
head = head[:n]
|
|
return io.MultiReader(bytes.NewReader(head), r), bytes.Contains(head, zipLocalFileHeaderSignature)
|
|
}
|
|
|
|
// HasZipEndOfCentralDirectory reports whether the content ends in a zip end-of-central-directory
|
|
// record, which is the definition of a zip and the only part of one whose position is knowable
|
|
// without parsing.
|
|
//
|
|
// This is what identifies an archive that content sniffing types by its head: a self-extracting
|
|
// archive is a launcher stub with a zip concatenated onto it, so its first bytes are the stub's and
|
|
// no magic-at-offset-zero test can see the archive. The record at the end is unambiguous, and
|
|
// mholt/archives extracts such a file correctly once it is asked to - it reads through
|
|
// archive/zip, which locates the central directory from the end.
|
|
//
|
|
// It is a content test, so it cannot make a non-archive extractable: a file named `notes.zip` that
|
|
// is prose has no such record and is refused here exactly as it is refused by format identification.
|
|
func HasZipEndOfCentralDirectory(r ReaderAtSeeker) bool {
|
|
size, err := r.Seek(0, io.SeekEnd)
|
|
if err != nil || size < int64(len(zipEOCDSignature)) {
|
|
return false
|
|
}
|
|
|
|
window := int64(maxZipEOCDSearch)
|
|
if size < window {
|
|
window = size
|
|
}
|
|
|
|
buf := make([]byte, window)
|
|
if _, err := r.ReadAt(buf, size-window); err != nil && !errors.Is(err, io.EOF) {
|
|
return false
|
|
}
|
|
|
|
return bytes.LastIndex(buf, zipEOCDSignature) >= 0
|
|
}
|