| // Copyright The Monogon Project Authors. |
| // SPDX-License-Identifier: Apache-2.0 |
| |
| // Package osimage allows reading OS images represented as OCI artifacts, and |
| // contains the types for the OS image config. |
| package osimage |
| |
| import ( |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "io" |
| |
| "github.com/klauspost/compress/zstd" |
| |
| "source.monogon.dev/osbase/oci" |
| "source.monogon.dev/osbase/structfs" |
| ) |
| |
| // Image represents an OS image. |
| type Image struct { |
| // Config contains the parsed config. |
| Config *Config |
| // RawConfig contains the bytes of the config. |
| RawConfig []byte |
| |
| image *oci.Image |
| } |
| |
| // Read reads the config from an OCI image and returns an [Image]. |
| func Read(image *oci.Image) (*Image, error) { |
| manifest := image.Manifest |
| if manifest.ArtifactType != ArtifactTypeOSImage { |
| return nil, fmt.Errorf("unexpected manifest artifact type %q", manifest.ArtifactType) |
| } |
| if manifest.Config.MediaType != MediaTypeOSImageConfig { |
| return nil, fmt.Errorf("unexpected config media type %q", manifest.Config.MediaType) |
| } |
| |
| // Read the config. |
| rawConfig, err := image.ReadBlobVerified(&manifest.Config) |
| if err != nil { |
| return nil, fmt.Errorf("failed to read config: %w", err) |
| } |
| config := &Config{} |
| err = json.Unmarshal(rawConfig, config) |
| if err != nil { |
| return nil, fmt.Errorf("failed to parse config: %w", err) |
| } |
| if config.FormatVersion != ConfigVersion { |
| return nil, fmt.Errorf("unsupported config version %q", config.FormatVersion) |
| } |
| if len(config.Payloads) != len(manifest.Layers) { |
| return nil, fmt.Errorf("number of layers %d does not match number of payloads %d", len(manifest.Layers), len(config.Payloads)) |
| } |
| for i := range config.Payloads { |
| payload := &config.Payloads[i] |
| if payload.Size < 0 { |
| return nil, fmt.Errorf("payload %q has negative size", payload.Name) |
| } |
| if payload.HashChunkSize <= 0 { |
| return nil, fmt.Errorf("payload %q has invalid chunk size %d", payload.Name, payload.HashChunkSize) |
| } |
| if payload.HashChunkSize > 16*1024*1024 { |
| return nil, fmt.Errorf("payload %q has too large chunk size %d", payload.Name, payload.HashChunkSize) |
| } |
| chunks := payload.Size / payload.HashChunkSize |
| if chunks*payload.HashChunkSize < payload.Size { |
| chunks++ |
| } |
| if int64(len(payload.ChunkHashesSHA256)) != chunks { |
| return nil, fmt.Errorf("payload %q has %d chunks but %d chunk hashes", payload.Name, chunks, len(payload.ChunkHashesSHA256)) |
| } |
| } |
| |
| osImage := &Image{ |
| Config: config, |
| RawConfig: rawConfig, |
| image: image, |
| } |
| return osImage, nil |
| } |
| |
| // Payload returns the contents of the payload of the given name. |
| // All data is verified against hashes in the config before it is returned. |
| func (i *Image) Payload(name string) (structfs.Blob, error) { |
| for pi := range i.Config.Payloads { |
| info := &i.Config.Payloads[pi] |
| if info.Name == name { |
| layer := &i.image.Manifest.Layers[pi] |
| blob := &payloadBlob{ |
| raw: i.image.StructfsBlob(layer), |
| mediaType: layer.MediaType, |
| info: info, |
| } |
| return blob, nil |
| } |
| } |
| return nil, fmt.Errorf("payload %q not found", name) |
| } |
| |
| // PayloadUnverified returns the contents of the payload of the given name. |
| // Data is not verified against hashes. This only works for uncompressed images. |
| func (i *Image) PayloadUnverified(name string) (structfs.Blob, error) { |
| for pi, info := range i.Config.Payloads { |
| if info.Name == name { |
| layer := &i.image.Manifest.Layers[pi] |
| if layer.MediaType != MediaTypePayloadUncompressed { |
| return nil, fmt.Errorf("unsupported media type %q for unverified payload", layer.MediaType) |
| } |
| return i.image.StructfsBlob(layer), nil |
| } |
| } |
| return nil, fmt.Errorf("payload %q not found", name) |
| } |
| |
| type payloadBlob struct { |
| raw structfs.Blob |
| mediaType string |
| info *PayloadInfo |
| } |
| |
| func (b *payloadBlob) Open() (io.ReadCloser, error) { |
| blobReader, err := b.raw.Open() |
| if err != nil { |
| return nil, err |
| } |
| reader := &payloadReader{ |
| chunkHashes: b.info.ChunkHashesSHA256, |
| blobReader: blobReader, |
| remaining: b.info.Size, |
| buf: make([]byte, b.info.HashChunkSize), |
| } |
| switch b.mediaType { |
| case MediaTypePayloadUncompressed: |
| reader.uncompressed = blobReader |
| case MediaTypePayloadZstd: |
| reader.zstdDecoder, err = zstd.NewReader(blobReader) |
| if err != nil { |
| blobReader.Close() |
| return nil, fmt.Errorf("failed to create zstd decoder: %w", err) |
| } |
| reader.uncompressed = reader.zstdDecoder |
| default: |
| blobReader.Close() |
| return nil, fmt.Errorf("unsupported media type %q", b.mediaType) |
| } |
| return reader, nil |
| } |
| |
| func (b *payloadBlob) Size() int64 { |
| return b.info.Size |
| } |
| |
| type payloadReader struct { |
| chunkHashes []string |
| blobReader io.ReadCloser |
| zstdDecoder *zstd.Decoder |
| uncompressed io.Reader |
| remaining int64 // number of bytes remaining in uncompressed |
| buf []byte // buffer of chunk size |
| available []byte // bytes available for reading in the last read chunk |
| } |
| |
| func (r *payloadReader) Read(p []byte) (n int, err error) { |
| if len(r.available) != 0 { |
| n = copy(p, r.available) |
| r.available = r.available[n:] |
| return |
| } |
| if r.remaining == 0 { |
| err = io.EOF |
| return |
| } |
| chunkLen := min(r.remaining, int64(len(r.buf))) |
| chunk := r.buf[:chunkLen] |
| _, err = io.ReadFull(r.uncompressed, chunk) |
| if err != nil { |
| if err == io.EOF { |
| err = io.ErrUnexpectedEOF |
| } |
| r.remaining = 0 |
| return |
| } |
| chunkHashBytes := sha256.Sum256(chunk) |
| chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:]) |
| if chunkHash != r.chunkHashes[0] { |
| err = fmt.Errorf("payload failed verification against chunk hash, expected %q, got %q", r.chunkHashes[0], chunkHash) |
| r.remaining = 0 |
| return |
| } |
| r.chunkHashes = r.chunkHashes[1:] |
| r.remaining -= chunkLen |
| n = copy(p, chunk) |
| r.available = chunk[n:] |
| return |
| } |
| |
| func (r *payloadReader) Close() error { |
| if r.zstdDecoder != nil { |
| r.zstdDecoder.Close() |
| } |
| return r.blobReader.Close() |
| } |