// Package imageasset resolves bounded local, HTTP, and data-URI image // resources without depending on a renderer or scene representation. Animated // GIF, APNG, and WebP resources use their first frame on the logical canvas; // later frames are neither decoded nor charged to the decoded-pixel budget.
package imageasset import ( ) // Preserve imgbundler's worker ceiling so per-resource byte limits also bound // aggregate in-flight fetch, decompression, validation, and retained bytes. const maxConcurrentResolutions = 16 const maxResponseHeaderBytes int64 = 64 << 10 // Kind identifies how a resolved resource must be consumed. type Kind uint8 const ( KindRaster Kind = iota + 1 KindSVG ) // ErrUnavailable identifies a source whose bytes could not be fetched. It is // reserved for absence and transport failures; malformed image data, policy // limits, unsupported sources, and caller cancellation do not match it. var ErrUnavailable = errors.New("imageasset: source unavailable") type unavailableError struct { err error } func ( *unavailableError) () string { return .err.Error() } func ( *unavailableError) () error { return .err } func ( *unavailableError) ( error) bool { return == ErrUnavailable } func unavailable( error) error { if == nil || errors.Is(, ErrUnavailable) { return } return &unavailableError{err: } } func ( Kind) () string { switch { case KindRaster: return "raster" case KindSVG: return "svg" default: return fmt.Sprintf("Kind(%d)", ) } } // Limits are caller-selected, inclusive ceilings. There are intentionally no // package defaults: callers must choose budgets appropriate to their corpus. type Limits struct { // MaxFetchedBytes bounds bytes read from a local file or HTTP response and // the encoded payload text of a data URI. MaxFetchedBytes int64 // MaxEncodedBytes bounds the final PNG, JPEG, GIF, WebP, or SVG byte stream. MaxEncodedBytes int64 // MaxDecompressedBytes bounds bytes after HTTP content decoding. Identity, // local-file, and data-URI sources are checked against it as well. MaxDecompressedBytes int64 // MaxSVGBytes bounds raw or declared-compressed SVG XML before tokenization. // Callers that import resolved SVG must align this with the importer's // per-source byte ceiling. MaxSVGBytes int64 MaxDecodedWidth int MaxDecodedHeight int MaxDecodedPixels int64 // MaxAssets bounds distinct successfully resolved canonical sources. MaxAssets int // MaxCumulativeEncodedBytes bounds immutable encoded bytes retained by one // Resolver session. MaxCumulativeEncodedBytes int64 // MaxCumulativeDecodedBytes bounds unique canonical sources successfully // returned by one Resolver session. Raster resources, including animated // inputs represented by their first frame, charge at least width*height*4, // or eight bytes per pixel for 16-bit models; SVG resources charge their // strict raw byte length. MaxCumulativeDecodedBytes int64 } func ( Limits) () error { if .MaxFetchedBytes <= 0 || .MaxEncodedBytes <= 0 || .MaxDecompressedBytes <= 0 || .MaxSVGBytes <= 0 || .MaxDecodedWidth <= 0 || .MaxDecodedHeight <= 0 || .MaxDecodedPixels <= 0 || .MaxAssets <= 0 || .MaxCumulativeEncodedBytes <= 0 || .MaxCumulativeDecodedBytes <= 0 { return errors.New("imageasset: every limit must be positive") } return nil } // Resource is immutable after construction. BytesContext returns an owned // copy, so a cached Resource can safely be shared between goroutines and // converted into a scene asset without exposing the cache's backing storage. type Resource struct { kind Kind mimeType string data []byte pixelWidth int pixelHeight int fetchedBytes int64 decompressedBytes int64 decodedBytes int64 } func ( *Resource) () Kind { return .kind } func ( *Resource) () string { return .mimeType } func ( *Resource) () int { return .pixelWidth } func ( *Resource) () int { return .pixelHeight } func ( *Resource) () int64 { return int64(len(.data)) } func ( *Resource) () int64 { return .decodedBytes } func ( *Resource) () int64 { return .fetchedBytes } func ( *Resource) () int64 { return .decompressedBytes } func ( *Resource) () []byte { return append([]byte(nil), .data...) } // BytesContext returns an owned copy while observing cancellation between // bounded chunks. A canceled copy never exposes partially initialized bytes. func ( *Resource) ( context.Context) ([]byte, error) { if == nil { = context.Background() } if := checkContext(); != nil { return nil, } := make([]byte, len(.data)) const = 32 << 10 for := 0; < len(.data); += { if := checkContext(); != nil { return nil, } := + if > len(.data) { = len(.data) } copy([:], .data[:]) } if := checkContext(); != nil { return nil, } return , nil } // DataURIContext returns a data URI for the immutable resource while observing // cancellation between bounded encoding chunks. It encodes the resource's // private backing bytes directly, avoiding the full owned copy required by // BytesContext for callers that only need a self-contained representation. func ( *Resource) ( context.Context) ([]byte, error) { if == nil { return nil, errors.New("imageasset: nil resource") } if == nil { = context.Background() } if := checkContext(); != nil { return nil, } := []byte("data:" + .mimeType + ";base64,") := int64(len(.data)) if > (int64(^uint64(0)>>1)-2)/4*3 { return nil, errors.New("imageasset: data URI size overflows int64") } := ( + 2) / 3 * 4 := int64(len()) + := uint64(^uint(0) >> 1) if < 0 || uint64() > { return nil, errors.New("imageasset: data URI is not representable") } := make([]byte, int()) copy(, ) := len() // A multiple of three lets each non-final chunk encode independently. const = 48 << 10 for := 0; < len(.data); += { if := checkContext(); != nil { return nil, } := + if > len(.data) { = len(.data) } base64.StdEncoding.Encode([:], .data[:]) += base64.StdEncoding.EncodedLen( - ) } if := checkContext(); != nil { return nil, } if != len() { return nil, errors.New("imageasset: data URI size preflight mismatch") } return , nil } // Cache is optional. Implementations must be safe for concurrent use. Only // Resources produced by this package can contain non-empty immutable data. type Cache interface { Get(key string) (*Resource, bool) Put(key string, resource *Resource) } // MemoryCache is a bounded, concurrency-safe LRU cache. It is never installed // globally; callers explicitly own it, and Resolver namespaces every key. type MemoryCache struct { mu sync.Mutex entries map[string]*cacheEntry recent *list.List maxEntries int maxBytes int64 bytes int64 } type cacheEntry struct { key string resource *Resource element *list.Element } // NewMemoryCache returns a cache with inclusive entry and encoded-byte limits. func ( int, int64) (*MemoryCache, error) { if <= 0 || <= 0 { return nil, errors.New("imageasset: cache limits must be positive") } return &MemoryCache{ entries: make(map[string]*cacheEntry), recent: list.New(), maxEntries: , maxBytes: , }, nil } func ( *MemoryCache) ( string) (*Resource, bool) { if == nil || .recent == nil { return nil, false } .mu.Lock() defer .mu.Unlock() , := .entries[] if ! { return nil, false } .recent.MoveToFront(.element) return .resource, true } func ( *MemoryCache) ( string, *Resource) { if == nil || .recent == nil || == nil { return } .mu.Lock() defer .mu.Unlock() if int64(len(.data)) > .maxBytes { if , := .entries[]; { delete(.entries, ) .recent.Remove(.element) .bytes -= int64(len(.resource.data)) } return } if , := .entries[]; { delete(.entries, ) .bytes -= int64(len(.resource.data)) .recent.Remove(.element) } := int64(len(.data)) for len(.entries) >= .maxEntries || > .maxBytes-.bytes { := .recent.Back() if == nil { break } := .Value.(*cacheEntry) delete(.entries, .key) .recent.Remove() .bytes -= int64(len(.resource.data)) } := &cacheEntry{key: , resource: } .element = .recent.PushFront() .entries[] = .bytes += } // Options configures one independent resolver. type Options struct { // BaseDir resolves relative local paths. New makes it absolute. When it is // empty, rooted local-file policies use their configured root and other // policies freeze the current working directory at construction time. BaseDir string // LocalFiles controls local-file access. Its zero value denies local files. // Use localfile.Rooted for untrusted input. localfile.Unrestricted is an // explicit opt-in intended only for trusted local applications. LocalFiles localfile.Policy HTTPClient *http.Client // NetworkPolicy defaults to public addresses only. Trusted callers can // explicitly allow private-network assets. NetworkPolicy netpolicy.Policy Cache Cache // CacheNamespace is required when Cache is set. Reusing a namespace asserts // identical tenant, credentials, CookieJar, redirect policy, transport, and // fetch semantics for every Resolver sharing that cache. NetworkPolicy is // automatically included in the effective namespace. CacheNamespace string Limits Limits } // Resolver is one cumulative-budget session (normally one output document). It // owns no global state and is safe for concurrent use. type Resolver struct { baseDir string localFiles localfile.Policy client *http.Client // ownsHTTPTransport is true when New created or policy-cloned the // transport. Injected transports deliberately allowed by a private-network // policy remain caller-owned and are not closed by this resolver. ownsHTTPTransport bool cache Cache cachePrefix string limits Limits mu sync.Mutex assetCount int cumulativeEncodedBytes int64 cumulativeDecodedBytes int64 resolvedSources map[string]*Resource inflight map[string]*resolveCall resolveSlots chan struct{} } func ( Options) (*Resolver, error) { if := .Limits.Validate(); != nil { return nil, } if .Cache != nil && .CacheNamespace == "" { return nil, errors.New("imageasset: CacheNamespace is required when Cache is set") } := .BaseDir if == "" { if , := .LocalFiles.RootPath(); { = } else { = "." } } , := filepath.Abs() if != nil { return nil, fmt.Errorf("imageasset: resolve base directory %q: %w", , ) } = filepath.Clean() // Match imgbundler's per-request safety net when the caller does not // provide a client. An injected client's fields are preserved, including a // shorter timeout and its redirect policy; loadHTTP also keeps the existing // one-minute request ceiling. := boundedDefaultTransport(http.DefaultTransport) := &http.Client{Transport: , Timeout: time.Minute} := true if .HTTPClient != nil { := *.HTTPClient if .Transport == nil { .Transport = } else if .NetworkPolicy.AllowPrivateNetworks { // The trusted policy preserves a caller-injected transport instead of // cloning it, so its connection pool remains caller-owned. = false } = & } , = netpolicy.NewHTTPClient(, .NetworkPolicy) if != nil { return nil, fmt.Errorf("imageasset: configure HTTP network policy: %w", ) } := "" if .Cache != nil { := "public-only" if .NetworkPolicy.AllowPrivateNetworks { = "private-network" } := sha256.Sum256([]byte(.CacheNamespace + "\x00network-policy:" + )) = fmt.Sprintf("namespace:%x:", ) } return &Resolver{ baseDir: , localFiles: .LocalFiles, client: , ownsHTTPTransport: , cache: .Cache, cachePrefix: , limits: .Limits, resolvedSources: make(map[string]*Resource), inflight: make(map[string]*resolveCall), resolveSlots: make(chan struct{}, maxConcurrentResolutions), }, nil } // CloseIdleConnections releases idle connections held by the transport New // created for this resolver session. A trusted caller-injected transport is // borrowed and remains the caller's responsibility. func ( *Resolver) () { if == nil || .client == nil || !.ownsHTTPTransport { return } .client.CloseIdleConnections() } func boundedDefaultTransport( http.RoundTripper) *http.Transport { if , := .(*http.Transport); { := .Clone() .MaxResponseHeaderBytes = maxResponseHeaderBytes return } // http.DefaultTransport is exported and may legally be replaced. Retain the // standard library's safe defaults instead of asserting its concrete type. return &http.Transport{ Proxy: http.ProxyFromEnvironment, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, MaxResponseHeaderBytes: maxResponseHeaderBytes, } } func ( *Resolver) () int { .mu.Lock() defer .mu.Unlock() return .assetCount } func ( *Resolver) () int64 { .mu.Lock() defer .mu.Unlock() return .cumulativeEncodedBytes } func ( *Resolver) () int64 { .mu.Lock() defer .mu.Unlock() return .cumulativeDecodedBytes } // SourceError associates an error with the external source and resolution // stage. Data URI payloads are never copied into Source or error text. type SourceError struct { Source string Op string Err error } func ( *SourceError) () string { return fmt.Sprintf("imageasset: %s %q: %v", .Op, .Source, .Err) } func ( *SourceError) () error { return .Err } // LimitError reports the exact inclusive ceiling that was exceeded. type LimitError struct { Name string Actual int64 Limit int64 } func ( *LimitError) () string { return fmt.Sprintf("%s %d exceeds limit %d", .Name, .Actual, .Limit) } func sourceError(, string, error) error { if == nil { return nil } return &SourceError{Source: , Op: , Err: } } func checkContext( context.Context) error { select { case <-.Done(): return .Err() default: return nil } }