package manifest import ( "encoding/json" "errors" "fmt" "slices" "strings" "time" "github.com/containers/image/v5/docker/reference" "github.com/containers/image/v5/internal/manifest" "github.com/containers/image/v5/internal/set" compressiontypes "github.com/containers/image/v5/pkg/compression/types" "github.com/containers/image/v5/types" "github.com/containers/storage/pkg/regexp" "github.com/docker/docker/api/types/versions" "github.com/opencontainers/go-digest" ) // Schema1FSLayers is an entry of the "fsLayers" array in docker/distribution schema 1. type Schema1FSLayers struct { BlobSum digest.Digest `json:"blobSum"` } // Schema1History is an entry of the "history" array in docker/distribution schema 1. type Schema1History struct { V1Compatibility string `json:"v1Compatibility"` } // Schema1 is a manifest in docker/distribution schema 1. type Schema1 struct { Name string `json:"name"` Tag string `json:"tag"` Architecture string `json:"architecture"` FSLayers []Schema1FSLayers `json:"fsLayers"` History []Schema1History `json:"history"` // Keep this in sync with ExtractedV1Compatibility! ExtractedV1Compatibility []Schema1V1Compatibility `json:"-"` // Keep this in sync with History! Does not contain the full config (Schema2V1Image) SchemaVersion int `json:"schemaVersion"` } type schema1V1CompatibilityContainerConfig struct { Cmd []string } // Schema1V1Compatibility is a v1Compatibility in docker/distribution schema 1. type Schema1V1Compatibility struct { ID string `json:"id"` Parent string `json:"parent,omitempty"` Comment string `json:"comment,omitempty"` Created time.Time `json:"created"` ContainerConfig schema1V1CompatibilityContainerConfig `json:"container_config,omitempty"` Author string `json:"author,omitempty"` ThrowAway bool `json:"throwaway,omitempty"` } // Schema1FromManifest creates a Schema1 manifest instance from a manifest blob. // (NOTE: The instance is not necessary a literal representation of the original blob, // layers with duplicate IDs are eliminated.) func Schema1FromManifest(manifestBlob []byte) (*Schema1, error) { s1 := Schema1{} if err := json.Unmarshal(manifestBlob, &s1); err != nil { return nil, err } if s1.SchemaVersion != 1 { return nil, fmt.Errorf("unsupported schema version %d", s1.SchemaVersion) } if err := manifest.ValidateUnambiguousManifestFormat(manifestBlob, DockerV2Schema1SignedMediaType, manifest.AllowedFieldFSLayers|manifest.AllowedFieldHistory); err != nil { return nil, err } if err := s1.initialize(); err != nil { return nil, err } if err := s1.fixManifestLayers(); err != nil { return nil, err } return &s1, nil } // Schema1FromComponents creates an Schema1 manifest instance from the supplied data. func Schema1FromComponents(ref reference.Named, fsLayers []Schema1FSLayers, history []Schema1History, architecture string) (*Schema1, error) { var name, tag string if ref != nil { // Well, what to do if it _is_ nil? Most consumers actually don't use these fields nowadays, so we might as well try not supplying them. name = reference.Path(ref) if tagged, ok := ref.(reference.NamedTagged); ok { tag = tagged.Tag() } } s1 := Schema1{ Name: name, Tag: tag, Architecture: architecture, FSLayers: fsLayers, History: history, SchemaVersion: 1, } if err := s1.initialize(); err != nil { return nil, err } return &s1, nil } // Schema1Clone creates a copy of the supplied Schema1 manifest. func Schema1Clone(src *Schema1) *Schema1 { copy := *src return © } // initialize initializes ExtractedV1Compatibility and verifies invariants, so that the rest of this code can assume a minimally healthy manifest. func (m *Schema1) initialize() error { if len(m.FSLayers) != len(m.History) { return errors.New("length of history not equal to number of layers") } if len(m.FSLayers) == 0 { return errors.New("no FSLayers in manifest") } m.ExtractedV1Compatibility = make([]Schema1V1Compatibility, len(m.History)) for i, h := range m.History { if err := json.Unmarshal([]byte(h.V1Compatibility), &m.ExtractedV1Compatibility[i]); err != nil { return fmt.Errorf("parsing v2s1 history entry %d: %w", i, err) } } return nil } // ConfigInfo returns a complete BlobInfo for the separate config object, or a BlobInfo{Digest:""} if there isn't a separate object. func (m *Schema1) ConfigInfo() types.BlobInfo { return types.BlobInfo{} } // LayerInfos returns a list of LayerInfos of layers referenced by this image, in order (the root layer first, and then successive layered layers). // The Digest field is guaranteed to be provided; Size may be -1. // WARNING: The list may contain duplicates, and they are semantically relevant. func (m *Schema1) LayerInfos() []LayerInfo { layers := make([]LayerInfo, len(m.FSLayers)) for i, layer := range m.FSLayers { // NOTE: This includes empty layers (where m.History.V1Compatibility->ThrowAway) layers[(len(m.FSLayers)-1)-i] = LayerInfo{ BlobInfo: types.BlobInfo{Digest: layer.BlobSum, Size: -1}, EmptyLayer: m.ExtractedV1Compatibility[i].ThrowAway, } } return layers } const fakeSchema1MIMEType = DockerV2Schema2LayerMediaType // Used only in schema1CompressionMIMETypeSets var schema1CompressionMIMETypeSets = []compressionMIMETypeSet{ { mtsUncompressed: fakeSchema1MIMEType, compressiontypes.GzipAlgorithmName: fakeSchema1MIMEType, compressiontypes.ZstdAlgorithmName: mtsUnsupportedMIMEType, }, } // UpdateLayerInfos replaces the original layers with the specified BlobInfos (size+digest+urls), in order (the root layer first, and then successive layered layers) func (m *Schema1) UpdateLayerInfos(layerInfos []types.BlobInfo) error { // Our LayerInfos includes empty layers (where m.ExtractedV1Compatibility[].ThrowAway), so expect them to be included here as well. if len(m.FSLayers) != len(layerInfos) { return fmt.Errorf("Error preparing updated manifest: layer count changed from %d to %d", len(m.FSLayers), len(layerInfos)) } m.FSLayers = make([]Schema1FSLayers, len(layerInfos)) for i, info := range layerInfos { // There are no MIME types in schema1, but we do a “conversion” here to reject unsupported compression algorithms, // in a way that is consistent with the other schema implementations. if _, err := updatedMIMEType(schema1CompressionMIMETypeSets, fakeSchema1MIMEType, info); err != nil { return fmt.Errorf("preparing updated manifest, layer %q: %w", info.Digest, err) } // (docker push) sets up m.ExtractedV1Compatibility[].{Id,Parent} based on values of info.Digest, // but (docker pull) ignores them in favor of computing DiffIDs from uncompressed data, except verifying the child->parent links and uniqueness. // So, we don't bother recomputing the IDs in m.History.V1Compatibility. m.FSLayers[(len(layerInfos)-1)-i].BlobSum = info.Digest if info.CryptoOperation != types.PreserveOriginalCrypto { return fmt.Errorf("encryption change (for layer %q) is not supported in schema1 manifests", info.Digest) } } return nil } // Serialize returns the manifest in a blob format. // NOTE: Serialize() does not in general reproduce the original blob if this object was loaded from one, even if no modifications were made! func (m *Schema1) Serialize() ([]byte, error) { // docker/distribution requires a signature even if the incoming data uses the nominally unsigned DockerV2Schema1MediaType. unsigned, err := json.Marshal(*m) if err != nil { return nil, err } return AddDummyV2S1Signature(unsigned) } // fixManifestLayers, after validating the supplied manifest // (to use correctly-formatted IDs, and to not have non-consecutive ID collisions in m.History), // modifies manifest to only have one entry for each layer ID in m.History (deleting the older duplicates, // both from m.History and m.FSLayers). // Note that even after this succeeds, m.FSLayers may contain duplicate entries // (for Dockerfile operations which change the configuration but not the filesystem). func (m *Schema1) fixManifestLayers() error { // m.initialize() has verified that len(m.FSLayers) == len(m.History) for _, compat := range m.ExtractedV1Compatibility { if err := validateV1ID(compat.ID); err != nil { return err } } if m.ExtractedV1Compatibility[len(m.ExtractedV1Compatibility)-1].Parent != "" { return errors.New("Invalid parent ID in the base layer of the image") } // check general duplicates to error instead of a deadlock idmap := set.New[string]() var lastID string for _, img := range m.ExtractedV1Compatibility { // skip IDs that appear after each other, we handle those later if img.ID != lastID && idmap.Contains(img.ID) { return fmt.Errorf("ID %+v appears multiple times in manifest", img.ID) } lastID = img.ID idmap.Add(lastID) } // backwards loop so that we keep the remaining indexes after removing items for i := len(m.ExtractedV1Compatibility) - 2; i >= 0; i-- { if m.ExtractedV1Compatibility[i].ID == m.ExtractedV1Compatibility[i+1].ID { // repeated ID. remove and continue m.FSLayers = slices.Delete(m.FSLayers, i, i+1) m.History = slices.Delete(m.History, i, i+1) m.ExtractedV1Compatibility = slices.Delete(m.ExtractedV1Compatibility, i, i+1) } else if m.ExtractedV1Compatibility[i].Parent != m.ExtractedV1Compatibility[i+1].ID { return fmt.Errorf("Invalid parent ID. Expected %v, got %q", m.ExtractedV1Compatibility[i+1].ID, m.ExtractedV1Compatibility[i].Parent) } } return nil } var validHex = regexp.Delayed(`^([a-f0-9]{64})$`) func validateV1ID(id string) error { if ok := validHex.MatchString(id); !ok { return fmt.Errorf("image ID %q is invalid", id) } return nil } // Inspect returns various information for (skopeo inspect) parsed from the manifest and configuration. func (m *Schema1) Inspect(_ func(types.BlobInfo) ([]byte, error)) (*types.ImageInspectInfo, error) { s1 := &Schema2V1Image{} if err := json.Unmarshal([]byte(m.History[0].V1Compatibility), s1); err != nil { return nil, err } layerInfos := m.LayerInfos() i := &types.ImageInspectInfo{ Tag: m.Tag, Created: &s1.Created, DockerVersion: s1.DockerVersion, Architecture: s1.Architecture, Variant: s1.Variant, Os: s1.OS, Layers: layerInfosToStrings(layerInfos), LayersData: imgInspectLayersFromLayerInfos(layerInfos), Author: s1.Author, } if s1.Config != nil { i.Labels = s1.Config.Labels i.Env = s1.Config.Env } return i, nil } // ToSchema2Config builds a schema2-style configuration blob using the supplied diffIDs. func (m *Schema1) ToSchema2Config(diffIDs []digest.Digest) ([]byte, error) { // Convert the schema 1 compat info into a schema 2 config, constructing some of the fields // that aren't directly comparable using info from the manifest. if len(m.History) == 0 { return nil, errors.New("image has no layers") } s1 := Schema2V1Image{} config := []byte(m.History[0].V1Compatibility) err := json.Unmarshal(config, &s1) if err != nil { return nil, fmt.Errorf("decoding configuration: %w", err) } // Images created with versions prior to 1.8.3 require us to re-encode the encoded object, // adding some fields that aren't "omitempty". if s1.DockerVersion != "" && versions.LessThan(s1.DockerVersion, "1.8.3") { config, err = json.Marshal(&s1) if err != nil { return nil, fmt.Errorf("re-encoding compat image config %#v: %w", s1, err) } } // Build the history. convertedHistory := []Schema2History{} for _, compat := range m.ExtractedV1Compatibility { hitem := Schema2History{ Created: compat.Created, CreatedBy: strings.Join(compat.ContainerConfig.Cmd, " "), Author: compat.Author, Comment: compat.Comment, EmptyLayer: compat.ThrowAway, } convertedHistory = append([]Schema2History{hitem}, convertedHistory...) } // Build the rootfs information. We need the decompressed sums that we've been // calculating to fill in the DiffIDs. It's expected (but not enforced by us) // that the number of diffIDs corresponds to the number of non-EmptyLayer // entries in the history. rootFS := &Schema2RootFS{ Type: "layers", DiffIDs: diffIDs, } // And now for some raw manipulation. raw := make(map[string]*json.RawMessage) err = json.Unmarshal(config, &raw) if err != nil { return nil, fmt.Errorf("re-decoding compat image config %#v: %w", s1, err) } // Drop some fields. delete(raw, "id") delete(raw, "parent") delete(raw, "parent_id") delete(raw, "layer_id") delete(raw, "throwaway") delete(raw, "Size") // Add the history and rootfs information. rootfs, err := json.Marshal(rootFS) if err != nil { return nil, fmt.Errorf("error encoding rootfs information %#v: %w", rootFS, err) } rawRootfs := json.RawMessage(rootfs) raw["rootfs"] = &rawRootfs history, err := json.Marshal(convertedHistory) if err != nil { return nil, fmt.Errorf("error encoding history information %#v: %w", convertedHistory, err) } rawHistory := json.RawMessage(history) raw["history"] = &rawHistory // Encode the result. config, err = json.Marshal(raw) if err != nil { return nil, fmt.Errorf("error re-encoding compat image config %#v: %w", s1, err) } return config, nil } // ImageID computes an ID which can uniquely identify this image by its contents. func (m *Schema1) ImageID(diffIDs []digest.Digest) (string, error) { image, err := m.ToSchema2Config(diffIDs) if err != nil { return "", err } return digest.FromBytes(image).Encoded(), nil }