Files
apple--containerization/Sources/ContainerizationOCI/Reference.swift
T
wehub-resource-sync 680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

286 lines
11 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
// nameTotalLengthMax matches the OCI distribution spec which allows up to 255 bytes for the
// repository name component (domain + "/" + path).
private let nameTotalLengthMax = 255
// referenceTotalLengthMax is the upper bound for the full reference string: max name (255) +
// separator (1) + max tag length (128) = 384.
private let tagLengthMax = 128
private let referenceTotalLengthMax = nameTotalLengthMax + 1 + tagLengthMax
private let legacyDockerRegistryHost = "docker.io"
private let dockerRegistryHost = "registry-1.docker.io"
private let defaultDockerRegistryRepo = "library"
private let defaultTag = "latest"
/// A Reference is composed of the various parts of an OCI image reference.
/// For example:
/// let imageReference = "my-registry.com/repository/image:tag2"
/// let reference = Reference.parse(imageReference)
/// print(reference.domain!) // gives us "my-registry.com"
/// print(reference.name) // gives us "my-registry.com/repository/image"
/// print(reference.path) // gives us "repository/image"
/// print(reference.tag!) // gives us "tag2"
/// print(reference.digest) // gives us "nil"
public class Reference: CustomStringConvertible {
private var _domain: String?
public var domain: String? {
_domain
}
public var resolvedDomain: String? {
if let d = _domain {
return Self.resolveDomain(domain: d)
}
return nil
}
private var _path: String
public var path: String {
_path
}
private var _tag: String?
public var tag: String? {
_tag
}
private var _digest: String?
public var digest: String? {
_digest
}
public var name: String {
if let domain, !domain.isEmpty {
return "\(domain)/\(path)"
}
return path
}
public var description: String {
if let tag {
return "\(name):\(tag)"
}
if let digest {
return "\(name)@\(digest)"
}
return name
}
static let identifierPattern = "([a-f0-9]{64})"
static let domainPattern = {
let domainNameComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
let optionalPort = "(?::[0-9]+)?"
let ipv6address = "\\[(?:[a-fA-F0-9:]+)\\]"
let domainName = "\(domainNameComponent)(?:\\.\(domainNameComponent))*"
let host = "(?:\(domainName)|\(ipv6address))"
let domainAndPort = "\(host)\(optionalPort)"
return domainAndPort
}()
static let pathPattern = "(?<path>(?:[a-z0-9]+(?:[._]|__|-|/)?)*[a-z0-9]+)"
static let tagPattern = "(?::(?<tag>[\\w][\\w.-]{0,127}))?(?:@(?<digest>sha256:[0-9a-fA-F]{64}))?"
static let pathTagPattern = "\(pathPattern)\(tagPattern)"
public init(path: String, domain: String? = nil, tag: String? = nil, digest: String? = nil) throws {
if let domain, !domain.isEmpty {
self._domain = domain
}
self._path = path
self._tag = tag
self._digest = digest
}
public static func parse(_ s: String) throws -> Reference {
if s.count > referenceTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "reference length \(s.count) greater than \(referenceTotalLengthMax)")
}
let identifierRegex = try Regex(Self.identifierPattern)
guard try identifierRegex.wholeMatch(in: s) == nil else {
throw ContainerizationError(.invalidArgument, message: "cannot specify 64 byte hex string as reference")
}
let (domain, remainder) = try Self.parseDomain(from: s)
let constructedRawReference: String = remainder
if let domain {
let domainRegex = try Regex(domainPattern)
guard try domainRegex.wholeMatch(in: domain) != nil else {
throw ContainerizationError(.invalidArgument, message: "invalid domain \(domain) for reference \(s)")
}
}
let fields = try constructedRawReference.matches(regex: pathTagPattern)
guard let path = fields["path"] else {
throw ContainerizationError(.invalidArgument, message: "cannot parse path for reference \(s)")
}
let ref = try Reference(path: path, domain: domain)
if ref.name.count > nameTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "repo length \(ref.name.count) greater than \(nameTotalLengthMax)")
}
// Extract tag and digest
let tag = fields["tag"] ?? ""
let digest = fields["digest"] ?? ""
if !digest.isEmpty {
return try ref.withDigest(digest)
} else if !tag.isEmpty {
return try ref.withTag(tag)
}
return ref
}
private static func parseDomain(from s: String) throws -> (domain: String?, remainder: String) {
var domain: String? = nil
var path: String = s
let charset = CharacterSet(charactersIn: ".:")
let splits = s.split(separator: "/", maxSplits: 1)
guard splits.count == 2 else {
if s.starts(with: "localhost") {
return (s, "")
}
return (nil, s)
}
let _domain = String(splits[0])
let _path = String(splits[1])
if _domain.starts(with: "localhost") || _domain.rangeOfCharacter(from: charset) != nil {
domain = _domain
path = _path
}
return (domain, path)
}
public static func withName(_ name: String) throws -> Reference {
if name.count > nameTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "name length \(name.count) greater than \(nameTotalLengthMax)")
}
let fields = try name.matches(regex: Self.domainPattern)
// Extract domain and path
let domain = fields["domain"] ?? ""
let path = fields["path"] ?? ""
if domain.isEmpty || path.isEmpty {
throw ContainerizationError(.invalidArgument, message: "image reference domain or path is empty")
}
return try Reference(path: path, domain: domain)
}
public func withTag(_ tag: String) throws -> Reference {
var tag = tag
if !tag.starts(with: ":") {
tag = ":" + tag
}
let fields = try tag.matches(regex: Self.tagPattern)
tag = fields["tag"] ?? ""
if tag.isEmpty {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing tag")
}
return try Reference(path: self.path, domain: self.domain, tag: tag)
}
public func withDigest(_ digest: String) throws -> Reference {
var digest = digest
if !digest.starts(with: "@") {
digest = "@" + digest
}
let fields = try digest.matches(regex: Self.tagPattern)
digest = fields["digest"] ?? ""
if digest.isEmpty {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing digest")
}
return try Reference(path: self.path, domain: self.domain, digest: digest)
}
private static func splitDomain(_ name: String) -> (domain: String, path: String) {
let parts = name.split(separator: "/")
guard parts.count == 2 else {
return ("", name)
}
return (String(parts[0]), String(parts[1]))
}
/// Normalize the reference object.
/// Normalization is useful in cases where the reference object is to be used to
/// fetch/push an image from/to a remote registry.
/// It does the following:
/// - Adds a default tag of "latest" if the reference had no tag/digest set.
/// - If the domain is "registry-1.docker.io" or "docker.io" and the path has no repository set,
/// it adds a default "library/" repository name.
public func normalize() {
if let domain = self.domain, domain == dockerRegistryHost || domain == legacyDockerRegistryHost {
// Check if the image is being referenced by a named tag.
// If it is, and a repository is not specified, prefix it with "library/".
// This needs to be done only if we are using the Docker registry.
if !self.path.contains("/") {
self._path = "\(defaultDockerRegistryRepo)/\(self._path)"
}
}
let identifier = self._tag ?? self._digest
if identifier == nil {
// If the user did not specify a tag or a digest for the reference, set the tag to "latest".
self._tag = defaultTag
}
}
public static func resolveDomain(domain: String) -> String {
if domain == legacyDockerRegistryHost {
return dockerRegistryHost
}
return domain
}
}
extension String {
func matches(regex: String) throws -> [String: String] {
do {
let regex = try NSRegularExpression(pattern: regex, options: [])
let nsRange = NSRange(self.startIndex..<self.endIndex, in: self)
guard let match = regex.firstMatch(in: self, options: [], range: nsRange), match.range == nsRange else {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference")
}
var results = [String: String]()
for name in try regex.captureGroupNames() {
if let range = Range(match.range(withName: name), in: self) {
results[name] = String(self[range])
}
}
return results
} catch {
throw error
}
}
}
extension NSRegularExpression {
func captureGroupNames() throws -> [String] {
let pattern = self.pattern
let regex = try NSRegularExpression(pattern: "\\(\\?<(\\w+)>", options: [])
let nsRange = NSRange(pattern.startIndex..<pattern.endIndex, in: pattern)
let matches = regex.matches(in: pattern, options: [], range: nsRange)
return matches.map {
String(pattern[Range($0.range(at: 1), in: pattern)!])
}
}
}