e3cf1df73e
Archives are detected by their signature, not just their extension, so a ZIP renamed to anything still opens as a folder — and nesting keeps going, verified on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are skipped without inflating, since checking the signature inside an archive means decompressing, and an EAR holds thousands of .class files. The comparison now records which mode settled each file, so Automatic shows an icon per row (byte / text / structural) instead of leaving you to guess when it went semantic. Sizes are shown on both sides and highlighted when they disagree, which is often the fastest hint of what changed. Two files whose names don't match can now be compared: mark one side, pick the other, and it becomes the new base in this tab or a new one. Entries living inside an archive are extracted to a temporary file first, since they have no path of their own — that's what lets a file inside a JAR be paired with a loose one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
185 lines
8.6 KiB
Swift
185 lines
8.6 KiB
Swift
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
|
|
}
|
|
}
|