blob: 30a88ae7e71b3d3528b41bd6ebaef248fa69889c [file] [log] [blame]
Jan Schärc53a9fc2025-04-14 11:09:27 +00001// Copyright The Monogon Project Authors.
2// SPDX-License-Identifier: Apache-2.0
3
4// Package osimage allows reading OS images represented as OCI artifacts, and
5// contains the types for the OS image config.
6package osimage
7
8import (
9 "crypto/sha256"
10 "encoding/base64"
11 "encoding/json"
12 "fmt"
13 "io"
14
15 "github.com/klauspost/compress/zstd"
16
17 "source.monogon.dev/osbase/oci"
18 "source.monogon.dev/osbase/structfs"
19)
20
21// Image represents an OS image.
22type Image struct {
23 // Config contains the parsed config.
24 Config *Config
25 // RawConfig contains the bytes of the config.
26 RawConfig []byte
27
28 image *oci.Image
29}
30
31// Read reads the config from an OCI image and returns an [Image].
32func Read(image *oci.Image) (*Image, error) {
33 manifest := image.Manifest
34 if manifest.ArtifactType != ArtifactTypeOSImage {
35 return nil, fmt.Errorf("unexpected manifest artifact type %q", manifest.ArtifactType)
36 }
37 if manifest.Config.MediaType != MediaTypeOSImageConfig {
38 return nil, fmt.Errorf("unexpected config media type %q", manifest.Config.MediaType)
39 }
40
41 // Read the config.
42 rawConfig, err := image.ReadBlobVerified(&manifest.Config)
43 if err != nil {
44 return nil, fmt.Errorf("failed to read config: %w", err)
45 }
46 config := &Config{}
47 err = json.Unmarshal(rawConfig, &config)
48 if err != nil {
49 return nil, fmt.Errorf("failed to parse config: %w", err)
50 }
51 if config.FormatVersion != ConfigVersion {
52 return nil, fmt.Errorf("unsupported config version %q", config.FormatVersion)
53 }
54 if len(config.Payloads) != len(manifest.Layers) {
55 return nil, fmt.Errorf("number of layers %d does not match number of payloads %d", len(manifest.Layers), len(config.Payloads))
56 }
57 for i := range config.Payloads {
58 payload := &config.Payloads[i]
59 if payload.Size < 0 {
60 return nil, fmt.Errorf("payload %q has negative size", payload.Name)
61 }
62 if payload.HashChunkSize <= 0 {
63 return nil, fmt.Errorf("payload %q has invalid chunk size %d", payload.Name, payload.HashChunkSize)
64 }
65 if payload.HashChunkSize > 16*1024*1024 {
66 return nil, fmt.Errorf("payload %q has too large chunk size %d", payload.Name, payload.HashChunkSize)
67 }
68 chunks := payload.Size / payload.HashChunkSize
69 if chunks*payload.HashChunkSize < payload.Size {
70 chunks++
71 }
72 if int64(len(payload.ChunkHashesSHA256)) != chunks {
73 return nil, fmt.Errorf("payload %q has %d chunks but %d chunk hashes", payload.Name, chunks, len(payload.ChunkHashesSHA256))
74 }
75 }
76
77 osImage := &Image{
78 Config: config,
79 RawConfig: rawConfig,
80 image: image,
81 }
82 return osImage, nil
83}
84
85// Payload returns the contents of the payload of the given name.
86// All data is verified against hashes in the config before it is returned.
87func (i *Image) Payload(name string) (structfs.Blob, error) {
88 for pi := range i.Config.Payloads {
89 info := &i.Config.Payloads[pi]
90 if info.Name == name {
91 layer := &i.image.Manifest.Layers[pi]
92 blob := &payloadBlob{
93 raw: i.image.StructfsBlob(layer),
94 mediaType: layer.MediaType,
95 info: info,
96 }
97 return blob, nil
98 }
99 }
100 return nil, fmt.Errorf("payload %q not found", name)
101}
102
103type payloadBlob struct {
104 raw structfs.Blob
105 mediaType string
106 info *PayloadInfo
107}
108
109func (b *payloadBlob) Open() (io.ReadCloser, error) {
110 blobReader, err := b.raw.Open()
111 if err != nil {
112 return nil, err
113 }
114 reader := &payloadReader{
115 chunkHashes: b.info.ChunkHashesSHA256,
116 blobReader: blobReader,
117 remaining: b.info.Size,
118 buf: make([]byte, b.info.HashChunkSize),
119 }
120 switch b.mediaType {
121 case MediaTypePayloadUncompressed:
122 reader.uncompressed = blobReader
123 case MediaTypePayloadZstd:
124 reader.zstdDecoder, err = zstd.NewReader(blobReader)
125 if err != nil {
126 blobReader.Close()
127 return nil, fmt.Errorf("failed to create zstd decoder: %w", err)
128 }
129 reader.uncompressed = reader.zstdDecoder
130 default:
131 blobReader.Close()
132 return nil, fmt.Errorf("unsupported media type %q", b.mediaType)
133 }
134 return reader, nil
135}
136
137func (b *payloadBlob) Size() int64 {
138 return b.info.Size
139}
140
141type payloadReader struct {
142 chunkHashes []string
143 blobReader io.ReadCloser
144 zstdDecoder *zstd.Decoder
145 uncompressed io.Reader
146 remaining int64 // number of bytes remaining in uncompressed
147 buf []byte // buffer of chunk size
148 available []byte // bytes available for reading in the last read chunk
149}
150
151func (r *payloadReader) Read(p []byte) (n int, err error) {
152 if len(r.available) != 0 {
153 n = copy(p, r.available)
154 r.available = r.available[n:]
155 return
156 }
157 if r.remaining == 0 {
158 err = io.EOF
159 return
160 }
161 chunkLen := min(r.remaining, int64(len(r.buf)))
162 chunk := r.buf[:chunkLen]
163 _, err = io.ReadFull(r.uncompressed, chunk)
164 if err != nil {
165 if err == io.EOF {
166 err = io.ErrUnexpectedEOF
167 }
168 r.remaining = 0
169 return
170 }
171 chunkHashBytes := sha256.Sum256(chunk)
172 chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:])
173 if chunkHash != r.chunkHashes[0] {
174 err = fmt.Errorf("payload failed verification against chunk hash, expected %q, got %q", r.chunkHashes[0], chunkHash)
175 r.remaining = 0
176 return
177 }
178 r.chunkHashes = r.chunkHashes[1:]
179 r.remaining -= chunkLen
180 n = copy(p, chunk)
181 r.available = chunk[n:]
182 return
183}
184
185func (r *payloadReader) Close() error {
186 if r.zstdDecoder != nil {
187 r.zstdDecoder.Close()
188 }
189 return r.blobReader.Close()
190}