Repository: losisin/helm-values-schema-json
Pull request #179 (2025-06-07 19:20:16)

chore: Split loaders into separate file & add 200MB limit reader to HTTPLoader
I wanted to tackle #150, but for that I want a smaller file to work with.

To not mess up the diff during the PR review then I'll do this step as a separate PR.

To get the coverage to pass, I've also added some new code:

- Removed the "windows path fix" code in favor of more general code and better usage of `filepath.FromSlash`
- Added `LimitReader` to `HTTPLoader` to reject files over 200MB



Patch:
diff --git a/pkg/bundle.go b/pkg/bundle.go
index 178182b..a180e92 100644
--- a/pkg/bundle.go
+++ b/pkg/bundle.go
@@ -1,49 +1,14 @@
-/*
-	This file contains some modified versions of the
-	santhosh-tekuri/jsonschema loader code, licensed
-	under the Apache License, Version 2.0
-
-	Based on code from:
-	- https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/loader.go
-	- https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/cmd/jv/loader.go
-*/
-
 package pkg
 
 import (
-	"compress/gzip"
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
-	"maps"
-	"mime"
-	"net/http"
 	"net/url"
-	"os"
 	"path"
 	"path/filepath"
-	"regexp"
-	"runtime"
-	"slices"
 	"strings"
-	"time"
-
-	"gopkg.in/yaml.v3"
 )
 
-func NewDefaultLoader(client *http.Client, root *os.Root, basePath string) Loader {
-	fileLoader := NewFileLoader(root, basePath)
-	httpLoader := NewHTTPLoader(client)
-	return NewCacheLoader(URLSchemeLoader{
-		"http":  httpLoader,
-		"https": httpLoader,
-		"file":  fileLoader, // Used for "file:///some/abs/path"
-		"":      fileLoader, // Used for "./foobar.json" or "/some/abs/path"
-	})
-}
-
 // BundleSchema will use the [Loader] to load any "$ref" references and
 // store them in "$defs".
 //
@@ -325,26 +290,6 @@ func resolvePtr(schema *Schema, ptr Ptr) []*Schema {
 	}
 }
 
-func Load(ctx context.Context, loader Loader, ref string) (*Schema, error) {
-	if loader == nil {
-		return nil, fmt.Errorf("nil loader")
-	}
-	if ref == "" {
-		return nil, fmt.Errorf("cannot load empty $ref")
-	}
-	refURL, err := url.Parse(ref)
-	if err != nil {
-		return nil, fmt.Errorf("parse $ref as URL: %w", err)
-	}
-	schema, err := loader.Load(ctx, refURL)
-	if err != nil {
-		return nil, err
-	}
-
-	schema.ID = bundleRefURLToID(refURL)
-	return schema, nil
-}
-
 func bundleRefToID(ref string) string {
 	refURL, err := url.Parse(ref)
 	if err != nil {
@@ -363,11 +308,11 @@ func FixRootSchemaRef(rootSchemaRef, filePath string) string {
 	if rootSchemaRef == "" {
 		return ""
 	}
-	parsed, err := url.Parse(pathWindowsFix(rootSchemaRef))
+	parsed, err := url.Parse(rootSchemaRef)
 	if err != nil || parsed.Scheme != "" {
 		return rootSchemaRef
 	}
-	relPath, err := filepath.Rel(filepath.Dir(filePath), parsed.Path)
+	relPath, err := filepath.Rel(filepath.Dir(filePath), filepath.FromSlash(parsed.Path))
 	if err != nil {
 		err := fmt.Errorf("tried to fix root schema $ref path for bundling: get relative path from file %q to schema root ref %q: %w", filePath, rootSchemaRef, err)
 		fmt.Println("Warning:", err)
@@ -379,252 +324,3 @@ func FixRootSchemaRef(rootSchemaRef, filePath string) string {
 	}
 	return relPath
 }
-
-type Loader interface {
-	Load(ctx context.Context, ref *url.URL) (*Schema, error)
-}
-
-// DummyLoader is a dummy implementation of [Loader] meant to be
-// used in tests.
-type DummyLoader struct {
-	LoadFunc func(ctx context.Context, ref *url.URL) (*Schema, error)
-}
-
-var _ Loader = DummyLoader{}
-
-// Load implements [Loader].
-func (loader DummyLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
-	return loader.LoadFunc(ctx, ref)
-}
-
-// FileLoader loads a schema from a "$ref: file:/some/path" reference
-// from the local file-system.
-type FileLoader struct {
-	root     *os.Root
-	basePath string
-}
-
-func NewFileLoader(root *os.Root, basePath string) FileLoader {
-	return FileLoader{
-		root:     root,
-		basePath: basePath,
-	}
-}
-
-var _ Loader = FileLoader{}
-
-// Load implements [Loader].
-func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error) {
-	if ref.Scheme != "file" && ref.Scheme != "" {
-		return nil, fmt.Errorf(`file url in $ref=%q must start with "file://", "./", or "/"`, ref)
-	}
-	if ref.Path == "" {
-		return nil, fmt.Errorf(`file url in $ref=%q must contain a path`, ref)
-	}
-	path := pathWindowsFix(ref.Path)
-	if loader.basePath != "" && !filepath.IsAbs(path) {
-		path = filepath.Join(loader.basePath, path)
-	}
-
-	fmt.Println("Loading file", path)
-	f, err := loader.root.Open(path)
-	if err != nil {
-		return nil, fmt.Errorf("open $ref=%q file: %w", ref, err)
-	}
-	defer closeIgnoreError(f)
-	b, err := io.ReadAll(f)
-	if err != nil {
-		return nil, fmt.Errorf("read $ref=%q file: %w", ref, err)
-	}
-
-	fmt.Printf("=> got %s\n", formatSizeBytes(len(b)))
-
-	switch filepath.Ext(path) {
-	case ".yml", ".yaml":
-		var schema Schema
-		if err := yaml.Unmarshal(b, &schema); err != nil {
-			return nil, fmt.Errorf("parse $ref=%q YAML file: %w", ref, err)
-		}
-		return &schema, nil
-	default:
-		var schema Schema
-		if err := json.Unmarshal(b, &schema); err != nil {
-			return nil, fmt.Errorf("parse $ref=%q JSON file: %w", ref, err)
-		}
-		return &schema, nil
-	}
-}
-
-func pathWindowsFix(path string) string {
-	if runtime.GOOS == "windows" {
-		path = strings.TrimPrefix(path, "/")
-		path = filepath.FromSlash(path)
-	}
-	return path
-}
-
-// URLSchemeLoader delegates to other [Loader] implementations
-// based on the [url.URL] scheme.
-type URLSchemeLoader map[string]Loader
-
-var _ Loader = URLSchemeLoader{}
-
-// Load implements [Loader].
-func (loader URLSchemeLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
-	loaderForScheme, ok := loader[ref.Scheme]
-	if !ok {
-		return nil, fmt.Errorf("%w: cannot load schema from $ref=%q, supported schemes: %v",
-			errors.ErrUnsupported, ref, strings.Join(slices.Collect(maps.Keys(loader)), ","))
-	}
-	return loaderForScheme.Load(ctx, ref)
-}
-
-// CacheLoader stores loaded schemas in memory and reuses (or "memoizes", if you will)
-// calls to the underlying [Loader].
-type CacheLoader struct {
-	schemas   map[string]*Schema
-	subLoader Loader
-}
-
-func NewCacheLoader(loader Loader) *CacheLoader {
-	return &CacheLoader{
-		schemas:   map[string]*Schema{},
-		subLoader: loader,
-	}
-}
-
-var _ Loader = CacheLoader{}
-
-// Load implements [Loader].
-func (loader CacheLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
-	urlString := bundleRefURLToID(ref)
-	if schema := loader.schemas[urlString]; schema != nil {
-		return schema, nil
-	}
-	schema, err := loader.subLoader.Load(ctx, ref)
-	if err != nil {
-		return nil, err
-	}
-	loader.schemas[urlString] = schema
-	return schema, nil
-}
-
-type HTTPLoader struct {
-	client *http.Client
-}
-
-func NewHTTPLoader(client *http.Client) HTTPLoader {
-	return HTTPLoader{client: client}
-}
-
-var _ Loader = HTTPLoader{}
-
-var yamlMediaTypeRegexp = regexp.MustCompile(`^application/(.*\+)?yaml$`)
-
-// Load implements [Loader].
-func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
-	// Hardcoding a higher limit so CI/CD pipelines don't get stuck
-	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
-	defer cancel()
-
-	refClone := *ref
-	refClone.Fragment = ""
-
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, refClone.String(), nil)
-	if err != nil {
-		return nil, err
-	}
-	// YAML now has a proper media type since Feb 2024 :D
-	// https://datatracker.ietf.org/doc/rfc9512/
-	req.Header.Add("Accept", "application/schema+json,application/json,application/schema+yaml,application/yaml,text/plain; charset=utf-8")
-	req.Header.Add("Accept-Encoding", "gzip")
-	if referrer, ok := ctx.Value(loaderContextReferrer).(string); ok {
-		if strings.HasPrefix(referrer, "http://") || strings.HasPrefix(referrer, "https://") {
-			req.Header.Add("Link", fmt.Sprintf(`<%s>; rel="describedby"`, referrer))
-		}
-	}
-	if req.Header.Get("User-Agent") == "" {
-		req.Header.Set("User-Agent", "helm-values-schema-json/1")
-	}
-
-	start := time.Now()
-	fmt.Println("Loading", req.URL.Redacted())
-
-	resp, err := loader.client.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err)
-	}
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return nil, fmt.Errorf("request $ref=%q over HTTP: got non-2xx status code: %s", ref, resp.Status)
-	}
-	defer closeIgnoreError(resp.Body)
-
-	reader := resp.Body
-	switch resp.Header.Get("Content-Encoding") {
-	case "gzip":
-		r, err := gzip.NewReader(reader)
-		if err != nil {
-			return nil, fmt.Errorf("request $ref=%q over HTTP: create gzip reader: %w", ref, err)
-		}
-		reader = r
-	case "":
-		// Do nothing
-	default:
-		return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported content encoding: %q", ref, errors.ErrUnsupported, resp.Header.Get("Content-Encoding"))
-	}
-
-	var isYAML bool
-	if mediatype, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err == nil {
-		switch strings.ToLower(params["charset"]) {
-		case "", "utf-8", "utf8":
-			// OK
-		default:
-			return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported response charset: %q", ref, errors.ErrUnsupported, params["charset"])
-		}
-
-		if yamlMediaTypeRegexp.MatchString(mediatype) {
-			isYAML = true
-		}
-	}
-
-	b, err := io.ReadAll(reader)
-	if err != nil {
-		return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err)
-	}
-
-	duration := time.Since(start)
-	fmt.Printf("=> got %s in %s\n", formatSizeBytes(len(b)), duration.Truncate(time.Millisecond))
-
-	if isYAML {
-		var schema Schema
-		if err := yaml.Unmarshal(b, &schema); err != nil {
-			return nil, fmt.Errorf("parse $ref=%q YAML: %w", ref, err)
-		}
-		return &schema, nil
-	} else {
-		var schema Schema
-		if err := json.Unmarshal(b, &schema); err != nil {
-			return nil, fmt.Errorf("parse $ref=%q JSON: %w", ref, err)
-		}
-		return &schema, nil
-	}
-}
-
-type loaderContextKey int
-
-var loaderContextReferrer = loaderContextKey(1)
-
-func ContextWithLoaderReferrer(parent context.Context, referrer string) context.Context {
-	return context.WithValue(parent, loaderContextReferrer, referrer)
-}
-
-func formatSizeBytes(size int) string {
-	switch {
-	case size < 2_000:
-		return fmt.Sprintf("%dB", size)
-	case size < 2_000_000:
-		return fmt.Sprintf("%dKB", size/1_000)
-	default:
-		return fmt.Sprintf("%dMB", size/1_000_000)
-	}
-}
diff --git a/pkg/generator.go b/pkg/generator.go
index e9a3f1e..7c7efa1 100644
--- a/pkg/generator.go
+++ b/pkg/generator.go
@@ -114,7 +114,7 @@ func GenerateJsonSchema(config *Config) error {
 			if err != nil {
 				return fmt.Errorf("get relative path from bundle root to file %q: %w", filePath, err)
 			}
-			loader := NewDefaultLoader(http.DefaultClient, root, basePath)
+			loader := NewDefaultLoader(http.DefaultClient, (*RootFS)(root), basePath)
 			if err := BundleSchema(ctx, loader, tempSchema); err != nil {
 				return fmt.Errorf("bundle schemas on %q: %w", filePath, err)
 			}
diff --git a/pkg/loader.go b/pkg/loader.go
new file mode 100644
index 0000000..9b34f85
--- /dev/null
+++ b/pkg/loader.go
@@ -0,0 +1,333 @@
+/*
+	This file contains some modified versions of the
+	santhosh-tekuri/jsonschema loader code, licensed
+	under the Apache License, Version 2.0
+
+	Based on code from:
+	- https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/loader.go
+	- https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/cmd/jv/loader.go
+*/
+
+package pkg
+
+import (
+	"compress/gzip"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"maps"
+	"mime"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"strings"
+	"time"
+
+	"gopkg.in/yaml.v3"
+)
+
+// RootFS is a replacement for [os.Root.FS] that intentionally doesn't call
+// [fs.ValidPath] as that messes up the error messages and doesn't add any
+// security guarantees as all the security is implemented in [os.Root] already.
+type RootFS os.Root
+
+var _ fs.FS = &RootFS{}
+
+// Open implements [fs.FS].
+func (r *RootFS) Open(name string) (fs.File, error) {
+	return ((*os.Root)(r)).Open(name)
+}
+
+func NewDefaultLoader(client *http.Client, fs fs.FS, basePath string) Loader {
+	fileLoader := NewFileLoader(fs, basePath)
+	httpLoader := NewHTTPLoader(client)
+	return NewCacheLoader(URLSchemeLoader{
+		"http":  httpLoader,
+		"https": httpLoader,
+		"file":  fileLoader, // Used for "file:///some/abs/path"
+		"":      fileLoader, // Used for "./foobar.json" or "/some/abs/path"
+	})
+}
+
+// Load uses a bundle [Loader] to resolve a schema "$ref".
+// Depending on the loader implementation, it may read from cache,
+// read files from disk, or fetch files from the web using HTTP.
+func Load(ctx context.Context, loader Loader, ref string) (*Schema, error) {
+	if loader == nil {
+		return nil, fmt.Errorf("nil loader")
+	}
+	if ref == "" {
+		return nil, fmt.Errorf("cannot load empty $ref")
+	}
+	refURL, err := url.Parse(ref)
+	if err != nil {
+		return nil, fmt.Errorf("parse $ref as URL: %w", err)
+	}
+	schema, err := loader.Load(ctx, refURL)
+	if err != nil {
+		return nil, err
+	}
+
+	schema.ID = bundleRefURLToID(refURL)
+	return schema, nil
+}
+
+type Loader interface {
+	Load(ctx context.Context, ref *url.URL) (*Schema, error)
+}
+
+// DummyLoader is a dummy implementation of [Loader] meant to be
+// used in tests.
+type DummyLoader struct {
+	LoadFunc func(ctx context.Context, ref *url.URL) (*Schema, error)
+}
+
+var _ Loader = DummyLoader{}
+
+// Load implements [Loader].
+func (loader DummyLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	return loader.LoadFunc(ctx, ref)
+}
+
+// FileLoader loads a schema from a "$ref: file:/some/path" reference
+// from the local file-system.
+type FileLoader struct {
+	fs       fs.FS
+	basePath string
+}
+
+func NewFileLoader(fs fs.FS, basePath string) FileLoader {
+	return FileLoader{
+		fs:       fs,
+		basePath: basePath,
+	}
+}
+
+var _ Loader = FileLoader{}
+
+// Load implements [Loader].
+func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error) {
+	if ref.Scheme != "file" && ref.Scheme != "" {
+		return nil, fmt.Errorf(`file url in $ref=%q must start with "file://", "./", or "/"`, ref)
+	}
+	if ref.Path == "" {
+		return nil, fmt.Errorf(`file url in $ref=%q must contain a path`, ref)
+	}
+	path := filepath.FromSlash(ref.Path)
+	if loader.basePath != "" && !filepath.IsAbs(path) {
+		path = filepath.Join(loader.basePath, path)
+	}
+
+	fmt.Println("Loading file", path)
+	f, err := loader.fs.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("open $ref=%q file: %w", ref, err)
+	}
+	defer closeIgnoreError(f)
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil, fmt.Errorf("read $ref=%q file: %w", ref, err)
+	}
+
+	fmt.Printf("=> got %s\n", formatSizeBytes(len(b)))
+
+	switch filepath.Ext(path) {
+	case ".yml", ".yaml":
+		var schema Schema
+		if err := yaml.Unmarshal(b, &schema); err != nil {
+			return nil, fmt.Errorf("parse $ref=%q YAML file: %w", ref, err)
+		}
+		return &schema, nil
+	default:
+		var schema Schema
+		if err := json.Unmarshal(b, &schema); err != nil {
+			return nil, fmt.Errorf("parse $ref=%q JSON file: %w", ref, err)
+		}
+		return &schema, nil
+	}
+}
+
+// URLSchemeLoader delegates to other [Loader] implementations
+// based on the [url.URL] scheme.
+type URLSchemeLoader map[string]Loader
+
+var _ Loader = URLSchemeLoader{}
+
+// Load implements [Loader].
+func (loader URLSchemeLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	loaderForScheme, ok := loader[ref.Scheme]
+	if !ok {
+		return nil, fmt.Errorf("%w: cannot load schema from $ref=%q, supported schemes: %v",
+			errors.ErrUnsupported, ref, strings.Join(slices.Collect(maps.Keys(loader)), ","))
+	}
+	return loaderForScheme.Load(ctx, ref)
+}
+
+// CacheLoader stores loaded schemas in memory and reuses (or "memoizes", if you will)
+// calls to the underlying [Loader].
+type CacheLoader struct {
+	schemas   map[string]*Schema
+	subLoader Loader
+}
+
+func NewCacheLoader(loader Loader) *CacheLoader {
+	return &CacheLoader{
+		schemas:   map[string]*Schema{},
+		subLoader: loader,
+	}
+}
+
+var _ Loader = CacheLoader{}
+
+// Load implements [Loader].
+func (loader CacheLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	urlString := bundleRefURLToID(ref)
+	if schema := loader.schemas[urlString]; schema != nil {
+		return schema, nil
+	}
+	schema, err := loader.subLoader.Load(ctx, ref)
+	if err != nil {
+		return nil, err
+	}
+	loader.schemas[urlString] = schema
+	return schema, nil
+}
+
+type HTTPLoader struct {
+	client    *http.Client
+	SizeLimit int64
+}
+
+func NewHTTPLoader(client *http.Client) HTTPLoader {
+	return HTTPLoader{
+		client:    client,
+		SizeLimit: 200 * 1000 * 1000, // arbitrary limit, but prevents CLI from eating all RAM
+	}
+}
+
+var _ Loader = HTTPLoader{}
+
+var yamlMediaTypeRegexp = regexp.MustCompile(`^application/(.*\+)?yaml$`)
+
+// Flag is only used in testing to achieve better test coverage
+var failHTTPLoaderNewRequest bool
+
+// Load implements [Loader].
+func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	// Hardcoding a higher limit so CI/CD pipelines don't get stuck
+	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+
+	refClone := *ref
+	refClone.Fragment = ""
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, refClone.String(), nil)
+	if err != nil || failHTTPLoaderNewRequest {
+		// The [http.NewRequestWithContext] will never fail,
+		// so we have to induce a fake failure via [failHTTPLoaderNewRequest]
+		return nil, fmt.Errorf("create request: %w", err)
+	}
+	// YAML now has a proper media type since Feb 2024 :D
+	// https://datatracker.ietf.org/doc/rfc9512/
+	req.Header.Add("Accept", "application/schema+json,application/json,application/schema+yaml,application/yaml,text/plain; charset=utf-8")
+	req.Header.Add("Accept-Encoding", "gzip")
+	if referrer, ok := ctx.Value(loaderContextReferrer).(string); ok {
+		if strings.HasPrefix(referrer, "http://") || strings.HasPrefix(referrer, "https://") {
+			req.Header.Add("Link", fmt.Sprintf(`<%s>; rel="describedby"`, referrer))
+		}
+	}
+	if req.Header.Get("User-Agent") == "" {
+		req.Header.Set("User-Agent", "helm-values-schema-json/1")
+	}
+
+	start := time.Now()
+	fmt.Println("Loading", req.URL.Redacted())
+
+	resp, err := loader.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err)
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("request $ref=%q over HTTP: got non-2xx status code: %s", ref, resp.Status)
+	}
+	defer closeIgnoreError(resp.Body)
+
+	var reader io.Reader = resp.Body
+	if loader.SizeLimit > 0 {
+		reader = LimitReaderWithError(reader, loader.SizeLimit,
+			fmt.Errorf("aborted request after reading more than %s", formatSizeBytes(int(loader.SizeLimit))))
+	}
+	switch resp.Header.Get("Content-Encoding") {
+	case "gzip":
+		r, err := gzip.NewReader(reader)
+		if err != nil {
+			return nil, fmt.Errorf("request $ref=%q over HTTP: create gzip reader: %w", ref, err)
+		}
+		reader = r
+	case "":
+		// Do nothing
+	default:
+		return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported content encoding: %q", ref, errors.ErrUnsupported, resp.Header.Get("Content-Encoding"))
+	}
+
+	var isYAML bool
+	if mediatype, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err == nil {
+		switch strings.ToLower(params["charset"]) {
+		case "", "utf-8", "utf8":
+			// OK
+		default:
+			return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported response charset: %q", ref, errors.ErrUnsupported, params["charset"])
+		}
+
+		if yamlMediaTypeRegexp.MatchString(mediatype) {
+			isYAML = true
+		}
+	}
+
+	b, err := io.ReadAll(reader)
+	if err != nil {
+		return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err)
+	}
+
+	duration := time.Since(start)
+	fmt.Printf("=> got %s in %s\n", formatSizeBytes(len(b)), duration.Truncate(time.Millisecond))
+
+	if isYAML {
+		var schema Schema
+		if err := yaml.Unmarshal(b, &schema); err != nil {
+			return nil, fmt.Errorf("parse $ref=%q YAML: %w", ref, err)
+		}
+		return &schema, nil
+	} else {
+		var schema Schema
+		if err := json.Unmarshal(b, &schema); err != nil {
+			return nil, fmt.Errorf("parse $ref=%q JSON: %w", ref, err)
+		}
+		return &schema, nil
+	}
+}
+
+type loaderContextKey int
+
+var loaderContextReferrer = loaderContextKey(1)
+
+func ContextWithLoaderReferrer(parent context.Context, referrer string) context.Context {
+	return context.WithValue(parent, loaderContextReferrer, referrer)
+}
+
+func formatSizeBytes(size int) string {
+	switch {
+	case size < 2_000:
+		return fmt.Sprintf("%dB", size)
+	case size < 2_000_000:
+		return fmt.Sprintf("%dKB", size/1_000)
+	default:
+		return fmt.Sprintf("%dMB", size/1_000_000)
+	}
+}
diff --git a/pkg/utils.go b/pkg/utils.go
index d295201..5eda64c 100644
--- a/pkg/utils.go
+++ b/pkg/utils.go
@@ -23,3 +23,28 @@ func uniqueStringAppend(dest []string, src ...string) []string {
 func closeIgnoreError(closer io.Closer) {
 	_ = closer.Close()
 }
+
+// LimitReaderWithError returns a wrapper around [io.LimitedReader] that
+// returns a custom error when the limit is reached instead of [io.EOF].
+func LimitReaderWithError(r io.Reader, n int64, err error) LimitedReaderWithError {
+	return LimitedReaderWithError{
+		Reader: &io.LimitedReader{R: r, N: n},
+		Err:    err,
+	}
+}
+
+// LimitedReaderWithError is a wrapper around [io.LimitedReader] that
+// returns a custom error when the limit is reached instead of [io.EOF].
+type LimitedReaderWithError struct {
+	Reader *io.LimitedReader
+	Err    error
+}
+
+// Read implements [io.Reader].
+func (r LimitedReaderWithError) Read(b []byte) (int, error) {
+	n, err := r.Reader.Read(b)
+	if err == io.EOF && r.Reader.N <= 0 {
+		return n, r.Err
+	}
+	return n, err
+}


Test patch:
diff --git a/pkg/bundle_test.go b/pkg/bundle_test.go
index a139214..f97072c 100644
--- a/pkg/bundle_test.go
+++ b/pkg/bundle_test.go
@@ -1,13 +1,9 @@
 package pkg
 
 import (
-	"compress/gzip"
 	"context"
 	"fmt"
-	"net/http"
-	"net/http/httptest"
 	"net/url"
-	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -918,413 +914,3 @@ func TestFixRootSchemaRef(t *testing.T) {
 		})
 	}
 }
-
-func TestLoad_Errors(t *testing.T) {
-	t.Parallel()
-	tests := []struct {
-		name    string
-		loader  Loader
-		ref     string
-		wantErr string
-	}{
-		{
-			name:    "nil loader",
-			loader:  nil,
-			wantErr: "nil loader",
-		},
-		{
-			name:    "empty ref",
-			loader:  DummyLoader{},
-			wantErr: "cannot load empty $ref",
-		},
-		{
-			name:    "invalid URL",
-			loader:  DummyLoader{},
-			ref:     "::",
-			wantErr: `parse $ref as URL: parse "::": missing protocol scheme`,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			_, err := Load(t.Context(), tt.loader, tt.ref)
-			assert.EqualError(t, err, tt.wantErr)
-		})
-	}
-}
-
-func TestFileLoader_Error(t *testing.T) {
-	tests := []struct {
-		name    string
-		url     *url.URL
-		wantErr string
-	}{
-		{
-			name:    "invalid scheme",
-			url:     mustParseURL("ftp:///foo"),
-			wantErr: `file url in $ref="ftp:///foo" must start with "file://", "./", or "/"`,
-		},
-		{
-			name:    "invalid path",
-			url:     mustParseURL("file://localhost"),
-			wantErr: `file url in $ref="file://localhost" must contain a path`,
-		},
-		{
-			name:    "path escapes parent",
-			url:     mustParseURL("file:///file/that/does/not/exist"),
-			wantErr: "path escapes from parent",
-		},
-		{
-			name:    "path not found",
-			url:     mustParseURL("./local/file/that/does/not/exist"),
-			wantErr: "no such file or directory",
-		},
-		{
-			name:    "invalid JSON",
-			url:     mustParseURL("./invalid-schema.json"),
-			wantErr: `parse $ref="./invalid-schema.json" JSON file: invalid character 'h' in literal true`,
-		},
-		{
-			name:    "invalid YAML",
-			url:     mustParseURL("./invalid-schema.yaml"),
-			wantErr: `parse $ref="./invalid-schema.yaml" YAML file: yaml: did not find expected key`,
-		},
-	}
-
-	root, err := os.OpenRoot("../testdata/bundle")
-	require.NoError(t, err)
-	defer func() {
-		require.NoError(t, root.Close())
-	}()
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			loader := NewFileLoader(root, "")
-			_, err := loader.Load(t.Context(), tt.url)
-			assert.ErrorContains(t, err, tt.wantErr)
-		})
-	}
-}
-
-func TestURLSchemeLoader_Error(t *testing.T) {
-	tests := []struct {
-		name    string
-		url     *url.URL
-		loader  URLSchemeLoader
-		wantErr string
-	}{
-		{
-			name: "invalid scheme",
-			url:  mustParseURL("bar:///foo"),
-			loader: URLSchemeLoader{
-				"foo": DummyLoader{},
-			},
-			wantErr: `unsupported operation: cannot load schema from $ref="bar:///foo", supported schemes: foo`,
-		},
-		{
-			name: "loader returns error",
-			url:  mustParseURL("foo://"),
-			loader: URLSchemeLoader{
-				"foo": DummyLoader{
-					LoadFunc: func(ctx context.Context, ref *url.URL) (*Schema, error) {
-						return nil, fmt.Errorf("test error")
-					},
-				},
-			},
-			wantErr: `test error`,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			_, err := tt.loader.Load(t.Context(), tt.url)
-			assert.ErrorContains(t, err, tt.wantErr)
-		})
-	}
-}
-
-func TestCacheLoader(t *testing.T) {
-	counter := 0
-	loader := NewCacheLoader(DummyLoader{
-		LoadFunc: func(ctx context.Context, ref *url.URL) (*Schema, error) {
-			counter++
-			return &Schema{
-				Enum: []any{counter},
-			}, nil
-		},
-	})
-
-	schema1, err := loader.Load(t.Context(), mustParseURL("foo://"))
-	require.NoError(t, err)
-	schema2, err := loader.Load(t.Context(), mustParseURL("foo://"))
-	require.NoError(t, err)
-	schema3, err := loader.Load(t.Context(), mustParseURL("foo://"))
-	require.NoError(t, err)
-
-	assert.Same(t, schema1, schema2)
-	assert.Same(t, schema2, schema3)
-	assert.Same(t, schema3, schema1)
-	assert.Equal(t, 1, schema1.Enum[0], "schema1")
-	assert.Equal(t, 1, schema2.Enum[0], "schema2")
-	assert.Equal(t, 1, schema3.Enum[0], "schema3")
-}
-
-func TestHTTPLoader(t *testing.T) {
-	t.Parallel()
-	tests := []struct {
-		name         string
-		response     string
-		responseType string
-		want         *Schema
-	}{
-		{
-			name:     "empty object",
-			response: `{"$comment": "hello"}`,
-			want:     &Schema{Comment: "hello"},
-		},
-		{
-			name:         "JSON content",
-			response:     `{"$comment": "hello"}`,
-			responseType: "application/json",
-			want:         &Schema{Comment: "hello"},
-		},
-		{
-			name:         "JSON with YAML content type",
-			response:     `{"$comment": "hello"}`,
-			responseType: "application/yaml",
-			want:         &Schema{Comment: "hello"},
-		},
-		{
-			name:         "YAML content",
-			response:     `$comment: hello`,
-			responseType: "application/yaml",
-			want:         &Schema{Comment: "hello"},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name+"/simple", func(t *testing.T) {
-			t.Parallel()
-			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-				if tt.responseType != "" {
-					w.Header().Add("Content-Type", tt.responseType)
-				}
-				w.WriteHeader(http.StatusOK)
-				_, err := w.Write([]byte(tt.response))
-				require.NoError(t, err)
-			}))
-			defer server.Close()
-
-			ctx := t.Context()
-
-			loader := NewHTTPLoader(server.Client())
-			schema, err := loader.Load(ctx, mustParseURL(server.URL))
-			require.NoError(t, err)
-			assert.Equal(t, tt.want, schema, "Schema")
-		})
-
-		t.Run(tt.name+"/gzip", func(t *testing.T) {
-			t.Parallel()
-			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-				if tt.responseType != "" {
-					w.Header().Add("Content-Type", tt.responseType)
-				}
-				w.Header().Add("Content-Encoding", "gzip")
-				w.WriteHeader(http.StatusOK)
-				gzipper := gzip.NewWriter(w)
-				defer func() {
-					require.NoError(t, gzipper.Close())
-				}()
-				_, err := gzipper.Write([]byte(tt.response))
-				require.NoError(t, err)
-			}))
-			defer server.Close()
-
-			ctx := t.Context()
-
-			loader := NewHTTPLoader(server.Client())
-			schema, err := loader.Load(ctx, mustParseURL(server.URL))
-			require.NoError(t, err)
-			assert.Equal(t, tt.want, schema, "Schema")
-		})
-
-		t.Run(tt.name+"/link", func(t *testing.T) {
-			t.Parallel()
-			var linkHeader string
-			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-				linkHeader = req.Header.Get("Link")
-				if tt.responseType != "" {
-					w.Header().Add("Content-Type", tt.responseType)
-				}
-				w.WriteHeader(http.StatusOK)
-				_, err := w.Write([]byte(tt.response))
-				require.NoError(t, err)
-			}))
-			defer server.Close()
-
-			ctx := t.Context()
-			ctx = ContextWithLoaderReferrer(ctx, "http://some-referrerer")
-
-			loader := NewHTTPLoader(server.Client())
-			_, err := loader.Load(ctx, mustParseURL(server.URL))
-			require.NoError(t, err)
-			assert.Equal(t, `<http://some-referrerer>; rel="describedby"`, linkHeader, "Link header")
-		})
-	}
-}
-
-func TestHTTPLoader_Error(t *testing.T) {
-	t.Parallel()
-	tests := []struct {
-		name         string
-		response     string
-		responseType string
-		responseCode int
-		wantErr      string
-	}{
-		{
-			name:         "invalid undefined content",
-			response:     `foobar`,
-			responseType: "",
-			responseCode: http.StatusOK,
-			wantErr:      "JSON: invalid character 'o'",
-		},
-		{
-			name:         "invalid JSON content",
-			response:     `foobar`,
-			responseType: "application/json",
-			responseCode: http.StatusOK,
-			wantErr:      "JSON: invalid character 'o'",
-		},
-		{
-			name: "valid JSON but invalid YAML content",
-			// YAML doesn't allow tabs
-			response:     "\t{\"$comment\":\"hello\"}",
-			responseType: "application/yaml",
-			responseCode: http.StatusOK,
-			wantErr:      "YAML: yaml: found character that cannot start any token",
-		},
-		{
-			name:         "invalid YAML content",
-			response:     `: foo:`,
-			responseType: "application/yaml",
-			responseCode: http.StatusOK,
-			wantErr:      "YAML: yaml: did not find expected key",
-		},
-		{
-			name:         "invalid response code",
-			response:     `{}`,
-			responseCode: http.StatusGone,
-			wantErr:      "got non-2xx status code: 410 Gone",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-				if tt.responseType != "" {
-					w.Header().Add("Content-Type", tt.responseType)
-				}
-				w.WriteHeader(tt.responseCode)
-				_, err := w.Write([]byte(tt.response))
-				require.NoError(t, err)
-			}))
-			defer server.Close()
-
-			ctx := t.Context()
-
-			loader := NewHTTPLoader(server.Client())
-			_, err := loader.Load(ctx, mustParseURL(server.URL))
-			assert.ErrorContains(t, err, tt.wantErr)
-		})
-	}
-
-	t.Run("invalid encoding", func(t *testing.T) {
-		t.Parallel()
-		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-			w.Header().Add("Content-Encoding", "foobar")
-			w.WriteHeader(http.StatusOK)
-			_, err := w.Write([]byte("{}"))
-			require.NoError(t, err)
-		}))
-		defer server.Close()
-
-		ctx := t.Context()
-
-		loader := NewHTTPLoader(server.Client())
-		_, err := loader.Load(ctx, mustParseURL(server.URL))
-		assert.ErrorContains(t, err, `unsupported content encoding: "foobar"`)
-	})
-
-	t.Run("shutdown server", func(t *testing.T) {
-		t.Parallel()
-		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-			w.Header().Add("Content-Encoding", "foobar")
-			w.WriteHeader(http.StatusOK)
-			_, err := w.Write([]byte("{}"))
-			require.NoError(t, err)
-		}))
-		server.Close() // close now already
-
-		ctx := t.Context()
-
-		loader := NewHTTPLoader(server.Client())
-		_, err := loader.Load(ctx, mustParseURL(server.URL))
-		assert.ErrorContains(t, err, `connect: connection refused`)
-	})
-
-	t.Run("invalid gzip", func(t *testing.T) {
-		t.Parallel()
-		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-			w.Header().Add("Content-Encoding", "gzip")
-			w.WriteHeader(http.StatusOK)
-			_, err := w.Write([]byte("{}"))
-			require.NoError(t, err)
-		}))
-		defer server.Close()
-
-		ctx := t.Context()
-
-		loader := NewHTTPLoader(server.Client())
-		_, err := loader.Load(ctx, mustParseURL(server.URL))
-		assert.ErrorContains(t, err, `create gzip reader: unexpected EOF`)
-	})
-}
-
-func TestFormatSizeBytes(t *testing.T) {
-	tests := []struct {
-		name string
-		size int
-		want string
-	}{
-		{size: 0, want: "0B"},
-		{size: 1000, want: "1000B"},
-		{size: 1999, want: "1999B"},
-		{size: 2000, want: "2KB"},
-		{size: 10_000, want: "10KB"},
-		{size: 1_000_000, want: "1000KB"},
-		{size: 1_999_999, want: "1999KB"},
-		{size: 2_000_000, want: "2MB"},
-		{size: 10_000_000, want: "10MB"},
-		{size: 3_000_000_000_000, want: "3000000MB"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.want, func(t *testing.T) {
-			got := formatSizeBytes(tt.size)
-			if got != tt.want {
-				t.Errorf("wrong result\nwant: %q\ngot:  %q", tt.want, got)
-			}
-		})
-	}
-}
-
-func mustParseURL(rawURL string) *url.URL {
-	u, err := url.Parse(rawURL)
-	if err != nil {
-		panic(err)
-	}
-	return u
-}
diff --git a/pkg/loader_test.go b/pkg/loader_test.go
new file mode 100644
index 0000000..f44c0a4
--- /dev/null
+++ b/pkg/loader_test.go
@@ -0,0 +1,516 @@
+package pkg
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestLoad_Errors(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name    string
+		loader  Loader
+		ref     string
+		wantErr string
+	}{
+		{
+			name:    "nil loader",
+			loader:  nil,
+			wantErr: "nil loader",
+		},
+		{
+			name:    "empty ref",
+			loader:  DummyLoader{},
+			wantErr: "cannot load empty $ref",
+		},
+		{
+			name:    "invalid URL",
+			loader:  DummyLoader{},
+			ref:     "::",
+			wantErr: `parse $ref as URL: parse "::": missing protocol scheme`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			_, err := Load(t.Context(), tt.loader, tt.ref)
+			assert.EqualError(t, err, tt.wantErr)
+		})
+	}
+}
+
+func TestFileLoader_Error(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     *url.URL
+		wantErr string
+	}{
+		{
+			name:    "invalid scheme",
+			url:     mustParseURL("ftp:///foo"),
+			wantErr: `file url in $ref="ftp:///foo" must start with "file://", "./", or "/"`,
+		},
+		{
+			name:    "invalid path",
+			url:     mustParseURL("file://localhost"),
+			wantErr: `file url in $ref="file://localhost" must contain a path`,
+		},
+		{
+			name:    "path escapes parent",
+			url:     mustParseURL("file:///file/that/does/not/exist"),
+			wantErr: "path escapes from parent",
+		},
+		{
+			name:    "path not found",
+			url:     mustParseURL("./local/file/that/does/not/exist"),
+			wantErr: "no such file or directory",
+		},
+		{
+			name:    "invalid JSON",
+			url:     mustParseURL("./invalid-schema.json"),
+			wantErr: `parse $ref="./invalid-schema.json" JSON file: invalid character 'h' in literal true`,
+		},
+		{
+			name:    "invalid YAML",
+			url:     mustParseURL("./invalid-schema.yaml"),
+			wantErr: `parse $ref="./invalid-schema.yaml" YAML file: yaml: did not find expected key`,
+		},
+	}
+
+	root, err := os.OpenRoot("../testdata/bundle")
+	require.NoError(t, err)
+	defer func() {
+		require.NoError(t, root.Close())
+	}()
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			loader := NewFileLoader((*RootFS)(root), "")
+			_, err := loader.Load(t.Context(), tt.url)
+			assert.ErrorContains(t, err, tt.wantErr)
+		})
+	}
+}
+
+type DummyFS struct {
+	OpenFunc func(name string) (fs.File, error)
+}
+
+var _ fs.FS = DummyFS{}
+
+// Open implements [fs.FS].
+func (d DummyFS) Open(name string) (fs.File, error) {
+	return d.OpenFunc(name)
+}
+
+type DummyFile struct {
+	StatFunc  func() (fs.FileInfo, error)
+	ReadFunc  func([]byte) (int, error)
+	CloseFunc func() error
+}
+
+var _ fs.File = DummyFile{}
+
+// Close implements [fs.File].
+func (d DummyFile) Close() error {
+	return d.CloseFunc()
+}
+
+// Read implements [fs.File].
+func (d DummyFile) Read(b []byte) (int, error) {
+	return d.ReadFunc(b)
+}
+
+// Stat implements [fs.File].
+func (d DummyFile) Stat() (fs.FileInfo, error) {
+	return d.StatFunc()
+}
+
+func TestFileLoader_TestFSError(t *testing.T) {
+	root := DummyFS{
+		OpenFunc: func(name string) (fs.File, error) {
+			return DummyFile{
+				CloseFunc: func() error { return nil },
+				ReadFunc: func([]byte) (int, error) {
+					return 0, fmt.Errorf("dummy error")
+				},
+			}, nil
+		},
+	}
+
+	loader := NewFileLoader(root, "")
+	_, err := loader.Load(t.Context(), mustParseURL("./some-fake-file.txt"))
+	assert.ErrorContains(t, err, "read $ref=\"./some-fake-file.txt\" file: dummy error")
+}
+
+func TestURLSchemeLoader_Error(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     *url.URL
+		loader  URLSchemeLoader
+		wantErr string
+	}{
+		{
+			name: "invalid scheme",
+			url:  mustParseURL("bar:///foo"),
+			loader: URLSchemeLoader{
+				"foo": DummyLoader{},
+			},
+			wantErr: `unsupported operation: cannot load schema from $ref="bar:///foo", supported schemes: foo`,
+		},
+		{
+			name: "loader returns error",
+			url:  mustParseURL("foo://"),
+			loader: URLSchemeLoader{
+				"foo": DummyLoader{
+					LoadFunc: func(ctx context.Context, ref *url.URL) (*Schema, error) {
+						return nil, fmt.Errorf("test error")
+					},
+				},
+			},
+			wantErr: `test error`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := tt.loader.Load(t.Context(), tt.url)
+			assert.ErrorContains(t, err, tt.wantErr)
+		})
+	}
+}
+
+func TestCacheLoader(t *testing.T) {
+	counter := 0
+	loader := NewCacheLoader(DummyLoader{
+		LoadFunc: func(ctx context.Context, ref *url.URL) (*Schema, error) {
+			counter++
+			return &Schema{
+				Enum: []any{counter},
+			}, nil
+		},
+	})
+
+	schema1, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	require.NoError(t, err)
+	schema2, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	require.NoError(t, err)
+	schema3, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	require.NoError(t, err)
+
+	assert.Same(t, schema1, schema2)
+	assert.Same(t, schema2, schema3)
+	assert.Same(t, schema3, schema1)
+	assert.Equal(t, 1, schema1.Enum[0], "schema1")
+	assert.Equal(t, 1, schema2.Enum[0], "schema2")
+	assert.Equal(t, 1, schema3.Enum[0], "schema3")
+}
+
+func TestHTTPLoader(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name         string
+		response     string
+		responseType string
+		want         *Schema
+	}{
+		{
+			name:     "empty object",
+			response: `{"$comment": "hello"}`,
+			want:     &Schema{Comment: "hello"},
+		},
+		{
+			name:         "JSON content",
+			response:     `{"$comment": "hello"}`,
+			responseType: "application/json",
+			want:         &Schema{Comment: "hello"},
+		},
+		{
+			name:         "charset",
+			response:     `{"$comment": "hello"}`,
+			responseType: "application/json; charset=utf8",
+			want:         &Schema{Comment: "hello"},
+		},
+		{
+			name:         "JSON with YAML content type",
+			response:     `{"$comment": "hello"}`,
+			responseType: "application/yaml",
+			want:         &Schema{Comment: "hello"},
+		},
+		{
+			name:         "YAML content",
+			response:     `$comment: hello`,
+			responseType: "application/yaml",
+			want:         &Schema{Comment: "hello"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name+"/simple", func(t *testing.T) {
+			t.Parallel()
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				if tt.responseType != "" {
+					w.Header().Add("Content-Type", tt.responseType)
+				}
+				w.WriteHeader(http.StatusOK)
+				_, err := w.Write([]byte(tt.response))
+				require.NoError(t, err)
+			}))
+			defer server.Close()
+
+			ctx := t.Context()
+
+			loader := NewHTTPLoader(server.Client())
+			schema, err := loader.Load(ctx, mustParseURL(server.URL))
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, schema, "Schema")
+		})
+
+		t.Run(tt.name+"/gzip", func(t *testing.T) {
+			t.Parallel()
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				if tt.responseType != "" {
+					w.Header().Add("Content-Type", tt.responseType)
+				}
+				w.Header().Add("Content-Encoding", "gzip")
+				w.WriteHeader(http.StatusOK)
+				gzipper := gzip.NewWriter(w)
+				defer func() {
+					require.NoError(t, gzipper.Close())
+				}()
+				_, err := gzipper.Write([]byte(tt.response))
+				require.NoError(t, err)
+			}))
+			defer server.Close()
+
+			ctx := t.Context()
+
+			loader := NewHTTPLoader(server.Client())
+			schema, err := loader.Load(ctx, mustParseURL(server.URL))
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, schema, "Schema")
+		})
+
+		t.Run(tt.name+"/link", func(t *testing.T) {
+			t.Parallel()
+			var linkHeader string
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				linkHeader = req.Header.Get("Link")
+				if tt.responseType != "" {
+					w.Header().Add("Content-Type", tt.responseType)
+				}
+				w.WriteHeader(http.StatusOK)
+				_, err := w.Write([]byte(tt.response))
+				require.NoError(t, err)
+			}))
+			defer server.Close()
+
+			ctx := t.Context()
+			ctx = ContextWithLoaderReferrer(ctx, "http://some-referrerer")
+
+			loader := NewHTTPLoader(server.Client())
+			_, err := loader.Load(ctx, mustParseURL(server.URL))
+			require.NoError(t, err)
+			assert.Equal(t, `<http://some-referrerer>; rel="describedby"`, linkHeader, "Link header")
+		})
+	}
+}
+
+func TestHTTPLoader_Error(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name         string
+		response     string
+		responseType string
+		responseCode int
+		wantErr      string
+	}{
+		{
+			name:         "invalid undefined content",
+			response:     `foobar`,
+			responseType: "",
+			responseCode: http.StatusOK,
+			wantErr:      "JSON: invalid character 'o'",
+		},
+		{
+			name:         "invalid JSON content",
+			response:     `foobar`,
+			responseType: "application/json",
+			responseCode: http.StatusOK,
+			wantErr:      "JSON: invalid character 'o'",
+		},
+		{
+			name: "valid JSON but invalid YAML content",
+			// YAML doesn't allow tabs
+			response:     "\t{\"$comment\":\"hello\"}",
+			responseType: "application/yaml",
+			responseCode: http.StatusOK,
+			wantErr:      "YAML: yaml: found character that cannot start any token",
+		},
+		{
+			name:         "invalid YAML content",
+			response:     `: foo:`,
+			responseType: "application/yaml",
+			responseCode: http.StatusOK,
+			wantErr:      "YAML: yaml: did not find expected key",
+		},
+		{
+			name:         "invalid charset",
+			response:     "{\"$comment\":\"hello\"}",
+			responseType: "application/json; charset=iso-whatever-123456",
+			responseCode: http.StatusOK,
+			wantErr:      "unsupported response charset",
+		},
+		{
+			name:         "invalid response code",
+			response:     `{}`,
+			responseCode: http.StatusGone,
+			wantErr:      "got non-2xx status code: 410 Gone",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				if tt.responseType != "" {
+					w.Header().Add("Content-Type", tt.responseType)
+				}
+				w.WriteHeader(tt.responseCode)
+				_, err := w.Write([]byte(tt.response))
+				require.NoError(t, err)
+			}))
+			defer server.Close()
+
+			ctx := t.Context()
+
+			loader := NewHTTPLoader(server.Client())
+			_, err := loader.Load(ctx, mustParseURL(server.URL))
+			assert.ErrorContains(t, err, tt.wantErr)
+		})
+	}
+
+	t.Run("invalid encoding", func(t *testing.T) {
+		t.Parallel()
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Header().Add("Content-Encoding", "foobar")
+			w.WriteHeader(http.StatusOK)
+			_, err := w.Write([]byte("{}"))
+			require.NoError(t, err)
+		}))
+		defer server.Close()
+
+		ctx := t.Context()
+
+		loader := NewHTTPLoader(server.Client())
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
+		assert.ErrorContains(t, err, `unsupported content encoding: "foobar"`)
+	})
+
+	t.Run("size limit", func(t *testing.T) {
+		t.Parallel()
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.WriteHeader(http.StatusOK)
+			_, err := w.Write(bytes.Repeat([]byte(" "), 1000))
+			require.NoError(t, err)
+		}))
+		defer server.Close()
+
+		ctx := t.Context()
+
+		loader := NewHTTPLoader(server.Client())
+		loader.SizeLimit = 20
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
+		assert.ErrorContains(t, err, `aborted request after reading more than 20B`)
+	})
+
+	t.Run("shutdown server", func(t *testing.T) {
+		t.Parallel()
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Header().Add("Content-Encoding", "foobar")
+			w.WriteHeader(http.StatusOK)
+			_, err := w.Write([]byte("{}"))
+			require.NoError(t, err)
+		}))
+		server.Close() // close now already
+
+		ctx := t.Context()
+
+		loader := NewHTTPLoader(server.Client())
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
+		assert.ErrorContains(t, err, `connect: connection refused`)
+	})
+
+	t.Run("invalid gzip", func(t *testing.T) {
+		t.Parallel()
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Header().Add("Content-Encoding", "gzip")
+			w.WriteHeader(http.StatusOK)
+			_, err := w.Write([]byte("{}"))
+			require.NoError(t, err)
+		}))
+		defer server.Close()
+
+		ctx := t.Context()
+
+		loader := NewHTTPLoader(server.Client())
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
+		assert.ErrorContains(t, err, `create gzip reader: unexpected EOF`)
+	})
+}
+
+func TestHTTPLoader_NewRequestError(t *testing.T) {
+	failHTTPLoaderNewRequest = true
+	defer func() { failHTTPLoaderNewRequest = false }()
+	loader := NewHTTPLoader(http.DefaultClient)
+	_, err := loader.Load(t.Context(), mustParseURL("file://localhost"))
+	assert.ErrorContains(t, err, `create request: `)
+}
+
+func TestFormatSizeBytes(t *testing.T) {
+	tests := []struct {
+		name string
+		size int
+		want string
+	}{
+		{size: 0, want: "0B"},
+		{size: 1000, want: "1000B"},
+		{size: 1999, want: "1999B"},
+		{size: 2000, want: "2KB"},
+		{size: 10_000, want: "10KB"},
+		{size: 1_000_000, want: "1000KB"},
+		{size: 1_999_999, want: "1999KB"},
+		{size: 2_000_000, want: "2MB"},
+		{size: 10_000_000, want: "10MB"},
+		{size: 3_000_000_000_000, want: "3000000MB"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.want, func(t *testing.T) {
+			got := formatSizeBytes(tt.size)
+			if got != tt.want {
+				t.Errorf("wrong result\nwant: %q\ngot:  %q", tt.want, got)
+			}
+		})
+	}
+}
+
+func mustParseURL(rawURL string) *url.URL {
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		panic(err)
+	}
+	return u
+}