blob: ac9721aafac416093cc664837a3c76c6cbff3ffe [file] [log] [blame]
// 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()
}