@@ -9,20 +9,24 @@ import (
99 "fmt"
1010 "io"
1111 "log/slog"
12+ "mime"
1213 "net/http"
1314 "net/url"
1415 "os"
16+ "regexp"
1517 "runtime"
1618 "strings"
1719
1820 "go.opentelemetry.io/otel/attribute"
1921 "go.opentelemetry.io/otel/codes"
22+ semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
2023 "go.opentelemetry.io/otel/trace"
2124 "golang.org/x/sync/errgroup"
2225
2326 "github.com/quay/claircore"
2427 "github.com/quay/claircore/indexer"
2528 "github.com/quay/claircore/internal/cache"
29+ "github.com/quay/claircore/internal/httpreader"
2630 "github.com/quay/claircore/internal/httputil"
2731 "github.com/quay/claircore/internal/wart"
2832 "github.com/quay/claircore/internal/zreader"
@@ -125,6 +129,43 @@ func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl
125129 span .End ()
126130 }()
127131
132+ var d details
133+ d , err = a .inspect (ctx , desc )
134+ if err != nil {
135+ return err
136+ }
137+
138+ HTTPReader:
139+ switch { // Switch for a single case to be able to break.
140+ case d .Uncompressed :
141+ var opts []httpreader.Option
142+ if d .ContentLength > 0 {
143+ opts = append (opts , httpreader .WithSize (d .ContentLength ))
144+ }
145+ if len (desc .Headers ) != 0 {
146+ opts = append (opts , httpreader .WithHeaders (desc .Headers ))
147+ }
148+ var rd * httpreader.Reader
149+ rd , err = httpreader .New (ctx , a .wc , desc .URI , opts ... )
150+ switch {
151+ case err == nil :
152+ if err = l .Init (ctx , desc , rd ); err != nil {
153+ return err
154+ }
155+ * cl = closeFunc (func () (err error ) {
156+ err = errors .Join (l .Close (), rd .Close ())
157+ return err
158+ })
159+ a .logger (desc ).DebugContext (ctx , "using httpreader" )
160+ return nil
161+ case errors .Is (err , errors .ErrUnsupported ):
162+ err = nil
163+ break HTTPReader
164+ default :
165+ return err
166+ }
167+ }
168+
128169 // NB This is not closed on purpose. The [io.Closer] populated by this
129170 // function holds the pointer until that function is cleaned up. Once
130171 // nothing has a copy of this [*os.File], the runtime will run all the
@@ -135,19 +176,20 @@ func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl
135176 var spool * os.File
136177 spool , err = a .files .Get (ctx , key , func (ctx context.Context , _ string ) (* os.File , error ) {
137178 cacheHit = false
138- return a .fetchFileForCache (ctx , desc )
179+ return a .fetchFileForCache (ctx , desc , d )
139180 })
140181 if err != nil {
141182 return err
142183 }
184+ var f * os.File
143185 // This is an owned, independent descriptor for the passed [*os.File].
144- f , err : = reopen (a .root , spool )
186+ f , err = reopen (a .root , spool )
145187 if err != nil {
146188 return err
147189 }
148190
149191 // If this succeeds, "f" is now owned by "l"
150- if err : = l .Init (ctx , desc , f ); err != nil {
192+ if err = l .Init (ctx , desc , f ); err != nil {
151193 return errors .Join (err , f .Close ())
152194 }
153195 * cl = closeFunc (func () (err error ) {
@@ -173,12 +215,106 @@ func (f closeFunc) Close() error {
173215 return f ()
174216}
175217
218+ // Inspect makes a request to the layer's URI and examines the response for
219+ // useful information.
220+ func (a * RemoteFetchArena ) inspect (ctx context.Context , desc * claircore.LayerDescription ) (d details , err error ) {
221+ ctx , span := tracer .Start (ctx , "RemoteFetchArena.inspect" )
222+ defer func () {
223+ a .logger (desc ).DebugContext (ctx , "inspected resource" , "ok" , err == nil , "details" , & d )
224+ span .RecordError (err )
225+ span .End ()
226+ }()
227+ span .SetStatus (codes .Error , "" )
228+
229+ var req * http.Request
230+ var res * http.Response
231+ req , err = http .NewRequestWithContext (ctx , http .MethodGet , desc .URI , nil )
232+ if err != nil {
233+ return d , fmt .Errorf ("fetcher: failed to construct request: %w" , err )
234+ }
235+ req .Header = http .Header (desc .Headers ).Clone ()
236+ req .Header .Set (`claircore-reason` , `inspect` )
237+ req .Header .Set (`range` , `bytes=0-15` )
238+ res , err = a .wc .Do (req )
239+ if err != nil {
240+ return d , fmt .Errorf ("fetcher: request failed: %w" , err )
241+ }
242+ err = httputil .CheckResponse (res , http .StatusOK , http .StatusPartialContent )
243+ if err != nil {
244+ return d , fmt .Errorf ("fetcher: %w" , err )
245+ }
246+ head := make ([]byte , 16 )
247+ _ , err = io .ReadFull (res .Body , head )
248+ _ = res .Body .Close ()
249+ if err != nil {
250+ return d , fmt .Errorf ("fetcher: unexpected read: %w" , err )
251+ }
252+
253+ const (
254+ ctKey = `content-type`
255+ arKey = `accept-ranges`
256+ )
257+ ct , _ , err := mime .ParseMediaType (res .Header .Get (ctKey ))
258+ if err != nil {
259+ return d , fmt .Errorf ("fetcher: %w" , err )
260+ }
261+ span .SetAttributes (
262+ semconv .HTTPResponseStatusCode (res .StatusCode ),
263+ semconv .HTTPResponseBodySize (int (res .ContentLength )),
264+ semconv .HTTPResponseHeader (ctKey , ct ),
265+ semconv .HTTPResponseHeader (arKey , res .Header .Get (arKey )),
266+ )
267+ if isOctetStream (ct ) && zreader .DetectCompression (head ) == zreader .KindNone {
268+ ct = `application/x-tar`
269+ }
270+ switch res .StatusCode {
271+ case http .StatusOK :
272+ d .ContentLength = res .ContentLength
273+ case http .StatusPartialContent :
274+ var cr httpreader.ContentRange
275+ if err := cr .Parse (res .Header .Get (`content-range` )); err == nil {
276+ d .ContentLength = cr .Length
277+ }
278+ }
279+ d .Uncompressed = ct == "application/x-tar" || strings .HasSuffix (ct , ".tar" )
280+ d .RangeOK = res .Header .Get (arKey ) == `bytes` || res .StatusCode == http .StatusPartialContent
281+
282+ span .SetStatus (codes .Ok , "" )
283+ return d , nil
284+ }
285+
286+ var _ slog.LogValuer = (* details )(nil )
287+
288+ // Details is the useful information reported by [RemoteFetchArena.inspect].
289+ type details struct {
290+ // The content length, if known.
291+ ContentLength int64
292+ // The resource reports to be known non-compressed contents.
293+ Uncompressed bool
294+ // The server reports supporting "bytes" via the "Accept-Ranges" header.
295+ RangeOK bool
296+ }
297+
298+ // LogValue implements [slog.LogValuer].
299+ func (d * details ) LogValue () slog.Value {
300+ return slog .GroupValue (
301+ slog .Int64 ("content-length" , d .ContentLength ),
302+ slog .Bool ("uncompressed" , d .Uncompressed ),
303+ slog .Bool ("range-ok" , d .RangeOK ),
304+ )
305+ }
306+
307+ // Logger returns a [*slog.Logger] with predefined attributes.
308+ func (a * RemoteFetchArena ) logger (desc * claircore.LayerDescription ) * slog.Logger {
309+ return slog .With ("arena" , a .root .Name (), "layer" , desc .Digest , "uri" , desc .URI )
310+ }
311+
176312// FetchFileForCache is the inner function used inside the [cache.Live].
177313//
178314// Because we know we're the only concurrent call that's dealing with this blob,
179315// we can be a bit more lax.
180- func (a * RemoteFetchArena ) fetchFileForCache (ctx context.Context , desc * claircore.LayerDescription ) (* os.File , error ) {
181- log := slog . With ( "arena" , a . root . Name (), "layer" , desc . Digest , "uri" , desc . URI )
316+ func (a * RemoteFetchArena ) fetchFileForCache (ctx context.Context , desc * claircore.LayerDescription , _ details ) (* os.File , error ) {
317+ log := a . logger ( desc )
182318 ctx , span := tracer .Start (ctx , "RemoteFetchArena.fetchFileForCache" )
183319 defer span .End ()
184320 span .SetStatus (codes .Error , "" )
@@ -210,6 +346,7 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
210346 URL : url ,
211347 Header : http .Header (desc .Headers ).Clone (),
212348 }).WithContext (ctx )
349+ req .Header .Set (`claircore-reason` , `fetch` )
213350 resp , err := a .wc .Do (req )
214351 if err != nil {
215352 return nil , fmt .Errorf ("fetcher: request failed: %w" , err )
@@ -235,10 +372,13 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
235372 }
236373 defer zr .Close ()
237374 // Look at the content-type and optionally fix it up.
238- ct := resp .Header .Get ("content-type" )
375+ ct , _ , err := mime .ParseMediaType (resp .Header .Get ("content-type" ))
376+ if err != nil {
377+ return nil , fmt .Errorf ("fetcher: %w" , err )
378+ }
239379 log .DebugContext (ctx , "reported content-type" , "content-type" , ct )
240380 span .SetAttributes (payloadType (ct ), payloadCompression (kind ))
241- if ct == "" || ct == "text/plain" || ct == "binary/octet-stream" || ct == "application/octet-stream" {
381+ if ct == "" || ct == "text/plain" || isOctetStream ( ct ) {
242382 switch kind {
243383 case zreader .KindGzip :
244384 ct = "application/gzip"
@@ -420,3 +560,14 @@ func (p *FetchProxy) Close() error {
420560 }
421561 return errors .Join (errs ... )
422562}
563+
564+ // IsOctetStream tests if the incoming media type string is an "octet-stream"
565+ // type.
566+ //
567+ // Reports false for strings that are not of the form "<type>/<subtype>". For
568+ // interoperability purposes, the nonstandard "binary" type is accepted.
569+ func isOctetStream (ct string ) bool {
570+ return reOctetStream .MatchString (ct )
571+ }
572+
573+ var reOctetStream = regexp .MustCompile (`^(application|binary)/octet-stream$` )
0 commit comments