//===----------------------------------------------------------------------===// // 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 = "(?(?:[a-z0-9]+(?:[._]|__|-|/)?)*[a-z0-9]+)" static let tagPattern = "(?::(?[\\w][\\w.-]{0,127}))?(?:@(?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.. [String] { let pattern = self.pattern let regex = try NSRegularExpression(pattern: "\\(\\?<(\\w+)>", options: []) let nsRange = NSRange(pattern.startIndex..