Nested archives by signature, per-side sizes, applied-mode icon, manual pairing
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
This commit is contained in:
@@ -33,6 +33,10 @@ final class ComparisonModel: Identifiable {
|
||||
/// sides of a row can carry a disclosure control.
|
||||
private var expanded: Set<String> = []
|
||||
|
||||
/// One side of a manual pairing, held while the other is chosen. Lets two
|
||||
/// files with different names be compared, which the tree alone can't express.
|
||||
var pendingPick: PendingPick?
|
||||
|
||||
func isExpanded(_ id: String) -> Bool { expanded.contains(id) }
|
||||
|
||||
func toggleExpansion(_ id: String) {
|
||||
@@ -135,6 +139,7 @@ final class ComparisonModel: Identifiable {
|
||||
visibleRows = []
|
||||
searchText = ""
|
||||
expanded.removeAll()
|
||||
pendingPick = nil
|
||||
}
|
||||
|
||||
func compare() {
|
||||
|
||||
+21
-1
@@ -4,6 +4,8 @@ import KotejEngine
|
||||
|
||||
struct ContentView: View {
|
||||
@Bindable var model: ComparisonModel
|
||||
/// Opens an arbitrary pair (left, right, inNewTab); provided by RootView.
|
||||
var openComparison: ((URL, URL, Bool) -> Void)?
|
||||
/// How much of the height the tree takes; dragging the divider keeps it.
|
||||
@AppStorage("kotej.splitFraction") private var splitFraction: Double = 0.45
|
||||
|
||||
@@ -17,6 +19,9 @@ struct ContentView: View {
|
||||
if let message = model.errorMessage {
|
||||
banner(message)
|
||||
}
|
||||
if let pending = model.pendingPick {
|
||||
pendingBanner(pending)
|
||||
}
|
||||
|
||||
if model.isScanning {
|
||||
VStack(spacing: 10) {
|
||||
@@ -26,7 +31,7 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if model.root != nil {
|
||||
SplitPane(fraction: splitBinding) {
|
||||
DiffTreeView(model: model)
|
||||
DiffTreeView(model: model, openComparison: openComparison)
|
||||
} bottom: {
|
||||
FileDiffView(model: model)
|
||||
}
|
||||
@@ -44,6 +49,21 @@ struct ContentView: View {
|
||||
Binding(get: { CGFloat(splitFraction) }, set: { splitFraction = Double($0) })
|
||||
}
|
||||
|
||||
/// Keeps the marked side visible; otherwise it's easy to forget one is armed.
|
||||
private func pendingBanner(_ pending: PendingPick) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pin.fill").foregroundStyle(.tint)
|
||||
Text("Marked for comparison:") + Text(verbatim: " \(pending.name)")
|
||||
Spacer()
|
||||
Button("Forget") { model.pendingPick = nil }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.accentColor.opacity(0.1))
|
||||
}
|
||||
|
||||
private func banner(_ text: String) -> some View {
|
||||
Label(text, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
|
||||
+105
-9
@@ -57,8 +57,11 @@ struct FlatRow: Identifiable {
|
||||
/// The side-by-side tree. Archives appear as folders.
|
||||
struct DiffTreeView: View {
|
||||
@Bindable var model: ComparisonModel
|
||||
/// Opens an arbitrary pair as a new comparison; supplied by the window.
|
||||
var openComparison: ((URL, URL, Bool) -> Void)?
|
||||
|
||||
private let sizeWidth: CGFloat = 84
|
||||
private let sizeWidth: CGFloat = 76
|
||||
private let modeWidth: CGFloat = 26
|
||||
private let statusWidth: CGFloat = 104
|
||||
|
||||
var body: some View {
|
||||
@@ -86,6 +89,7 @@ struct DiffTreeView: View {
|
||||
List(flatRows, selection: selectionBinding) { row in
|
||||
DiffRow(row: row,
|
||||
sizeWidth: sizeWidth,
|
||||
modeWidth: modeWidth,
|
||||
statusWidth: statusWidth,
|
||||
onToggle: { model.toggleExpansion(row.id) })
|
||||
.contentShape(Rectangle())
|
||||
@@ -127,11 +131,13 @@ struct DiffTreeView: View {
|
||||
if let name = model.leftURL?.lastPathComponent { Text(verbatim: name) } else { Text("Left") }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Size").frame(width: sizeWidth, alignment: .trailing)
|
||||
Group {
|
||||
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Size").frame(width: sizeWidth, alignment: .trailing)
|
||||
Color.clear.frame(width: modeWidth, height: 1)
|
||||
Text("Status").frame(width: statusWidth, alignment: .trailing)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -152,6 +158,30 @@ struct DiffTreeView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func rowMenu(_ node: DiffNode) -> some View {
|
||||
// Pair up two files whose names don't match: mark one side, then pick the
|
||||
// other. The marked side survives changing filter or folder.
|
||||
if let left = node.left, !node.isDirectory {
|
||||
Button("Mark left for comparison") { mark(left, path: node.path) }
|
||||
}
|
||||
if let right = node.right, !node.isDirectory {
|
||||
Button("Mark right for comparison") { mark(right, path: node.path) }
|
||||
}
|
||||
if let pending = model.pendingPick {
|
||||
Divider()
|
||||
if let left = node.left, !node.isDirectory {
|
||||
Button("Compare left with “\(pending.name)”") { compare(pending, with: left, newTab: false) }
|
||||
}
|
||||
if let right = node.right, !node.isDirectory {
|
||||
Button("Compare right with “\(pending.name)”") { compare(pending, with: right, newTab: false) }
|
||||
}
|
||||
if let right = node.right, !node.isDirectory {
|
||||
Button("Compare right with “\(pending.name)” in a new tab") {
|
||||
compare(pending, with: right, newTab: true)
|
||||
}
|
||||
}
|
||||
Button("Forget “\(pending.name)”") { model.pendingPick = nil }
|
||||
}
|
||||
Divider()
|
||||
Button("Expand all") { model.expandAll(from: displayRoot) }
|
||||
Button("Collapse all") { model.collapseAll() }
|
||||
Divider()
|
||||
@@ -166,6 +196,18 @@ struct DiffTreeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func mark(_ node: Node, path: String) {
|
||||
guard let url = try? NodeExporter.url(for: node) else { return }
|
||||
model.pendingPick = PendingPick(path: path, name: node.name, url: url)
|
||||
}
|
||||
|
||||
/// Extracts the second side if needed and hands the pair to the window.
|
||||
private func compare(_ pending: PendingPick, with node: Node, newTab: Bool) {
|
||||
guard let url = try? NodeExporter.url(for: node) else { return }
|
||||
model.pendingPick = nil
|
||||
openComparison?(pending.url, url, newTab)
|
||||
}
|
||||
|
||||
/// Only nodes backed by a real file can be shown in Finder; entries living
|
||||
/// inside an archive have no path of their own.
|
||||
private func canReveal(_ node: Node?) -> Bool {
|
||||
@@ -185,6 +227,7 @@ struct DiffTreeView: View {
|
||||
struct DiffRow: View {
|
||||
let row: FlatRow
|
||||
let sizeWidth: CGFloat
|
||||
let modeWidth: CGFloat
|
||||
let statusWidth: CGFloat
|
||||
let onToggle: () -> Void
|
||||
|
||||
@@ -193,12 +236,12 @@ struct DiffRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
side(present: node.left != nil)
|
||||
sizeCell(node.left?.size, differs: sizesDiffer)
|
||||
side(present: node.right != nil)
|
||||
sizeCell(node.right?.size, differs: sizesDiffer)
|
||||
|
||||
Text(verbatim: sizeText)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: sizeWidth, alignment: .trailing)
|
||||
ModeIcon(mode: node.appliedMode)
|
||||
.frame(width: modeWidth, alignment: .center)
|
||||
|
||||
StatusBadge(status: node.status)
|
||||
.frame(width: statusWidth, alignment: .trailing)
|
||||
@@ -242,12 +285,25 @@ struct DiffRow: View {
|
||||
return node.isDirectory ? "folder.fill" : "doc"
|
||||
}
|
||||
|
||||
/// Folders don't report a size; files show the side that exists.
|
||||
private var sizeText: String {
|
||||
guard !node.isDirectory else { return "" }
|
||||
let bytes = node.left?.size ?? node.right?.size ?? 0
|
||||
/// A size per side, so they can be compared directly. Highlighted when they
|
||||
/// disagree, which is often the quickest hint of what changed.
|
||||
private func sizeCell(_ bytes: UInt64?, differs: Bool) -> some View {
|
||||
Text(verbatim: sizeText(bytes))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(differs ? Color.orange : Color.secondary)
|
||||
.frame(width: sizeWidth, alignment: .trailing)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
|
||||
private func sizeText(_ bytes: UInt64?) -> String {
|
||||
guard !node.isDirectory, let bytes else { return "" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
|
||||
private var sizesDiffer: Bool {
|
||||
guard !node.isDirectory, let l = node.left?.size, let r = node.right?.size else { return false }
|
||||
return l != r
|
||||
}
|
||||
}
|
||||
|
||||
/// Coloured pill so the verdict reads at a glance instead of as plain text.
|
||||
@@ -285,3 +341,43 @@ struct StatusBadge: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Shows which comparison settled a row, which is what makes "Automatic"
|
||||
/// trustworthy: you can see when it went semantic rather than assuming.
|
||||
struct ModeIcon: View {
|
||||
let mode: ComparisonMode?
|
||||
|
||||
var body: some View {
|
||||
if let mode {
|
||||
Image(systemName: symbol(mode))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(tint(mode))
|
||||
.help(helpText(mode))
|
||||
}
|
||||
}
|
||||
|
||||
private func symbol(_ mode: ComparisonMode) -> String {
|
||||
switch mode {
|
||||
case .binary: return "number"
|
||||
case .text: return "text.alignleft"
|
||||
case .semantic: return "curlybraces"
|
||||
}
|
||||
}
|
||||
|
||||
private func tint(_ mode: ComparisonMode) -> Color {
|
||||
switch mode {
|
||||
case .binary: return .secondary
|
||||
case .text: return .blue
|
||||
case .semantic: return .purple
|
||||
}
|
||||
}
|
||||
|
||||
private func helpText(_ mode: ComparisonMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .binary: return "Compared byte for byte"
|
||||
case .text: return "Compared as text"
|
||||
case .semantic: return "Compared structurally (JSON/XML)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ struct RootView: View {
|
||||
VStack(spacing: 0) {
|
||||
TabStrip(tabs: tabs)
|
||||
Divider()
|
||||
ContentView(model: tabs.selected)
|
||||
ContentView(model: tabs.selected,
|
||||
openComparison: { left, right, newTab in
|
||||
tabs.openPair(left: left, right: right, inNewTab: newTab)
|
||||
})
|
||||
// Rebuild the body when the tab changes so per-tab state
|
||||
// (selection, loaded diff) doesn't leak across tabs.
|
||||
.id(tabs.selectedID)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import KotejEngine
|
||||
|
||||
/// Gets a real file path for any node, so an arbitrary pair can be re-compared as
|
||||
/// a new base.
|
||||
///
|
||||
/// Entries living inside an archive have no path of their own, so they're written
|
||||
/// out to a temporary file first — that's what makes it possible to pick a file
|
||||
/// inside a JAR on one side and a loose file on the other.
|
||||
enum NodeExporter {
|
||||
/// Returns a URL for the node, extracting it if it only exists inside an
|
||||
/// archive. Nil when the node has no readable content.
|
||||
static func url(for node: Node) throws -> URL {
|
||||
switch node.source {
|
||||
case .fileSystem(let url), .extracted(let url, _):
|
||||
return url
|
||||
case .archiveEntry:
|
||||
return try extract(node)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extract(_ node: Node) throws -> URL {
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("kotej-pick-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent(node.name)
|
||||
try node.data().write(to: url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// One side of a manual pairing, kept while the other side is chosen.
|
||||
struct PendingPick: Equatable {
|
||||
let path: String
|
||||
let name: String
|
||||
let url: URL
|
||||
}
|
||||
@@ -44,6 +44,15 @@ final class TabsModel {
|
||||
|
||||
func closeSelected() { close(selected) }
|
||||
|
||||
/// Opens an arbitrary pair, either replacing the current comparison or in a
|
||||
/// new tab, which is what makes a hand-picked pair the new base.
|
||||
func openPair(left: URL, right: URL, inNewTab: Bool) {
|
||||
let target = inNewTab ? newTab() : selected
|
||||
target.setSide(.left, to: left)
|
||||
target.setSide(.right, to: right)
|
||||
selectedID = target.id
|
||||
}
|
||||
|
||||
/// Opens a comparison in a tab: reuses the current one while it's still empty
|
||||
/// (the common case right after launch) and otherwise starts a new one, so an
|
||||
/// in-progress comparison is never overwritten.
|
||||
|
||||
@@ -85,3 +85,12 @@
|
||||
"whitespace" = "whitespace";
|
||||
"added" = "added";
|
||||
"removed" = "removed";
|
||||
|
||||
/* Manual pairing */
|
||||
"Mark left for comparison" = "Mark left for comparison";
|
||||
"Mark right for comparison" = "Mark right for comparison";
|
||||
"Marked for comparison:" = "Marked for comparison:";
|
||||
"Forget" = "Forget";
|
||||
"Compared byte for byte" = "Compared byte for byte";
|
||||
"Compared as text" = "Compared as text";
|
||||
"Compared structurally (JSON/XML)" = "Compared structurally (JSON/XML)";
|
||||
|
||||
@@ -85,3 +85,12 @@
|
||||
"whitespace" = "espacios";
|
||||
"added" = "añadidas";
|
||||
"removed" = "borradas";
|
||||
|
||||
/* Emparejar manualmente */
|
||||
"Mark left for comparison" = "Marcar izquierda para comparar";
|
||||
"Mark right for comparison" = "Marcar derecha para comparar";
|
||||
"Marked for comparison:" = "Marcado para comparar:";
|
||||
"Forget" = "Olvidar";
|
||||
"Compared byte for byte" = "Comparado byte a byte";
|
||||
"Compared as text" = "Comparado como texto";
|
||||
"Compared structurally (JSON/XML)" = "Comparado estructuralmente (JSON/XML)";
|
||||
|
||||
@@ -29,6 +29,9 @@ public final class DiffNode: @unchecked Sendable {
|
||||
public let left: Node?
|
||||
public let right: Node?
|
||||
public var status: DiffStatus
|
||||
/// Which comparison actually settled this file, so "Automatic" can show what
|
||||
/// it decided rather than leaving you to guess.
|
||||
public var appliedMode: ComparisonMode?
|
||||
public internal(set) var children: [DiffNode]
|
||||
|
||||
init(name: String, path: String, isDirectory: Bool, isArchive: Bool,
|
||||
@@ -135,9 +138,12 @@ public enum DirectoryComparer {
|
||||
left: left, right: right, status: .different)
|
||||
}
|
||||
|
||||
return DiffNode(name: name, path: path, isDirectory: false,
|
||||
isArchive: false, left: left, right: right,
|
||||
status: fileStatus(left: left, right: right, options: options))
|
||||
let outcome = fileOutcome(left: left, right: right, options: options)
|
||||
let node = DiffNode(name: name, path: path, isDirectory: false,
|
||||
isArchive: false, left: left, right: right,
|
||||
status: outcome.status)
|
||||
node.appliedMode = outcome.mode
|
||||
return node
|
||||
}
|
||||
|
||||
private static func pairChildren(left: Node, right: Node, options: Options) -> [DiffNode] {
|
||||
@@ -161,6 +167,21 @@ public enum DirectoryComparer {
|
||||
return rows
|
||||
}
|
||||
|
||||
/// The status plus which comparison produced it.
|
||||
static func fileOutcome(left: Node, right: Node,
|
||||
options: Options) -> (status: DiffStatus, mode: ComparisonMode?) {
|
||||
// Level 1 — CRC32 straight from the archive directory: free and decisive.
|
||||
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
|
||||
if leftCRC == rightCRC && left.size == right.size { return (.identical, nil) }
|
||||
} else if left.size == right.size, left.size == 0 {
|
||||
return (.identical, nil)
|
||||
}
|
||||
|
||||
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
|
||||
guard mustRead else { return (.pending, nil) }
|
||||
return resolveDetailed(left: left, right: right, options: options)
|
||||
}
|
||||
|
||||
/// The layered check for a single pair of files.
|
||||
static func fileStatus(left: Node, right: Node, options: Options) -> DiffStatus {
|
||||
// Level 1 — CRC32 straight from the archive directory: free and decisive.
|
||||
@@ -179,16 +200,23 @@ public enum DirectoryComparer {
|
||||
|
||||
/// Reads both sides and applies the chosen (or inferred) comparison mode.
|
||||
public static func resolve(left: Node, right: Node, options: Options = Options()) -> DiffStatus {
|
||||
guard let leftData = try? left.data(), let rightData = try? right.data() else { return .different }
|
||||
if leftData == rightData { return .identical }
|
||||
resolveDetailed(left: left, right: right, options: options).status
|
||||
}
|
||||
|
||||
/// Same, but also reports which mode was used.
|
||||
public static func resolveDetailed(left: Node, right: Node,
|
||||
options: Options = Options()) -> (status: DiffStatus, mode: ComparisonMode?) {
|
||||
guard let leftData = try? left.data(), let rightData = try? right.data() else { return (.different, nil) }
|
||||
|
||||
let kind = ContentKind.detect(path: left.name, data: leftData)
|
||||
let mode = options.mode ?? kind.defaultMode
|
||||
if leftData == rightData { return (.identical, mode) }
|
||||
|
||||
switch ContentComparer.compare(leftData, rightData, mode: mode, kind: kind,
|
||||
textOptions: options.textOptions) {
|
||||
case .equal: return .identical
|
||||
case .equivalent: return .equivalent
|
||||
case .different, .unsupported: return .different
|
||||
case .equal: return (.identical, mode)
|
||||
case .equivalent: return (.equivalent, mode)
|
||||
case .different, .unsupported: return (.different, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,11 @@ public enum TreeScanner {
|
||||
let name = (entry.path as NSString).lastPathComponent
|
||||
|
||||
// A JAR inside an EAR: extract once so its own entries can be listed.
|
||||
if expandNestedArchives, ZipReader.archiveExtensions.contains((name as NSString).pathExtension.lowercased()) {
|
||||
// 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)
|
||||
|
||||
@@ -40,7 +40,53 @@ public struct ZipReader {
|
||||
public static let archiveExtensions: Set<String> = ["zip", "jar", "ear", "war", "aar", "ipa"]
|
||||
|
||||
public static func isArchive(_ url: URL) -> Bool {
|
||||
archiveExtensions.contains(url.pathExtension.lowercased())
|
||||
if archiveExtensions.contains(url.pathExtension.lowercased()) { return true }
|
||||
// A ZIP renamed to anything else is still a ZIP, and deployment artefacts
|
||||
// do this constantly, so fall back to the signature.
|
||||
return looksLikeArchive(fileAt: url)
|
||||
}
|
||||
|
||||
/// Extensions that are definitely not containers, used to skip the signature
|
||||
/// check on the thousands of small entries inside a real EAR.
|
||||
static let neverArchiveExtensions: Set<String> = [
|
||||
"class", "java", "swift", "kt", "go", "c", "h", "m", "js", "ts", "css", "scss",
|
||||
"html", "htm", "xml", "xsd", "xsl", "xslt", "wsdl", "json", "yml", "yaml",
|
||||
"properties", "conf", "cfg", "ini", "toml", "md", "txt", "log", "csv", "sql",
|
||||
"sh", "bat", "mf", "sf", "rsa", "dsa", "png", "jpg", "jpeg", "gif", "svg",
|
||||
"ico", "pdf", "ttf", "otf", "woff", "woff2", "so", "dylib", "plist",
|
||||
]
|
||||
|
||||
/// The local-file-header signature, plus the empty and spanned variants.
|
||||
private static let signatures: [[UInt8]] = [
|
||||
[0x50, 0x4B, 0x03, 0x04],
|
||||
[0x50, 0x4B, 0x05, 0x06],
|
||||
[0x50, 0x4B, 0x07, 0x08],
|
||||
]
|
||||
|
||||
/// Cheap check: read only the first four bytes.
|
||||
public static func looksLikeArchive(fileAt url: URL) -> Bool {
|
||||
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
|
||||
defer { try? handle.close() }
|
||||
guard let head = try? handle.read(upToCount: 4) else { return false }
|
||||
return looksLikeArchive(head)
|
||||
}
|
||||
|
||||
public static func looksLikeArchive(_ head: Data?) -> Bool {
|
||||
guard let head, head.count >= 4 else { return false }
|
||||
let bytes = Array(head.prefix(4))
|
||||
return signatures.contains(bytes)
|
||||
}
|
||||
|
||||
/// Whether an entry inside an archive is worth opening as one. Known archive
|
||||
/// extensions always are; anything with a clearly non-container extension
|
||||
/// never is; the rest are only inflated when small enough that guessing wrong
|
||||
/// is cheap, since checking the signature means decompressing it.
|
||||
static func mayBeNestedArchive(name: String, uncompressedSize: UInt64,
|
||||
inflateLimit: UInt64 = 64 * 1024 * 1024) -> Bool {
|
||||
let ext = (name as NSString).pathExtension.lowercased()
|
||||
if archiveExtensions.contains(ext) { return true }
|
||||
if !ext.isEmpty && neverArchiveExtensions.contains(ext) { return false }
|
||||
return uncompressedSize >= 22 && uncompressedSize <= inflateLimit
|
||||
}
|
||||
|
||||
public init(url: URL) throws {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import XCTest
|
||||
@testable import KotejEngine
|
||||
|
||||
final class NestedArchiveTests: XCTestCase {
|
||||
private var scratch: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
scratch = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("kotej-nested-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: scratch)
|
||||
}
|
||||
|
||||
private func zip(_ files: [String: String], named name: String) throws -> URL {
|
||||
let content = scratch.appendingPathComponent("src-\(UUID().uuidString)")
|
||||
for (path, body) in files {
|
||||
let url = content.appendingPathComponent(path)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
let archive = scratch.appendingPathComponent(name)
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
process.arguments = ["-q", "-r", archive.path, "."]
|
||||
process.currentDirectoryURL = content
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return archive
|
||||
}
|
||||
|
||||
/// A ZIP renamed to something unfamiliar must still open as a folder.
|
||||
func testArchiveWithAnUnknownExtensionIsDetected() throws {
|
||||
let archive = try zip(["a/b.txt": "hola"], named: "bundle.customext")
|
||||
XCTAssertTrue(ZipReader.isArchive(archive), "detection must fall back to the signature")
|
||||
|
||||
let tree = try TreeScanner.scan(archive)
|
||||
XCTAssertTrue(tree.isDirectory)
|
||||
XCTAssertNotNil(tree.children.first { $0.name == "a" })
|
||||
}
|
||||
|
||||
func testPlainFileIsNotMistakenForAnArchive() throws {
|
||||
let file = scratch.appendingPathComponent("notes.unknownext")
|
||||
try "solo texto".write(to: file, atomically: true, encoding: .utf8)
|
||||
XCTAssertFalse(ZipReader.isArchive(file))
|
||||
}
|
||||
|
||||
/// EAR → JAR → renamed ZIP: every level has to keep expanding.
|
||||
func testThreeLevelsOfNestingExpand() throws {
|
||||
let inner = try zip(["deep/Service.class": "bytecode"], named: "inner.zipball")
|
||||
let middleSource = scratch.appendingPathComponent("mid-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: middleSource, withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: inner, to: middleSource.appendingPathComponent("inner.zipball"))
|
||||
try "manifest".write(to: middleSource.appendingPathComponent("MANIFEST.MF"),
|
||||
atomically: true, encoding: .utf8)
|
||||
|
||||
let middle = scratch.appendingPathComponent("lib.jar")
|
||||
var process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
process.arguments = ["-q", "-r", middle.path, "."]
|
||||
process.currentDirectoryURL = middleSource
|
||||
try process.run(); process.waitUntilExit()
|
||||
|
||||
let outerSource = scratch.appendingPathComponent("out-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: outerSource, withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: middle, to: outerSource.appendingPathComponent("lib.jar"))
|
||||
let outer = scratch.appendingPathComponent("app.ear")
|
||||
process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
process.arguments = ["-q", "-r", outer.path, "."]
|
||||
process.currentDirectoryURL = outerSource
|
||||
try process.run(); process.waitUntilExit()
|
||||
|
||||
let tree = try TreeScanner.scan(outer)
|
||||
let jar = try XCTUnwrap(tree.children.first { $0.name == "lib.jar" })
|
||||
XCTAssertTrue(jar.isDirectory, "JAR inside the EAR must open")
|
||||
let renamed = try XCTUnwrap(jar.children.first { $0.name == "inner.zipball" })
|
||||
XCTAssertTrue(renamed.isDirectory, "a renamed ZIP inside the JAR must open too")
|
||||
XCTAssertNotNil(renamed.children.first { $0.name == "deep" })
|
||||
}
|
||||
|
||||
func testEntriesThatCannotBeArchivesAreSkipped() {
|
||||
// Cheap guard: thousands of .class files in an EAR must not be inflated
|
||||
// just to check for a signature.
|
||||
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "Service.class", uncompressedSize: 5_000))
|
||||
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "beans.xml", uncompressedSize: 900))
|
||||
XCTAssertTrue(ZipReader.mayBeNestedArchive(name: "lib.jar", uncompressedSize: 5_000))
|
||||
XCTAssertTrue(ZipReader.mayBeNestedArchive(name: "mystery.bundle", uncompressedSize: 5_000))
|
||||
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "huge.bundle", uncompressedSize: 900_000_000),
|
||||
"too big to inflate on a guess")
|
||||
}
|
||||
|
||||
// MARK: Which comparison was applied
|
||||
|
||||
func testAutomaticReportsTheModeItChose() throws {
|
||||
let left = scratch.appendingPathComponent("l"), right = scratch.appendingPathComponent("r")
|
||||
for dir in [left, right] {
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
try #"{"a":1,"b":2}"#.write(to: left.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
|
||||
try "{\n \"b\":2,\n \"a\":1\n}".write(to: right.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
|
||||
try "uno".write(to: left.appendingPathComponent("n.txt"), atomically: true, encoding: .utf8)
|
||||
try "dos".write(to: right.appendingPathComponent("n.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right))
|
||||
func row(_ name: String) -> DiffNode? { diff.children.first { $0.name == name } }
|
||||
XCTAssertEqual(row("c.json")?.appliedMode, .semantic, "JSON should report semantic")
|
||||
XCTAssertEqual(row("n.txt")?.appliedMode, .text)
|
||||
}
|
||||
|
||||
func testForcedModeIsReportedAsSuch() throws {
|
||||
let left = scratch.appendingPathComponent("bl"), right = scratch.appendingPathComponent("br")
|
||||
for dir in [left, right] {
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
try #"{"a":1}"#.write(to: left.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
|
||||
try "{ \"a\": 1 }".write(to: right.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
|
||||
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right),
|
||||
options: .init(mode: .binary))
|
||||
XCTAssertEqual(diff.children.first?.appliedMode, .binary)
|
||||
XCTAssertEqual(diff.children.first?.status, .different)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user