import Foundation /// Where a node's bytes live. Archives are browsed in place, so an entry inside a /// JAR is addressed without ever unpacking the whole thing to disk. public enum NodeSource: Sendable { case fileSystem(URL) case archiveEntry(archive: URL, path: String) /// A nested archive (a JAR inside an EAR): the bytes were extracted once so /// its own central directory could be read. case extracted(URL, origin: String) } /// One file or folder in a comparison tree. Archives appear as folders. public final class Node: @unchecked Sendable { public let name: String /// Path relative to the root of its side, used to pair the two trees. public let path: String public let isDirectory: Bool public let size: UInt64 /// CRC32 from the archive's central directory, when the node came from one. /// Present means "we already know the content fingerprint for free". public let crc32: UInt32? public let source: NodeSource /// True when this folder is really an archive shown as a folder. public let isArchive: Bool public private(set) var children: [Node] init(name: String, path: String, isDirectory: Bool, size: UInt64 = 0, crc32: UInt32? = nil, source: NodeSource, isArchive: Bool = false, children: [Node] = []) { self.name = name self.path = path self.isDirectory = isDirectory self.size = size self.crc32 = crc32 self.source = source self.isArchive = isArchive self.children = children } func add(_ child: Node) { children.append(child) } func sortChildren() { children.sort { left, right in if left.isDirectory != right.isDirectory { return left.isDirectory } return left.name.localizedStandardCompare(right.name) == .orderedAscending } children.forEach { $0.sortChildren() } } /// Reads this node's bytes, inflating from its archive when needed. public func data() throws -> Data { switch source { case .fileSystem(let url), .extracted(let url, _): return try Data(contentsOf: url, options: .mappedIfSafe) case .archiveEntry(let archive, let entryPath): let reader = try ZipReader(url: archive) guard let entry = reader.entries.first(where: { $0.path == entryPath }) else { throw ZipReader.ZipError.corrupt("entry \(entryPath) vanished") } return try reader.data(for: entry) } } } /// Builds comparison trees from folders and archives. public enum TreeScanner { /// Scans a folder or an archive into a tree. Archives — including archives /// nested inside archives (an EAR's JARs) — are expanded as folders, which is /// what makes comparing deployment artefacts useful. public static func scan(_ url: URL, expandNestedArchives: Bool = true) throws -> Node { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { throw CocoaError(.fileNoSuchFile) } if isDirectory.boolValue { return try scanDirectory(url, expandNestedArchives: expandNestedArchives) } if ZipReader.isArchive(url) { return try scanArchive(url, expandNestedArchives: expandNestedArchives) } let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 return Node(name: url.lastPathComponent, path: url.lastPathComponent, isDirectory: false, size: UInt64(size), source: .fileSystem(url)) } // MARK: Folders private static func scanDirectory(_ url: URL, expandNestedArchives: Bool) throws -> Node { let root = Node(name: url.lastPathComponent, path: "", isDirectory: true, source: .fileSystem(url)) try fill(root, at: url, prefix: "", expandNestedArchives: expandNestedArchives) root.sortChildren() return root } private static func fill(_ parent: Node, at url: URL, prefix: String, expandNestedArchives: Bool) throws { let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] let entries = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles]) for entry in entries { let values = try? entry.resourceValues(forKeys: Set(keys)) let childPath = prefix.isEmpty ? entry.lastPathComponent : prefix + "/" + entry.lastPathComponent if values?.isDirectory == true { let node = Node(name: entry.lastPathComponent, path: childPath, isDirectory: true, source: .fileSystem(entry)) parent.add(node) try fill(node, at: entry, prefix: childPath, expandNestedArchives: expandNestedArchives) } else if expandNestedArchives, ZipReader.isArchive(entry) { // Show the archive as a folder, keeping its own name. if let archiveNode = try? scanArchive(entry, expandNestedArchives: expandNestedArchives, path: childPath) { parent.add(archiveNode) } else { parent.add(fileNode(entry, path: childPath, size: values?.fileSize ?? 0)) } } else { parent.add(fileNode(entry, path: childPath, size: values?.fileSize ?? 0)) } } } private static func fileNode(_ url: URL, path: String, size: Int) -> Node { Node(name: url.lastPathComponent, path: path, isDirectory: false, size: UInt64(size), source: .fileSystem(url)) } // MARK: Archives /// Turns an archive's flat entry list into a tree of folders and files. static func scanArchive(_ url: URL, expandNestedArchives: Bool, path: String? = nil) throws -> Node { let reader = try ZipReader(url: url) let rootPath = path ?? url.lastPathComponent let root = Node(name: url.lastPathComponent, path: rootPath, isDirectory: true, source: .fileSystem(url), isArchive: true) var folders: [String: Node] = ["": root] func folder(_ folderPath: String) -> Node { if let existing = folders[folderPath] { return existing } let parentPath = (folderPath as NSString).deletingLastPathComponent let parent = folder(parentPath) let node = Node(name: (folderPath as NSString).lastPathComponent, path: rootPath + "/" + folderPath, isDirectory: true, source: .archiveEntry(archive: url, path: folderPath)) parent.add(node) folders[folderPath] = node return node } for entry in reader.entries where !entry.isDirectory { let parentPath = (entry.path as NSString).deletingLastPathComponent let parent = folder(parentPath) let childPath = rootPath + "/" + entry.path let name = (entry.path as NSString).lastPathComponent // A JAR inside an EAR: extract once so its own entries can be listed. // A JAR inside an EAR, or any renamed ZIP: expand it as a folder. if expandNestedArchives, ZipReader.mayBeNestedArchive(name: name, uncompressedSize: entry.uncompressedSize) { if let nested = try? extractNested(reader: reader, entry: entry, name: name), ZipReader.looksLikeArchive(fileAt: nested), let nestedNode = try? scanArchive(nested, expandNestedArchives: expandNestedArchives, path: childPath) { parent.add(nestedNode) continue } } parent.add(Node(name: name, path: childPath, isDirectory: false, size: entry.uncompressedSize, crc32: entry.crc32, source: .archiveEntry(archive: url, path: entry.path))) } root.sortChildren() return root } private static func extractNested(reader: ZipReader, entry: ZipReader.Entry, name: String) throws -> URL { let directory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("kotej-nested-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appendingPathComponent(name) try reader.data(for: entry).write(to: url) return url } }