diff --git a/.gitignore b/.gitignore index 990fab4..04cf7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +bin/ # Test binary, built with `go test -c` *.test diff --git a/artifact.go b/artifact.go new file mode 100644 index 0000000..3962e42 --- /dev/null +++ b/artifact.go @@ -0,0 +1,32 @@ +package brief + +import "github.com/git-pkgs/brief/binary" + +// Artifact is the output of inspecting a distributed package archive or a +// bare native object file. +type Artifact struct { + Version string `json:"version"` + Path string `json:"path"` + + // Format is the physical container format as reported by + // git-pkgs/magic: "zip", "gzip", "tar", "elf", "mach-o", "pe". + // Packaging-level identity (wheel, gem, jar) is added by later + // container metadata parsing. + Format string `json:"format"` + + // SHA256 is the hex-encoded digest of the raw input file. + SHA256 string `json:"sha256,omitempty"` + + // Entries is the total number of regular-file entries walked when the + // input is an archive. Zero for bare native objects. + Entries int `json:"entries,omitempty"` + + // NativeObjects lists every ELF, Mach-O, or PE object found: the input + // itself when it is a bare native object, or each such entry inside an + // archive. + NativeObjects []binary.Object `json:"native_objects,omitempty"` + + // DurationMS is wall-clock milliseconds spent producing the report, + // excluding archive download time. + DurationMS float64 `json:"duration_ms"` +} diff --git a/cmd/brief/inspect.go b/cmd/brief/inspect.go new file mode 100644 index 0000000..078c30d --- /dev/null +++ b/cmd/brief/inspect.go @@ -0,0 +1,855 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/bzip2" + "compress/gzip" + "crypto/sha256" + stdbin "encoding/binary" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + pathpkg "path" + "path/filepath" + "runtime/debug" + "sort" + "strings" + "time" + + "github.com/git-pkgs/archives" + "github.com/git-pkgs/magic" + "github.com/ulikunitz/xz" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/brief/binary" + "github.com/git-pkgs/brief/report" +) + +const ( + // magicSniffLen is enough for every native-object and archive prefix that + // git-pkgs/magic recognises, including the PE header-offset indirection at + // 0x3c and the tar checksum at offset 148. + magicSniffLen = 512 + + inspectGCPercent = 100 + maxArchiveEntries = 100_000 + maxArchiveInputBytes = 512 << 20 + maxArchiveExtractedBytes = 512 << 20 + + peHeaderOffsetAt = 0x3c + peSignatureLen = 4 + + zipDirectoryHeaderSignature = 0x02014b50 + zipDirectoryEndSignature = 0x06054b50 + zipDirectory64EndSignature = 0x06064b50 + zipDirectory64LocSignature = 0x07064b50 + zipDirectoryHeaderLen = 46 + zipDirectoryEndLen = 22 + zipDirectory64EndLen = 56 + zipDirectory64LocLen = 20 + zipDirectorySearchLen = 65 << 10 + zipUint16Max = 1<<16 - 1 + zipUint32Max = 1<<32 - 1 +) + +var ( + errArchiveLimit = errors.New("archive resource limit exceeded") + errArchiveDuplicatePath = errors.New("archive contains duplicate file path") +) + +func cmdInspect(args []string) { + enableInspectGC() + + fs := flag.NewFlagSet("brief inspect", flag.ExitOnError) + jsonFlag := fs.Bool("json", false, "Force JSON output") + humanFlag := fs.Bool("human", false, "Force human-readable output") + _ = fs.Parse(args) + + if fs.NArg() == 0 { + _, _ = fmt.Fprintln(os.Stderr, "usage: brief inspect ") + os.Exit(1) + } + + art, err := inspectPath(fs.Arg(0)) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if *jsonFlag || (!*humanFlag && !isTTY()) { + writeJSONOrExit(report.ArtifactJSON(os.Stdout, art)) + } else { + report.ArtifactHuman(os.Stdout, art) + } +} + +func enableInspectGC() { + debug.SetGCPercent(inspectGCPercent) +} + +// inspectPath opens path, decides whether it is a bare native object or an +// archive, and returns an Artifact describing its native-object contents. +func inspectPath(path string) (*brief.Artifact, error) { + start := time.Now() + + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + head, err := sniff(f) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + return nil, err + } + if head.Format == "" && isPEFile(f, info.Size()) { + head.Format = magic.FormatPE + } + + art := &brief.Artifact{ + Version: brief.Version, + Path: path, + Format: head.Format, + } + + switch { + case isNativeObject(head.Format): + obj, err := binary.InspectReader(f, info.Size()) + if err != nil { + return nil, err + } + obj.Path = path + art.NativeObjects = []binary.Object{*obj} + art.SHA256 = hashFile(f) + + case isArchive(head.Format): + if err := inspectArchive(f, art); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("%s: not a native object or supported archive (detected %q)", + path, magicLabel(head)) + } + + art.DurationMS = float64(time.Since(start).Microseconds()) / 1000.0 //nolint:mnd + return art, nil +} + +// inspectArchive opens f as an archive, extracts it to a temp directory, and +// walks the tree collecting native objects into art. +func inspectArchive(f *os.File, art *brief.Artifact) error { + info, err := f.Stat() + if err != nil { + return err + } + if err := checkArchiveInputSize(info.Size()); err != nil { + return err + } + if err := preflightArtifactArchive(f, art.Path, art.Format, maxArchiveEntries, maxArchiveExtractedBytes); err != nil { + return err + } + dir, err := os.MkdirTemp("", "brief-inspect-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + caseInsensitive, err := filesystemCaseInsensitive(dir) + if err != nil { + return err + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + r, err := openArtifactArchive(f, art.Path, art.Format, caseInsensitive) + if err != nil { + return err + } + defer func() { _ = r.Close() }() + + if h, err := r.Hash("SHA256"); err == nil { + art.SHA256 = h + } + + if err := archives.ExtractAll(r, dir); err != nil { + return fmt.Errorf("extracting archive: %w", err) + } + if err := makeExtractedTreeAccessible(dir); err != nil { + return fmt.Errorf("preparing extracted archive: %w", err) + } + + return collectNativeObjects(dir, art) +} + +func checkArchiveInputSize(size int64) error { + if size < 0 || size > maxArchiveInputBytes { + return fmt.Errorf("%w: input is %d bytes, limit is %d", + errArchiveLimit, size, maxArchiveInputBytes) + } + return nil +} + +type archiveInputLimitReader struct { + io.Reader + remaining int64 +} + +func newArchiveInputLimitReader(r io.Reader, maxBytes int64) *archiveInputLimitReader { + return &archiveInputLimitReader{Reader: r, remaining: maxBytes} +} + +func (r *archiveInputLimitReader) Read(p []byte) (int, error) { + return readWithArchiveLimit(r.Reader, &r.remaining, p) +} + +// preflightArtifactArchive counts entries before archives.Open eagerly reads +// and indexes them. This keeps the entry cap effective for hostile archives +// with a small payload and a very large number of headers. +func preflightArtifactArchive(f *os.File, filePath, format string, maxEntries int, maxBytes int64) error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + + switch format { + case magic.FormatZIP: + info, err := f.Stat() + if err != nil { + return err + } + return preflightZIP(f, info.Size(), maxEntries) + case magic.FormatTAR: + r := newArchiveInputLimitReader(f, maxArchiveInputBytes) + if strings.EqualFold(filepath.Ext(filePath), ".gem") { + return preflightGem(r, maxEntries, maxBytes) + } + return preflightTAR(r, maxEntries, maxBytes) + case magic.FormatGZIP: + gz, err := gzip.NewReader(newArchiveInputLimitReader(f, maxArchiveInputBytes)) + if err != nil { + return fmt.Errorf("opening gzip: %w", err) + } + defer func() { _ = gz.Close() }() + return preflightTAR(gz, maxEntries, maxBytes) + case magic.FormatBZIP2: + return preflightTAR(bzip2.NewReader(newArchiveInputLimitReader(f, maxArchiveInputBytes)), maxEntries, maxBytes) + case magic.FormatXZ: + if err := preflightXZ(newArchiveInputLimitReader(f, maxArchiveInputBytes), maxXZDictionaryBytes); err != nil { + return err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + xzReader, err := xz.NewReader(newArchiveInputLimitReader(f, maxArchiveInputBytes)) + if err != nil { + return fmt.Errorf("opening xz: %w", err) + } + return preflightTAR(xzReader, maxEntries, maxBytes) + default: + return nil + } +} + +func preflightTAR(r io.Reader, maxEntries int, maxBytes int64) error { + tr := tar.NewReader(r) + entries := 0 + var total int64 + for { + header, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading tar: %w", err) + } + if err := checkTARHeader(header, &entries, &total, maxEntries, maxBytes); err != nil { + return err + } + } +} + +func checkTARHeader(header *tar.Header, entries *int, total *int64, maxEntries int, maxBytes int64) error { + (*entries)++ + if *entries > maxEntries { + return fmt.Errorf("%w: more than %d entries", errArchiveLimit, maxEntries) + } + + mode := header.FileInfo().Mode() + if header.Typeflag == tar.TypeLink { + mode |= fs.ModeIrregular + } + if header.Typeflag == tar.TypeDir || mode&fs.ModeType != 0 { + return nil + } + if header.Size < 0 || header.Size > maxBytes-(*total) { + return fmt.Errorf("%w: declared content exceeds %d bytes", errArchiveLimit, maxBytes) + } + *total += header.Size + return nil +} + +// preflightGem checks the nested data.tar.gz that archives.Open exposes. If +// the nested payload is malformed, the caller retains the existing behaviour +// of treating the outer file as a plain tar archive. +func preflightGem(r io.Reader, maxEntries int, maxBytes int64) error { + tr := tar.NewReader(r) + entries := 0 + var total int64 + for { + header, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading gem tar: %w", err) + } + if err := checkTARHeader(header, &entries, &total, maxEntries, maxBytes); err != nil { + return err + } + if header.Name != "data.tar.gz" { + continue + } + + gz, err := gzip.NewReader(tr) + if err != nil { + continue + } + innerErr := preflightTAR(gz, maxEntries, maxBytes) + _ = gz.Close() + if innerErr == nil || errors.Is(innerErr, errArchiveLimit) { + return innerErr + } + } +} + +type zipDirectoryEnd struct { + offset int64 + directoryRecords uint64 + directorySize uint64 + directoryOffset uint64 +} + +func preflightZIP(r io.ReaderAt, size int64, maxEntries int) error { + end, err := readZIPDirectoryEnd(r, size) + if err != nil { + return fmt.Errorf("reading zip directory: %w", err) + } + if end.directoryRecords > uint64(maxEntries) { + return fmt.Errorf("%w: %d entries exceeds limit of %d", + errArchiveLimit, end.directoryRecords, maxEntries) + } + + maxInt64 := uint64(1<<63 - 1) + if end.directorySize > maxInt64 || end.directoryOffset > maxInt64 { + return errors.New("zip directory offset exceeds supported size") + } + if end.directorySize > uint64(end.offset) { + return errors.New("zip directory size exceeds the archive") + } + start := end.offset - int64(end.directorySize) + if start < 0 || start >= size && end.directorySize != 0 { + return errors.New("zip directory offset is outside the archive") + } + + // Match archive/zip's compatibility path for files whose end record gives + // a spurious positive base offset but whose raw directory offset is valid. + if end.directoryOffset < uint64(start) && int64(end.directoryOffset) < size { + var signature [4]byte + if _, readErr := r.ReadAt(signature[:], int64(end.directoryOffset)); readErr == nil && + stdbin.LittleEndian.Uint32(signature[:]) == zipDirectoryHeaderSignature { + start = int64(end.directoryOffset) + } + } + + var header [zipDirectoryHeaderLen]byte + entries := 0 + for pos := start; pos+zipDirectoryHeaderLen <= size; { + if _, err := r.ReadAt(header[:], pos); err != nil { + return fmt.Errorf("reading zip directory entry: %w", err) + } + if stdbin.LittleEndian.Uint32(header[:4]) != zipDirectoryHeaderSignature { + break + } + entries++ + if entries > maxEntries { + return fmt.Errorf("%w: more than %d entries", errArchiveLimit, maxEntries) + } + + nameLen := int64(stdbin.LittleEndian.Uint16(header[28:30])) + extraLen := int64(stdbin.LittleEndian.Uint16(header[30:32])) + commentLen := int64(stdbin.LittleEndian.Uint16(header[32:34])) + next := pos + zipDirectoryHeaderLen + nameLen + extraLen + commentLen + if next <= pos || next > size { + return errors.New("zip directory entry extends beyond the archive") + } + pos = next + } + return nil +} + +func readZIPDirectoryEnd(r io.ReaderAt, size int64) (zipDirectoryEnd, error) { + if size < zipDirectoryEndLen { + return zipDirectoryEnd{}, io.ErrUnexpectedEOF + } + searchLen := int64(zipDirectorySearchLen + zipDirectoryEndLen) + if searchLen > size { + searchLen = size + } + buf := make([]byte, int(searchLen)) + if _, err := r.ReadAt(buf, size-searchLen); err != nil && err != io.EOF { + return zipDirectoryEnd{}, err + } + + index := -1 + for i := len(buf) - zipDirectoryEndLen; i >= 0; i-- { + if stdbin.LittleEndian.Uint32(buf[i:i+4]) != zipDirectoryEndSignature { + continue + } + commentLen := int(stdbin.LittleEndian.Uint16(buf[i+20 : i+22])) + if i+zipDirectoryEndLen+commentLen <= len(buf) { + index = i + break + } + } + if index < 0 { + return zipDirectoryEnd{}, errors.New("zip end record not found") + } + + record := buf[index : index+zipDirectoryEndLen] + if stdbin.LittleEndian.Uint16(record[4:6]) != 0 || stdbin.LittleEndian.Uint16(record[6:8]) != 0 { + return zipDirectoryEnd{}, errors.New("multi-disk zip archives are unsupported") + } + end := zipDirectoryEnd{ + offset: size - searchLen + int64(index), + directoryRecords: uint64(stdbin.LittleEndian.Uint16(record[10:12])), + directorySize: uint64(stdbin.LittleEndian.Uint32(record[12:16])), + directoryOffset: uint64(stdbin.LittleEndian.Uint32(record[16:20])), + } + + needsZIP64 := end.directoryRecords == zipUint16Max || end.directorySize == zipUint32Max || end.directoryOffset == zipUint32Max + if !needsZIP64 { + return end, nil + } + zip64End, found, err := readZIP64DirectoryEnd(r, end) + if err != nil { + return zipDirectoryEnd{}, err + } + if found { + return zip64End, nil + } + if end.directorySize == zipUint32Max || end.directoryOffset == zipUint32Max { + return zipDirectoryEnd{}, errors.New("zip64 locator not found") + } + return end, nil +} + +func readZIP64DirectoryEnd(r io.ReaderAt, end zipDirectoryEnd) (zipDirectoryEnd, bool, error) { + locatorOffset := end.offset - zipDirectory64LocLen + if locatorOffset < 0 { + return end, false, nil + } + var locator [zipDirectory64LocLen]byte + if _, err := r.ReadAt(locator[:], locatorOffset); err != nil { + return zipDirectoryEnd{}, false, err + } + if stdbin.LittleEndian.Uint32(locator[:4]) != zipDirectory64LocSignature { + return end, false, nil + } + if stdbin.LittleEndian.Uint32(locator[4:8]) != 0 || + stdbin.LittleEndian.Uint32(locator[16:20]) != 1 { + return zipDirectoryEnd{}, false, errors.New("invalid zip64 locator") + } + + zip64Offset := stdbin.LittleEndian.Uint64(locator[8:16]) + if zip64Offset > uint64(1<<63-1) { + return zipDirectoryEnd{}, false, errors.New("zip64 directory offset exceeds supported size") + } + var record [zipDirectory64EndLen]byte + if _, err := r.ReadAt(record[:], int64(zip64Offset)); err != nil { + return zipDirectoryEnd{}, false, err + } + if stdbin.LittleEndian.Uint32(record[:4]) != zipDirectory64EndSignature || + stdbin.LittleEndian.Uint32(record[16:20]) != 0 || + stdbin.LittleEndian.Uint32(record[20:24]) != 0 { + return zipDirectoryEnd{}, false, errors.New("invalid zip64 end record") + } + + end.offset = int64(zip64Offset) + end.directoryRecords = stdbin.LittleEndian.Uint64(record[32:40]) + end.directorySize = stdbin.LittleEndian.Uint64(record[40:48]) + end.directoryOffset = stdbin.LittleEndian.Uint64(record[48:56]) + return end, true, nil +} + +// openArtifactArchive uses the sniffed physical format instead of trusting a +// possibly misleading filename extension. Gems need their filename for the +// archives package to unwrap data.tar.gz; malformed gems fall back to plain +// tar inspection. +func openArtifactArchive(f *os.File, path, format string, caseInsensitive bool) (*archiveLimitReader, error) { + var r archives.Reader + var err error + if format == magic.FormatTAR && strings.EqualFold(filepath.Ext(path), ".gem") { + r, err = archives.Open(filepath.Base(path), newArchiveInputLimitReader(f, maxArchiveInputBytes)) + if err != nil { + r = nil + if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil { + return nil, seekErr + } + } + } + if r == nil { + r, err = archives.Open("", newArchiveInputLimitReader(f, maxArchiveInputBytes)) + if err != nil { + return nil, fmt.Errorf("opening archive: %w", err) + } + } + + limited, err := newArchiveLimitReader(r, maxArchiveEntries, maxArchiveExtractedBytes, caseInsensitive) + if err != nil { + _ = r.Close() + return nil, err + } + return limited, nil +} + +type archiveLimitReader struct { + archives.Reader + entries []archives.FileInfo + remaining int64 +} + +func newArchiveLimitReader( + r archives.Reader, + maxEntries int, + maxBytes int64, + caseInsensitive bool, +) (*archiveLimitReader, error) { + entries, err := r.List() + if err != nil { + return nil, err + } + if len(entries) > maxEntries { + return nil, fmt.Errorf("%w: %d entries exceeds limit of %d", + errArchiveLimit, len(entries), maxEntries) + } + if err := checkDuplicateArchivePaths(entries, caseInsensitive); err != nil { + return nil, err + } + + var total int64 + for _, entry := range entries { + if entry.IsDir || fs.FileMode(entry.Mode)&fs.ModeType != 0 { + continue + } + if entry.Size < 0 || entry.Size > maxBytes-total { + return nil, fmt.Errorf("%w: declared content exceeds %d bytes", + errArchiveLimit, maxBytes) + } + total += entry.Size + } + + return &archiveLimitReader{ + Reader: r, + entries: entries, + remaining: maxBytes, + }, nil +} + +func checkDuplicateArchivePaths(entries []archives.FileInfo, caseInsensitive bool) error { + seen := make(map[string]string) + for _, entry := range entries { + if entry.IsDir || fs.FileMode(entry.Mode)&fs.ModeType != 0 { + continue + } + name := pathpkg.Clean(strings.TrimSuffix(entry.Path, "/")) + if name == "." || name == "" { + continue + } + local, err := filepath.Localize(name) + if err != nil { + // ExtractAll reports the unsafe path with its established error. + continue + } + key := filepath.Clean(local) + if caseInsensitive { + key = strings.ToLower(key) + } + if previous, ok := seen[key]; ok { + return fmt.Errorf("%w: %q conflicts with %q", + errArchiveDuplicatePath, entry.Path, previous) + } + seen[key] = entry.Path + } + return nil +} + +func filesystemCaseInsensitive(dir string) (bool, error) { + f, err := os.CreateTemp(dir, "brief-case-probe-a") + if err != nil { + return false, err + } + name := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(name) + return false, err + } + defer func() { _ = os.Remove(name) }() + + upper := filepath.Join(dir, strings.ToUpper(filepath.Base(name))) + if upper == name { + return false, errors.New("case-sensitivity probe did not produce a distinct path") + } + if _, err := os.Stat(upper); err == nil { + return true, nil + } else if errors.Is(err, fs.ErrNotExist) { + return false, nil + } else { + return false, err + } +} + +func makeExtractedTreeAccessible(root string) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + perm := info.Mode().Perm() + switch { + case d.IsDir(): + perm |= 0o700 + case info.Mode().IsRegular(): + perm |= 0o400 + default: + return nil + } + if perm == info.Mode().Perm() { + return nil + } + return os.Chmod(path, perm) + }) +} + +func (r *archiveLimitReader) List() ([]archives.FileInfo, error) { + return r.entries, nil +} + +func (r *archiveLimitReader) Extract(path string) (io.ReadCloser, error) { + content, err := r.Reader.Extract(path) + if err != nil { + return nil, err + } + return &archiveLimitReadCloser{ + ReadCloser: content, + remaining: &r.remaining, + }, nil +} + +type archiveLimitReadCloser struct { + io.ReadCloser + remaining *int64 +} + +func (r *archiveLimitReadCloser) Read(p []byte) (int, error) { + return readWithArchiveLimit(r.ReadCloser, r.remaining, p) +} + +func readWithArchiveLimit(r io.Reader, remaining *int64, p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if *remaining == 0 { + var probe [1]byte + n, err := r.Read(probe[:]) + if n > 0 { + return 0, errArchiveLimit + } + return 0, err + } + if int64(len(p)) > *remaining { + p = p[:int(*remaining)] + } + n, err := r.Read(p) + *remaining -= int64(n) + return n, err +} + +// collectNativeObjects walks root and appends a binary.Object to art for each +// ELF, Mach-O, or PE file it finds. Paths on the resulting objects are made +// relative to root so they read as archive-entry paths. +func collectNativeObjects(root string, art *brief.Artifact) error { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + art.Entries++ + + f, err := os.Open(path) + if err != nil { + return err + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return err + } + head, err := sniff(f) + if err != nil { + _ = f.Close() + return err + } + native := isNativeObject(head.Format) + if !native && head.Format == "" { + native = isPEFile(f, info.Size()) + } + _ = f.Close() + if !native { + return nil + } + + obj, err := binary.Inspect(path) + if err != nil { + // A file with a native-object magic that the debug/* + // parsers reject is unusual but not fatal for the + // artifact as a whole; skip it. + return nil //nolint:nilerr + } + obj.Path = archivePath(root, path) + art.NativeObjects = append(art.NativeObjects, *obj) + return nil + }) + if err != nil { + return err + } + sort.Slice(art.NativeObjects, func(i, j int) bool { + return art.NativeObjects[i].Path < art.NativeObjects[j].Path + }) + return nil +} + +// hashFile returns the hex SHA-256 of f from offset 0, or "" on error. +func hashFile(f *os.File) string { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "" + } + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "" + } + return hex.EncodeToString(h.Sum(nil)) +} + +// sniff reads up to magicSniffLen bytes from f and returns the magic +// classification. PE files whose header falls beyond this prefix are handled +// separately by isPEFile. The file position is left at the end of the prefix. +func sniff(f *os.File) (magic.Result, error) { + var head [magicSniffLen]byte + n, err := io.ReadFull(f, head[:]) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return magic.Result{}, err + } + return magic.DetectPrefix(head[:n]), nil +} + +func isPEFile(f *os.File, size int64) bool { + if size < peHeaderOffsetAt+4 { + return false + } + var dos [peHeaderOffsetAt + 4]byte + if _, err := f.ReadAt(dos[:], 0); err != nil { + return false + } + if dos[0] != 'M' || dos[1] != 'Z' { + return false + } + offset := int64(stdbin.LittleEndian.Uint32(dos[peHeaderOffsetAt:])) + if offset < peHeaderOffsetAt+4 || offset > size-peSignatureLen { + return false + } + var signature [peSignatureLen]byte + if _, err := f.ReadAt(signature[:], offset); err != nil { + return false + } + return bytes.Equal(signature[:], []byte{'P', 'E', 0, 0}) +} + +// inspectAutoArgs builds the argument slice for an auto-routed inspect call +// from cmdScan's already-parsed shared flags. +func inspectAutoArgs(jsonFlag, humanFlag bool, path string) []string { + var args []string + if jsonFlag { + args = append(args, "-json") + } + if humanFlag { + args = append(args, "-human") + } + return append(args, "--", path) +} + +// shouldAutoInspect reports whether the default command should route path to +// cmdInspect: it is a regular file whose header is a native object or archive. +func shouldAutoInspect(path string) bool { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + f, err := os.Open(path) + if err != nil { + return false + } + head, err := sniff(f) + if err != nil { + _ = f.Close() + return false + } + isArtifact := isNativeObject(head.Format) || isArchive(head.Format) || isPEFile(f, info.Size()) + _ = f.Close() + return isArtifact +} + +func isNativeObject(format string) bool { + switch format { + case magic.FormatELF, magic.FormatMachO, magic.FormatPE: + return true + } + return false +} + +func isArchive(format string) bool { + switch format { + case magic.FormatZIP, magic.FormatTAR, magic.FormatGZIP, + magic.FormatBZIP2, magic.FormatXZ: + return true + } + return false +} + +func magicLabel(r magic.Result) string { + if r.Format != "" { + return r.Format + } + return string(r.Kind) +} + +func archivePath(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return path + } + return filepath.ToSlash(rel) +} diff --git a/cmd/brief/inspect_test.go b/cmd/brief/inspect_test.go new file mode 100644 index 0000000..acdbe78 --- /dev/null +++ b/cmd/brief/inspect_test.go @@ -0,0 +1,765 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + stdbin "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "testing" + + "github.com/git-pkgs/archives" + "github.com/git-pkgs/magic" + "github.com/ulikunitz/xz" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/brief/binary" + "github.com/git-pkgs/brief/report" +) + +func TestInspectPathBareObject(t *testing.T) { + bin := buildHello(t, runtime.GOOS, runtime.GOARCH) + + art, err := inspectPath(bin) + if err != nil { + t.Fatal(err) + } + if art.Format != hostObjectFormat() { + t.Fatalf("Format = %q, want %q", art.Format, hostObjectFormat()) + } + if art.Entries != 0 { + t.Fatalf("Entries = %d, want 0 for bare object", art.Entries) + } + if art.SHA256 == "" { + t.Fatal("SHA256 not set for bare object") + } + if len(art.NativeObjects) != 1 { + t.Fatalf("NativeObjects = %d, want 1", len(art.NativeObjects)) + } + obj := art.NativeObjects[0] + if obj.Arch != runtime.GOARCH { + t.Fatalf("Arch = %q, want %q", obj.Arch, runtime.GOARCH) + } + if obj.Go == nil || obj.Go.Path != "github.com/git-pkgs/brief/binary/testdata/hello" { + t.Fatalf("Go = %+v, want testdata/hello module path", obj.Go) + } + + // JSON round-trip. + var buf bytes.Buffer + if err := report.ArtifactJSON(&buf, art); err != nil { + t.Fatal(err) + } + var back brief.Artifact + if err := json.Unmarshal(buf.Bytes(), &back); err != nil { + t.Fatalf("ArtifactJSON output does not decode: %v", err) + } + if back.NativeObjects[0].Arch != obj.Arch { + t.Fatalf("round-trip Arch = %q, want %q", back.NativeObjects[0].Arch, obj.Arch) + } +} + +func TestInspectPathPEHeaderBeyondMagicPrefix(t *testing.T) { + path := writeLargeStubPE(t) + if !shouldAutoInspect(path) { + t.Fatal("PE with a large DOS stub should auto-inspect") + } + + art, err := inspectPath(path) + if err != nil { + t.Fatal(err) + } + if art.Format != magic.FormatPE { + t.Fatalf("Format = %q, want %q", art.Format, magic.FormatPE) + } + if len(art.NativeObjects) != 1 || art.NativeObjects[0].Format != magic.FormatPE { + t.Fatalf("NativeObjects = %+v, want one PE object", art.NativeObjects) + } +} + +func TestInspectPathArchivePEHeaderBeyondMagicPrefix(t *testing.T) { + pe := writeLargeStubPE(t) + archive := writeZip(t, map[string]string{"bin/large-stub.exe": pe}) + + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if len(art.NativeObjects) != 1 || art.NativeObjects[0].Format != magic.FormatPE { + t.Fatalf("NativeObjects = %+v, want one PE object", art.NativeObjects) + } +} + +func TestInspectPathArchive(t *testing.T) { + if testing.Short() { + t.Skip("cross-compilation skipped in -short") + } + + // Put objects for two different formats in one zip alongside a text + // file so both the native-object filter and multi-entry sort are + // exercised. + elf := buildHello(t, "linux", "amd64") + pe := buildHello(t, "windows", "amd64") + archive := writeZip(t, map[string]string{ + "README.txt": "not a binary\n", + "lib/hello.so": elf, + "bin/hello.exe": pe, + "nested/deep.txt": "still not a binary\n", + }) + + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if art.Format != "zip" { + t.Fatalf("Format = %q, want zip", art.Format) + } + if art.SHA256 == "" { + t.Fatal("SHA256 not set for archive") + } + if art.Entries != 4 { + t.Fatalf("Entries = %d, want 4", art.Entries) + } + if len(art.NativeObjects) != 2 { + t.Fatalf("NativeObjects = %d, want 2", len(art.NativeObjects)) + } + // Sorted by archive path. + if art.NativeObjects[0].Path != "bin/hello.exe" || art.NativeObjects[0].Format != "pe" { + t.Fatalf("NativeObjects[0] = %+v, want bin/hello.exe pe", art.NativeObjects[0]) + } + if art.NativeObjects[1].Path != "lib/hello.so" || art.NativeObjects[1].Format != "elf" { + t.Fatalf("NativeObjects[1] = %+v, want lib/hello.so elf", art.NativeObjects[1]) + } +} + +func TestInspectPathUsesDetectedArchiveFormat(t *testing.T) { + archive := writeZip(t, map[string]string{ + "README.txt": "zip content\n", + }) + mislabeled := filepath.Join(filepath.Dir(archive), "fixture.tar.gz") + if err := os.Rename(archive, mislabeled); err != nil { + t.Fatal(err) + } + + art, err := inspectPath(mislabeled) + if err != nil { + t.Fatal(err) + } + if art.Format != "zip" { + t.Fatalf("Format = %q, want zip", art.Format) + } + if art.Entries != 1 { + t.Fatalf("Entries = %d, want 1", art.Entries) + } +} + +func TestInspectPathRejectsDuplicateArchivePaths(t *testing.T) { + archive := writeDuplicateZip(t) + if _, err := inspectPath(archive); !errors.Is(err, errArchiveDuplicatePath) { + t.Fatalf("inspectPath error = %v, want errArchiveDuplicatePath", err) + } +} + +func TestInspectPathHandlesRestrictiveArchiveModes(t *testing.T) { + t.Run("file", func(t *testing.T) { + archive := writeModeTar(t, []tarEntry{ + {name: "locked", mode: 0, content: "plain text\n"}, + }) + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if art.Entries != 1 { + t.Fatalf("Entries = %d, want 1", art.Entries) + } + }) + + t.Run("directory", func(t *testing.T) { + archive := writeModeTar(t, []tarEntry{ + {name: "locked/", mode: 0, directory: true}, + {name: "locked/file", mode: 0o600, content: "plain text\n"}, + }) + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if art.Entries != 1 { + t.Fatalf("Entries = %d, want 1", art.Entries) + } + }) +} + +func TestInspectPathCaseDistinctArchiveEntries(t *testing.T) { + archive := writeZip(t, map[string]string{"A": "one", "a": "two"}) + caseInsensitive, err := filesystemCaseInsensitive(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + art, err := inspectPath(archive) + if caseInsensitive { + if !errors.Is(err, errArchiveDuplicatePath) { + t.Fatalf("inspectPath error = %v, want errArchiveDuplicatePath", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if art.Entries != 2 { + t.Fatalf("Entries = %d, want 2", art.Entries) + } +} + +func TestArchiveInputLimits(t *testing.T) { + t.Run("input bytes", func(t *testing.T) { + err := checkArchiveInputSize(maxArchiveInputBytes + 1) + if !errors.Is(err, errArchiveLimit) { + t.Fatalf("checkArchiveInputSize error = %v, want errArchiveLimit", err) + } + }) + + t.Run("streamed input bytes", func(t *testing.T) { + r := newArchiveInputLimitReader(strings.NewReader("1234"), 3) + if _, err := io.ReadAll(r); !errors.Is(err, errArchiveLimit) { + t.Fatalf("ReadAll error = %v, want errArchiveLimit", err) + } + }) +} + +func TestArchivePreflightLimits(t *testing.T) { + t.Run("zip entry preflight", func(t *testing.T) { + archive := writeZip(t, map[string]string{"a": "one", "b": "two"}) + data, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + end := bytes.LastIndex(data, []byte{'P', 'K', 0x05, 0x06}) + if end < 0 { + t.Fatal("zip end record not found") + } + // Advertise one record even though the central directory contains two. + // The preflight must scan headers instead of trusting this count. + stdbin.LittleEndian.PutUint16(data[end+8:end+10], 1) + stdbin.LittleEndian.PutUint16(data[end+10:end+12], 1) + if err := os.WriteFile(archive, data, 0o644); err != nil { + t.Fatal(err) + } + f, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if err := preflightArtifactArchive(f, archive, magic.FormatZIP, 1, 10); !errors.Is(err, errArchiveLimit) { + t.Fatalf("preflightArtifactArchive error = %v, want errArchiveLimit", err) + } + }) + + t.Run("empty zip preflight", func(t *testing.T) { + archive := writeZip(t, map[string]string{}) + f, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if err := preflightArtifactArchive(f, archive, magic.FormatZIP, 1, 10); err != nil { + t.Fatal(err) + } + }) + + t.Run("tar entry preflight", func(t *testing.T) { + archive := writeTar(t, map[string]string{"a": "one", "b": "two"}) + f, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if err := preflightArtifactArchive(f, archive, magic.FormatTAR, 1, 10); !errors.Is(err, errArchiveLimit) { + t.Fatalf("preflightArtifactArchive error = %v, want errArchiveLimit", err) + } + }) +} + +func TestXZArchivePreflight(t *testing.T) { + t.Run("valid dictionary", func(t *testing.T) { + archive := writeTarXZ(t, map[string]string{"file": "plain text\n"}) + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if art.Entries != 1 { + t.Fatalf("Entries = %d, want 1", art.Entries) + } + }) + + t.Run("multiple blocks", func(t *testing.T) { + archive := writeTarXZWithBlockSize(t, map[string]string{ + "file": strings.Repeat("plain text\n", 1_000), + }, 512) + art, err := inspectPath(archive) + if err != nil { + t.Fatal(err) + } + if art.Entries != 1 { + t.Fatalf("Entries = %d, want 1", art.Entries) + } + }) + + t.Run("oversized dictionary", func(t *testing.T) { + archive := writeTarXZ(t, map[string]string{"file": "plain text\n"}) + setFirstXZDictionaryCode(t, archive, 40) + f, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if err := preflightArtifactArchive(f, archive, magic.FormatXZ, 10, 10); !errors.Is(err, errArchiveLimit) { + t.Fatalf("preflightArtifactArchive error = %v, want errArchiveLimit", err) + } + }) +} + +func TestArchiveReaderLimits(t *testing.T) { + t.Run("entry count", func(t *testing.T) { + r := &fakeArchiveReader{entries: []archives.FileInfo{ + {Path: "a", Size: 1}, + {Path: "b", Size: 1}, + }} + if _, err := newArchiveLimitReader(r, 1, 10, false); !errors.Is(err, errArchiveLimit) { + t.Fatalf("newArchiveLimitReader error = %v, want errArchiveLimit", err) + } + }) + + t.Run("normalized duplicate paths", func(t *testing.T) { + r := &fakeArchiveReader{entries: []archives.FileInfo{ + {Path: "entry", Size: 1}, + {Path: "./entry", Size: 1}, + }} + if _, err := newArchiveLimitReader(r, 2, 10, false); !errors.Is(err, errArchiveDuplicatePath) { + t.Fatalf("newArchiveLimitReader error = %v, want errArchiveDuplicatePath", err) + } + }) + + t.Run("case-insensitive duplicate paths", func(t *testing.T) { + r := &fakeArchiveReader{entries: []archives.FileInfo{ + {Path: "Entry", Size: 1}, + {Path: "entry", Size: 1}, + }} + if _, err := newArchiveLimitReader(r, 2, 10, true); !errors.Is(err, errArchiveDuplicatePath) { + t.Fatalf("newArchiveLimitReader error = %v, want errArchiveDuplicatePath", err) + } + }) + + t.Run("declared bytes", func(t *testing.T) { + r := &fakeArchiveReader{entries: []archives.FileInfo{{Path: "a", Size: 4}}} + if _, err := newArchiveLimitReader(r, 1, 3, false); !errors.Is(err, errArchiveLimit) { + t.Fatalf("newArchiveLimitReader error = %v, want errArchiveLimit", err) + } + }) + + t.Run("actual bytes", func(t *testing.T) { + r := &fakeArchiveReader{ + entries: []archives.FileInfo{{Path: "a", Size: 1}}, + contents: map[string][]byte{"a": []byte("1234")}, + } + limited, err := newArchiveLimitReader(r, 1, 3, false) + if err != nil { + t.Fatal(err) + } + if err := archives.ExtractAll(limited, t.TempDir()); !errors.Is(err, errArchiveLimit) { + t.Fatalf("ExtractAll error = %v, want errArchiveLimit", err) + } + }) +} + +func TestInspectPathNotAnArtifact(t *testing.T) { + path := filepath.Join(t.TempDir(), "plain.txt") + if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := inspectPath(path); err == nil { + t.Fatal("plain text file was accepted") + } +} + +func TestShouldAutoInspect(t *testing.T) { + dir := t.TempDir() + if shouldAutoInspect(dir) { + t.Fatal("directory should not auto-inspect") + } + + text := filepath.Join(dir, "text") + if err := os.WriteFile(text, []byte("plain\n"), 0o644); err != nil { + t.Fatal(err) + } + if shouldAutoInspect(text) { + t.Fatal("text file should not auto-inspect") + } + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if !shouldAutoInspect(exe) { + t.Fatal("test executable should auto-inspect") + } + + if shouldAutoInspect(filepath.Join(dir, "missing")) { + t.Fatal("missing file should not auto-inspect") + } +} + +func TestEnableInspectGC(t *testing.T) { + original := debug.SetGCPercent(-1) + t.Cleanup(func() { debug.SetGCPercent(original) }) + + enableInspectGC() + got := debug.SetGCPercent(-1) + debug.SetGCPercent(got) + if got != inspectGCPercent { + t.Fatalf("GC percent = %d, want %d", got, inspectGCPercent) + } +} + +func TestArtifactHumanSanitizesObjectFields(t *testing.T) { + // A malicious archive could carry ANSI escapes in a soname or dylib + // path, or line breaks in any field; the human formatter must strip them. + esc := "\x1b[31m" + injected := "\nInjected: yes\t" + art := &brief.Artifact{ + Version: "test", + Path: "x" + injected, + Format: "elf", + NativeObjects: []binary.Object{{ + Path: "evil" + esc + injected, + Format: "elf", + SOName: "lib" + esc + injected + "red.so", + Needed: []string{"lib" + esc + injected + ".so"}, + Producer: []string{"GCC" + esc + injected}, + Go: &binary.GoBuild{Version: "go1" + esc + injected, Main: "m" + esc + injected}, + Static: []binary.Hint{{Library: "z" + esc + injected, Match: esc + injected}}, + }}, + } + var buf bytes.Buffer + report.ArtifactHuman(&buf, art) + out := buf.String() + if strings.Contains(out, "\x1b") { + t.Fatalf("ANSI escape leaked into human output:\n%s", out) + } + if strings.Contains(out, "\nInjected:") || strings.Contains(out, "\t") { + t.Fatalf("line-breaking whitespace leaked into human output:\n%s", out) + } +} + +func BenchmarkInspectBareObject(b *testing.B) { + bin := buildHello(b, runtime.GOOS, runtime.GOARCH) + b.ReportAllocs() + for b.Loop() { + if _, err := inspectPath(bin); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkInspectArchive(b *testing.B) { + elf := buildHello(b, "linux", "amd64") + entries := map[string]string{"lib/hello.so": elf} + // Pad with text files so the per-entry sniff cost is measured against + // a realistic ratio of source to native objects. + for i := range 50 { + entries[fmt.Sprintf("src/pkg/file%02d.py", i)] = "# comment\n" + } + archive := writeZip(b, entries) + + b.ReportAllocs() + for b.Loop() { + if _, err := inspectPath(archive); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkShouldAutoInspect(b *testing.B) { + exe, err := os.Executable() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + shouldAutoInspect(exe) + } +} + +// buildHello cross-compiles binary/testdata/hello for goos/goarch and returns +// the output path. Skips the calling test if the target toolchain is +// unavailable. +func buildHello(tb testing.TB, goos, goarch string) string { + tb.Helper() + out := filepath.Join(tb.TempDir(), "hello") + if goos == "windows" { + out += ".exe" + } + cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, "github.com/git-pkgs/brief/binary/testdata/hello") + cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=0") + if b, err := cmd.CombinedOutput(); err != nil { + tb.Skipf("cross-compile %s/%s: %v\n%s", goos, goarch, err, b) + } + return out +} + +func writeLargeStubPE(tb testing.TB) string { + tb.Helper() + const headerOffset = 0x400 + data := make([]byte, headerOffset+24) + copy(data[:2], "MZ") + stdbin.LittleEndian.PutUint32(data[peHeaderOffsetAt:], headerOffset) + copy(data[headerOffset:], []byte{'P', 'E', 0, 0}) + coff := data[headerOffset+peSignatureLen:] + stdbin.LittleEndian.PutUint16(coff[0:2], 0x8664) // IMAGE_FILE_MACHINE_AMD64 + stdbin.LittleEndian.PutUint16(coff[18:20], 0x0002) + + path := filepath.Join(tb.TempDir(), "large-stub.exe") + if err := os.WriteFile(path, data, 0o644); err != nil { + tb.Fatal(err) + } + return path +} + +func writeDuplicateZip(tb testing.TB) string { + tb.Helper() + path := filepath.Join(tb.TempDir(), "duplicate.zip") + f, err := os.Create(path) + if err != nil { + tb.Fatal(err) + } + zw := zip.NewWriter(f) + for _, content := range []string{"plain text\n", "second entry\n"} { + w, err := zw.Create("entry") + if err != nil { + tb.Fatal(err) + } + if _, err := io.WriteString(w, content); err != nil { + tb.Fatal(err) + } + } + if err := zw.Close(); err != nil { + tb.Fatal(err) + } + if err := f.Close(); err != nil { + tb.Fatal(err) + } + return path +} + +func writeTar(tb testing.TB, entries map[string]string) string { + tb.Helper() + path := filepath.Join(tb.TempDir(), "fixture.tar") + f, err := os.Create(path) + if err != nil { + tb.Fatal(err) + } + tw := tar.NewWriter(f) + for name, content := range entries { + header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))} + if err := tw.WriteHeader(header); err != nil { + tb.Fatal(err) + } + if _, err := io.WriteString(tw, content); err != nil { + tb.Fatal(err) + } + } + if err := tw.Close(); err != nil { + tb.Fatal(err) + } + if err := f.Close(); err != nil { + tb.Fatal(err) + } + return path +} + +type tarEntry struct { + name string + mode int64 + content string + directory bool +} + +func writeModeTar(tb testing.TB, entries []tarEntry) string { + tb.Helper() + path := filepath.Join(tb.TempDir(), "fixture.tar") + f, err := os.Create(path) + if err != nil { + tb.Fatal(err) + } + tw := tar.NewWriter(f) + for _, entry := range entries { + typeflag := byte(tar.TypeReg) + if entry.directory { + typeflag = tar.TypeDir + } + header := &tar.Header{ + Name: entry.name, + Mode: entry.mode, + Size: int64(len(entry.content)), + Typeflag: typeflag, + } + if err := tw.WriteHeader(header); err != nil { + tb.Fatal(err) + } + if _, err := io.WriteString(tw, entry.content); err != nil { + tb.Fatal(err) + } + } + if err := tw.Close(); err != nil { + tb.Fatal(err) + } + if err := f.Close(); err != nil { + tb.Fatal(err) + } + return path +} + +func writeTarXZ(tb testing.TB, entries map[string]string) string { + tb.Helper() + return writeTarXZWithBlockSize(tb, entries, 0) +} + +func writeTarXZWithBlockSize(tb testing.TB, entries map[string]string, blockSize int64) string { + tb.Helper() + path := filepath.Join(tb.TempDir(), "fixture.tar.xz") + f, err := os.Create(path) + if err != nil { + tb.Fatal(err) + } + xw, err := xz.WriterConfig{DictCap: 1 << 20, BlockSize: blockSize}.NewWriter(f) + if err != nil { + tb.Fatal(err) + } + tw := tar.NewWriter(xw) + for name, content := range entries { + header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))} + if err := tw.WriteHeader(header); err != nil { + tb.Fatal(err) + } + if _, err := io.WriteString(tw, content); err != nil { + tb.Fatal(err) + } + } + if err := tw.Close(); err != nil { + tb.Fatal(err) + } + if err := xw.Close(); err != nil { + tb.Fatal(err) + } + if err := f.Close(); err != nil { + tb.Fatal(err) + } + return path +} + +func setFirstXZDictionaryCode(tb testing.TB, path string, code byte) { + tb.Helper() + data, err := os.ReadFile(path) + if err != nil { + tb.Fatal(err) + } + const xzStreamHeaderLen = 12 + if len(data) <= xzStreamHeaderLen { + tb.Fatal("xz block header missing") + } + headerLen := (int(data[xzStreamHeaderLen]) + 1) * 4 + if headerLen < 12 || xzStreamHeaderLen+headerLen > len(data) { + tb.Fatalf("invalid xz block header length %d", headerLen) + } + header := data[xzStreamHeaderLen : xzStreamHeaderLen+headerLen] + if header[1] != 0 || header[2] != 0x21 || header[3] != 1 { + tb.Fatalf("unexpected xz block header %x", header) + } + header[4] = code + stdbin.LittleEndian.PutUint32(header[len(header)-4:], crc32.ChecksumIEEE(header[:len(header)-4])) + if err := os.WriteFile(path, data, 0o644); err != nil { + tb.Fatal(err) + } +} + +// writeZip creates a zip at a temp path. For each entry, if the value names an +// existing file its bytes are stored, otherwise the value itself is stored as +// literal content. +func writeZip(tb testing.TB, entries map[string]string) string { + tb.Helper() + path := filepath.Join(tb.TempDir(), "fixture.zip") + f, err := os.Create(path) + if err != nil { + tb.Fatal(err) + } + zw := zip.NewWriter(f) + for name, val := range entries { + w, err := zw.Create(name) + if err != nil { + tb.Fatal(err) + } + data := []byte(val) + if b, err := os.ReadFile(val); err == nil { + data = b + } + if _, err := w.Write(data); err != nil { + tb.Fatal(err) + } + } + if err := zw.Close(); err != nil { + tb.Fatal(err) + } + if err := f.Close(); err != nil { + tb.Fatal(err) + } + return path +} + +func hostObjectFormat() string { + switch runtime.GOOS { + case "darwin", "ios": + return "mach-o" + case "windows": + return "pe" + default: + return "elf" + } +} + +type fakeArchiveReader struct { + entries []archives.FileInfo + contents map[string][]byte +} + +func (r *fakeArchiveReader) List() ([]archives.FileInfo, error) { + return r.entries, nil +} + +func (r *fakeArchiveReader) ListDir(string) ([]archives.FileInfo, error) { + return nil, nil +} + +func (r *fakeArchiveReader) Extract(path string) (io.ReadCloser, error) { + content, ok := r.contents[path] + if !ok { + return nil, fmt.Errorf("file not found: %s", path) + } + return io.NopCloser(bytes.NewReader(content)), nil +} + +func (r *fakeArchiveReader) Hash(string) (string, error) { + return "", nil +} + +func (r *fakeArchiveReader) Close() error { + return nil +} diff --git a/cmd/brief/main.go b/cmd/brief/main.go index 7efe2c4..520185b 100644 --- a/cmd/brief/main.go +++ b/cmd/brief/main.go @@ -54,6 +54,9 @@ func main() { case "outline": cmdOutline(os.Args[2:]) return + case "inspect": + cmdInspect(os.Args[2:]) + return } } @@ -86,6 +89,16 @@ func cmdScan(args []string) { path = fs.Arg(0) } + // A regular-file argument that sniffs as a native object or archive is + // routed to inspect so `brief foo.whl` and `brief foo.so` work without + // the subcommand. Only the shared -json/-human flags carry over; + // scan-specific flags are dropped rather than passed to a FlagSet that + // would reject them. + if shouldAutoInspect(path) { + cmdInspect(inspectAutoArgs(*jsonFlag, *humanFlag, path)) + return + } + // Resolve remote sources src, err := remote.Resolve(context.Background(), path, remote.Options{ Keep: *keep, diff --git a/cmd/brief/xz_preflight.go b/cmd/brief/xz_preflight.go new file mode 100644 index 0000000..f80c863 --- /dev/null +++ b/cmd/brief/xz_preflight.go @@ -0,0 +1,429 @@ +package main + +import ( + "bufio" + "bytes" + stdbin "encoding/binary" + "errors" + "fmt" + "hash" + "hash/crc32" + "io" +) + +const ( + // The largest dictionary used by the standard xz presets is 64 MiB. + maxXZDictionaryBytes = 64 << 20 + + xzStreamHeaderLen = 12 + xzStreamFooterLen = 12 + xzLZMA2FilterID = 0x21 + xzMaxVLIBytes = 10 + xzAlignment = 4 + + xzLZMA2CompressedControl = 0x80 + xzLZMA2PropertiesControl = 0xc0 + xzLZMA2UncompressedHighMask = 0x1f + xzLZMA2UncompressedHighShift = 16 + xzLZMA2PropertiesCodeCount = 9 * 5 * 5 + xzVLIContinuationBit = 0x80 + xzVLIValueMask = 0x7f + xzVLIGroupBits = 7 + xzMaxDictionaryCode = 40 + xzDictionaryBase = 2 + xzDictionaryShiftBase = 11 + xzMaximumDictionarySize = 1<<32 - 1 + xzBlockHeaderChecksumSize = 4 + xzIndexChecksumSize = 4 + xzCheckCRC32 byte = 1 + xzCheckCRC64 byte = 4 + xzCheckSHA256 byte = 10 + xzCRC32Size = 4 + xzCRC64Size = 8 + xzSHA256Size = 32 + + xzBlockCompressedSizePresent = 0x40 + xzBlockUncompressedSizePresent = 0x80 + xzBlockReservedFlags = 0x3c +) + +var xzStreamMagic = []byte{0xfd, '7', 'z', 'X', 'Z', 0x00} + +type xzBlockHeader struct { + length int + compressedSize int64 + uncompressedSize int64 +} + +type xzIndexRecord struct { + unpaddedSize int64 + uncompressedSize int64 +} + +type xzReader interface { + io.Reader + io.ByteReader +} + +// preflightXZ validates every LZMA2 block header and chunk boundary before +// xz.NewReader can allocate the dictionary declared by an untrusted stream. +func preflightXZ(r io.Reader, maxDictionaryBytes uint64) error { + br := bufio.NewReader(r) + for { + if err := preflightXZStream(br, maxDictionaryBytes); err != nil { + return fmt.Errorf("reading xz: %w", err) + } + + for { + prefix, err := br.Peek(1) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("reading xz stream padding: %w", err) + } + if prefix[0] != 0 { + break + } + var padding [4]byte + if _, err := io.ReadFull(br, padding[:]); err != nil { + return fmt.Errorf("reading xz stream padding: %w", err) + } + if !xzAllZeros(padding[:]) { + return errors.New("xz stream padding is not aligned") + } + } + } +} + +func preflightXZStream(r xzReader, maxDictionaryBytes uint64) error { + flags, checkSize, err := readXZStreamHeader(r) + if err != nil { + return err + } + + var records []xzIndexRecord + for { + first, err := r.ReadByte() + if err != nil { + return err + } + if first == 0 { + indexSize, err := readXZIndex(r, records) + if err != nil { + return err + } + return readXZStreamFooter(r, flags, indexSize) + } + + header, err := readXZBlockHeader(r, first, maxDictionaryBytes) + if err != nil { + return err + } + compressedSize, uncompressedSize, err := scanXZLZMA2(r) + if err != nil { + return err + } + if header.compressedSize >= 0 && header.compressedSize != compressedSize { + return errors.New("xz block compressed size does not match its header") + } + if header.uncompressedSize >= 0 && header.uncompressedSize != uncompressedSize { + return errors.New("xz block uncompressed size does not match its header") + } + + paddingLen := xzPadding(int64(header.length) + compressedSize) + padding := make([]byte, paddingLen) + if _, err := io.ReadFull(r, padding); err != nil { + return err + } + if !xzAllZeros(padding) { + return errors.New("xz block padding contains non-zero bytes") + } + if _, err := io.CopyN(io.Discard, r, int64(checkSize)); err != nil { + return err + } + + records = append(records, xzIndexRecord{ + unpaddedSize: int64(header.length) + compressedSize + int64(checkSize), + uncompressedSize: uncompressedSize, + }) + if len(records) > maxArchiveEntries { + return fmt.Errorf("%w: more than %d xz blocks", errArchiveLimit, maxArchiveEntries) + } + } +} + +func readXZStreamHeader(r io.Reader) (flags byte, checkSize int, err error) { + var header [xzStreamHeaderLen]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return 0, 0, err + } + if !bytes.Equal(header[:len(xzStreamMagic)], xzStreamMagic) { + return 0, 0, errors.New("invalid xz stream header") + } + if header[6] != 0 || crc32.ChecksumIEEE(header[6:8]) != stdbin.LittleEndian.Uint32(header[8:]) { + return 0, 0, errors.New("invalid xz stream flags") + } + checkSize, err = xzCheckSize(header[7]) + if err != nil { + return 0, 0, err + } + return header[7], checkSize, nil +} + +func readXZBlockHeader( + r io.Reader, + first byte, + maxDictionaryBytes uint64, +) (xzBlockHeader, error) { + headerLen := (int(first) + 1) * xzAlignment + header := make([]byte, headerLen) + header[0] = first + if _, err := io.ReadFull(r, header[1:]); err != nil { + return xzBlockHeader{}, err + } + contentEnd := headerLen - xzBlockHeaderChecksumSize + if crc32.ChecksumIEEE(header[:contentEnd]) != stdbin.LittleEndian.Uint32(header[contentEnd:]) { + return xzBlockHeader{}, errors.New("invalid xz block header checksum") + } + + flags := header[1] + if flags&xzBlockReservedFlags != 0 || flags&0x03 != 0 { + return xzBlockHeader{}, errors.New("unsupported xz block flags") + } + br := bytes.NewReader(header[2:contentEnd]) + result := xzBlockHeader{length: headerLen, compressedSize: -1, uncompressedSize: -1} + if flags&xzBlockCompressedSizePresent != 0 { + size, _, err := readXZVLI(br) + if err != nil || size > 1<<63-1 { + return xzBlockHeader{}, errors.New("invalid xz block compressed size") + } + result.compressedSize = int64(size) + } + if flags&xzBlockUncompressedSizePresent != 0 { + size, _, err := readXZVLI(br) + if err != nil || size > 1<<63-1 { + return xzBlockHeader{}, errors.New("invalid xz block uncompressed size") + } + result.uncompressedSize = int64(size) + } + + filterID, _, err := readXZVLI(br) + if err != nil || filterID != xzLZMA2FilterID { + return xzBlockHeader{}, errors.New("unsupported xz block filter") + } + propertiesLen, _, err := readXZVLI(br) + if err != nil || propertiesLen != 1 { + return xzBlockHeader{}, errors.New("invalid xz LZMA2 filter properties") + } + dictionaryCode, err := br.ReadByte() + if err != nil { + return xzBlockHeader{}, err + } + dictionarySize, err := xzDictionarySize(dictionaryCode) + if err != nil { + return xzBlockHeader{}, err + } + if dictionarySize > maxDictionaryBytes { + return xzBlockHeader{}, fmt.Errorf("%w: xz dictionary is %d bytes, limit is %d", + errArchiveLimit, dictionarySize, maxDictionaryBytes) + } + if padding := header[contentEnd-br.Len() : contentEnd]; !xzAllZeros(padding) { + return xzBlockHeader{}, errors.New("xz block header padding contains non-zero bytes") + } + return result, nil +} + +func scanXZLZMA2(r xzReader) (compressedSize int64, uncompressedSize int64, err error) { + for { + control, err := r.ReadByte() + if err != nil { + return compressedSize, uncompressedSize, err + } + compressedSize++ + switch { + case control == 0: + return compressedSize, uncompressedSize, nil + case control == 1 || control == 2: + var header [2]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return compressedSize, uncompressedSize, err + } + size := int64(stdbin.BigEndian.Uint16(header[:])) + 1 + compressedSize += int64(len(header)) + size + uncompressedSize += size + if _, err := io.CopyN(io.Discard, r, size); err != nil { + return compressedSize, uncompressedSize, err + } + case control >= xzLZMA2CompressedControl: + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return compressedSize, uncompressedSize, err + } + compressed := int64(stdbin.BigEndian.Uint16(header[2:])) + 1 + uncompressed := int64(control&xzLZMA2UncompressedHighMask)<= xzLZMA2PropertiesControl { + properties, err := r.ReadByte() + if err != nil { + return compressedSize, uncompressedSize, err + } + compressedSize++ + if properties >= xzLZMA2PropertiesCodeCount { + return compressedSize, uncompressedSize, errors.New("invalid LZMA2 properties") + } + } + if _, err := io.CopyN(io.Discard, r, compressed); err != nil { + return compressedSize, uncompressedSize, err + } + default: + return compressedSize, uncompressedSize, errors.New("invalid LZMA2 chunk control byte") + } + } +} + +func readXZIndex(r xzReader, expected []xzIndexRecord) (int64, error) { + crc := crc32.NewIEEE() + _, _ = crc.Write([]byte{0}) + checked := &xzChecksumReader{reader: r, hash: crc} + recordCount, n, err := readXZVLI(checked) + consumed := int64(n + 1) + if err != nil { + return consumed, err + } + if recordCount != uint64(len(expected)) { + return consumed, errors.New("xz index block count does not match the stream") + } + for _, want := range expected { + unpadded, count, err := readXZVLI(checked) + consumed += int64(count) + if err != nil { + return consumed, err + } + uncompressed, count, err := readXZVLI(checked) + consumed += int64(count) + if err != nil { + return consumed, err + } + if unpadded != uint64(want.unpaddedSize) || uncompressed != uint64(want.uncompressedSize) { + return consumed, errors.New("xz index record does not match its block") + } + } + + padding := make([]byte, xzPadding(consumed)) + if _, err := io.ReadFull(checked, padding); err != nil { + return consumed, err + } + consumed += int64(len(padding)) + if !xzAllZeros(padding) { + return consumed, errors.New("xz index padding contains non-zero bytes") + } + var checksum [xzIndexChecksumSize]byte + if _, err := io.ReadFull(r, checksum[:]); err != nil { + return consumed, err + } + if stdbin.LittleEndian.Uint32(checksum[:]) != crc.Sum32() { + return consumed, errors.New("invalid xz index checksum") + } + return consumed + int64(len(checksum)), nil +} + +func readXZStreamFooter(r io.Reader, flags byte, indexSize int64) error { + var footer [xzStreamFooterLen]byte + if _, err := io.ReadFull(r, footer[:]); err != nil { + return err + } + if !bytes.Equal(footer[10:], []byte{'Y', 'Z'}) || + crc32.ChecksumIEEE(footer[4:10]) != stdbin.LittleEndian.Uint32(footer[:4]) { + return errors.New("invalid xz stream footer") + } + if footer[8] != 0 || footer[9] != flags { + return errors.New("xz stream footer flags do not match the header") + } + wantIndexSize := (int64(stdbin.LittleEndian.Uint32(footer[4:8])) + 1) * xzAlignment + if indexSize != wantIndexSize { + return errors.New("xz stream footer index size does not match") + } + return nil +} + +type xzChecksumReader struct { + reader io.Reader + hash hash.Hash32 +} + +func (r *xzChecksumReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + _, _ = r.hash.Write(p[:n]) + return n, err +} + +func (r *xzChecksumReader) ReadByte() (byte, error) { + var p [1]byte + if _, err := io.ReadFull(r.reader, p[:]); err != nil { + return 0, err + } + _, _ = r.hash.Write(p[:]) + return p[0], nil +} + +func readXZVLI(r io.ByteReader) (uint64, int, error) { + var value uint64 + for i, shift := 0, uint(0); ; i, shift = i+1, shift+xzVLIGroupBits { + b, err := r.ReadByte() + if err != nil { + return value, i, err + } + if i >= xzMaxVLIBytes || i == xzMaxVLIBytes-1 && b > 1 { + return value, i + 1, errors.New("xz VLI overflows uint64") + } + value |= uint64(b&xzVLIValueMask) << shift + if b < xzVLIContinuationBit { + return value, i + 1, nil + } + } +} + +func xzDictionarySize(code byte) (uint64, error) { + if code > xzMaxDictionaryCode { + return 0, errors.New("invalid xz dictionary size code") + } + if code == xzMaxDictionaryCode { + return xzMaximumDictionarySize, nil + } + return uint64(xzDictionaryBase|code&1) << (xzDictionaryShiftBase + (code >> 1)), nil +} + +func xzCheckSize(flags byte) (int, error) { + switch flags { + case 0: + return 0, nil + case xzCheckCRC32: + return xzCRC32Size, nil + case xzCheckCRC64: + return xzCRC64Size, nil + case xzCheckSHA256: + return xzSHA256Size, nil + default: + return 0, errors.New("unsupported xz integrity check") + } +} + +func xzPadding(size int64) int { + if remainder := size % xzAlignment; remainder != 0 { + return int(xzAlignment - remainder) + } + return 0 +} + +func xzAllZeros(data []byte) bool { + for _, b := range data { + if b != 0 { + return false + } + } + return true +} diff --git a/go.mod b/go.mod index 980a7b9..09d59d7 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.26 require ( github.com/BurntSushi/toml v1.6.0 + github.com/git-pkgs/archives v0.5.0 github.com/git-pkgs/enrichment v0.6.4 github.com/git-pkgs/forge v0.7.0 github.com/git-pkgs/licensecheck v0.4.1 + github.com/git-pkgs/magic v0.2.0 github.com/git-pkgs/manifests v0.7.0 github.com/git-pkgs/outline v0.1.8 github.com/git-pkgs/purl v0.1.15 @@ -21,7 +23,6 @@ require ( github.com/bazelbuild/buildtools v0.0.0-20260716142318-04cf7de1434f // indirect github.com/ecosyste-ms/ecosystems-go v0.4.0 // indirect github.com/git-pkgs/gitignore v1.2.0 // indirect - github.com/git-pkgs/magic v0.1.0 // indirect github.com/git-pkgs/packageurl-go v0.3.1 // indirect github.com/git-pkgs/pom v0.1.5 // indirect github.com/git-pkgs/vers v0.3.0 // indirect @@ -34,5 +35,6 @@ require ( github.com/odvcencio/gotreesitter v0.45.0 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect github.com/pandatix/go-cvss v0.6.2 // indirect + github.com/ulikunitz/xz v0.5.16 // indirect golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index e282681..351abd0 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT9CapVc5dGA= github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA= +github.com/git-pkgs/archives v0.5.0 h1:QdowC1jTOSbEOKTYGqVt8ZjIr+yJzy7cmLNWcLPj9Y8= +github.com/git-pkgs/archives v0.5.0/go.mod h1:tfio0OIuPKEBKHs/UCL5XBUvYmKpnvtnba2iDlfSd6g= github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU= github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY= github.com/git-pkgs/forge v0.7.0 h1:ZEH93CAzh22nlhoks7ssaR65eZjQSonKAHg9DkPDf5A= @@ -20,8 +22,8 @@ github.com/git-pkgs/gitignore v1.2.0 h1:7vdR8/SvF31dvXqIdC1bKgCvyySefXuN8aY3xJLu github.com/git-pkgs/gitignore v1.2.0/go.mod h1:Lr0XwhbvP071rZF/zIIhkY1gEhFDoWHH91lngwLpeUg= github.com/git-pkgs/licensecheck v0.4.1 h1:b5ilmpIpgeeewBFjdhJ4W7jvwPIFsYQ7ujZma7sli6k= github.com/git-pkgs/licensecheck v0.4.1/go.mod h1:cfFO7yHHPeuXsoODHBWyevajH2yWcbfkIFELyG3ZpU0= -github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY= -github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= +github.com/git-pkgs/magic v0.2.0 h1:c7HqVxnP8c88EaVMH0/KraDFVTcmiXckRiSvNZEnvMQ= +github.com/git-pkgs/magic v0.2.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= github.com/git-pkgs/manifests v0.7.0 h1:dEsBXRWSaZl5Li0aI53wzitecg31rnVVfQJh13B8Eng= github.com/git-pkgs/manifests v0.7.0/go.mod h1:U2aHcGcF7nJzAtRPU3ktVtb6GitJS9fmkKPKJUNHD38= github.com/git-pkgs/outline v0.1.8 h1:559sqAEapAkyfe/VUqNeVXok8tMILuFAEpFdvSfB86c= @@ -68,6 +70,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/report/report.go b/report/report.go index 33787e0..53653c3 100644 --- a/report/report.go +++ b/report/report.go @@ -26,6 +26,20 @@ func sanitize(s string) string { }, s) } +// sanitizeLine strips terminal control characters and replaces line-breaking +// whitespace so untrusted values cannot add fields to line-oriented output. +func sanitizeLine(s string) string { + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' { + return ' ' + } + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} + // JSON writes the report as JSON. func JSON(w io.Writer, r *brief.Report) error { enc := json.NewEncoder(w) @@ -388,6 +402,52 @@ func printEnrichment(w io.Writer, e *brief.EnrichmentInfo) { } } +// ArtifactJSON writes the artifact report as JSON. +func ArtifactJSON(w io.Writer, a *brief.Artifact) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(a) +} + +// ArtifactHuman writes the artifact report in human-readable format. +func ArtifactHuman(w io.Writer, a *brief.Artifact) { + _, _ = fmt.Fprintf(w, "brief %s — %s\n\n", a.Version, sanitizeLine(a.Path)) + _, _ = fmt.Fprintf(w, "Format: %s\n", a.Format) + if a.SHA256 != "" { + _, _ = fmt.Fprintf(w, "SHA256: %s\n", a.SHA256) + } + if a.Entries > 0 { + _, _ = fmt.Fprintf(w, "Entries: %d files, %d native objects\n", a.Entries, len(a.NativeObjects)) + } + + for _, obj := range a.NativeObjects { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintf(w, "Object: %s\n", sanitizeLine(obj.Path)) + _, _ = fmt.Fprintf(w, " Format: %s %s\n", obj.Format, obj.Arch) + if obj.SOName != "" { + _, _ = fmt.Fprintf(w, " SOName: %s\n", sanitizeLine(obj.SOName)) + } + for _, p := range obj.Producer { + _, _ = fmt.Fprintf(w, " Producer: %s\n", sanitizeLine(p)) + } + for _, n := range obj.Needed { + _, _ = fmt.Fprintf(w, " Needed: %s\n", sanitizeLine(n)) + } + if obj.Go != nil { + _, _ = fmt.Fprintf(w, " Go: %s %s\n", sanitizeLine(obj.Go.Version), sanitizeLine(obj.Go.Main)) + } + for _, h := range obj.Static { + label := h.Library + if h.Version != "" { + label += " " + h.Version + } + _, _ = fmt.Fprintf(w, " Static: %s (low confidence: %q)\n", sanitizeLine(label), sanitizeLine(h.Match)) + } + } + + _, _ = fmt.Fprintf(w, "\n%.1fms\n", a.DurationMS) +} + // MissingJSON writes the missing report as JSON. func MissingJSON(w io.Writer, r *brief.MissingReport) error { enc := json.NewEncoder(w)