package bundle import ( "errors" "fmt" "io" "path" "strings" ) // ErrLimit reports that a transport, archive or extraction budget was exceeded. var ErrLimit = errors.New("bundle: resource limit exceeded") // Limits bounds both archive transport and decoded tar data, including metadata. // Zero fields select defaults. These ceilings permit large (1M-prefix) configs; // they are safety ceilings, not a promise of suitability for a small-RAM node. type Limits struct { MaxCompressedBytes int64 MaxExpandedBytes int64 MaxFileBytes int64 MaxManifestBytes int64 MaxEntries int MaxPathBytes int } // DefaultLimits permits large production configs while bounding untrusted input. func DefaultLimits() Limits { return Limits{MaxCompressedBytes: 128 << 20, MaxExpandedBytes: 512 << 20, MaxFileBytes: 256 << 20, MaxManifestBytes: 2 << 20, MaxEntries: 4096, MaxPathBytes: 1024} } // Normalized fills zero fields with defaults and rejects invalid budgets. func (l Limits) Normalized() (Limits, error) { d := DefaultLimits() values := []struct { v *int64 fallback int64 }{{&l.MaxCompressedBytes, d.MaxCompressedBytes}, {&l.MaxExpandedBytes, d.MaxExpandedBytes}, {&l.MaxFileBytes, d.MaxFileBytes}, {&l.MaxManifestBytes, d.MaxManifestBytes}} for _, x := range values { if *x.v < 0 || *x.v == int64(^uint64(0)>>1) { return l, fmt.Errorf("bundle: invalid byte limit") } if *x.v == 0 { *x.v = x.fallback } } if l.MaxEntries < 0 || l.MaxPathBytes < 0 { return l, fmt.Errorf("bundle: invalid entry/path limit") } if l.MaxEntries == 0 { l.MaxEntries = d.MaxEntries } if l.MaxPathBytes == 0 { l.MaxPathBytes = d.MaxPathBytes } return l, nil } func validatePath(name string, limit int) error { if name == "" || len(name) > limit || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.ContainsAny(name, "\\:\x00") { return fmt.Errorf("bundle: unsafe path %q", name) } for _, part := range strings.Split(name, "/") { if part == ".." || strings.TrimRight(part, " .") != part { return fmt.Errorf("bundle: unsafe path %q", name) } base := strings.ToUpper(strings.SplitN(part, ".", 2)[0]) if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') { return fmt.Errorf("bundle: reserved path %q", name) } } return nil } type limitedWriter struct { w io.Writer remaining int64 } func (w *limitedWriter) Write(p []byte) (int, error) { if int64(len(p)) > w.remaining { return 0, ErrLimit } n, err := w.w.Write(p) w.remaining -= int64(n) return n, err } // ReadCompressed rejects oversized input while reading, including chunked HTTP // bodies and local files whose size changes after stat. func ReadCompressed(r io.Reader, limits Limits) ([]byte, error) { l, err := limits.Normalized() if err != nil { return nil, err } b, err := io.ReadAll(io.LimitReader(r, l.MaxCompressedBytes+1)) if err != nil { return nil, err } if int64(len(b)) > l.MaxCompressedBytes { return nil, ErrLimit } return b, nil }