Comparison engine: ZIP-aware trees, layered diffing, semantic JSON/XML
First slice of Cotejo, a Beyond Compare-style tool for macOS. The engine has no UI dependencies so the same code can back a CLI later. - ZipReader: own central-directory parser (no third-party zip), inflating via the system Compression framework and handling ZIP64. Because the directory already carries每 entry's CRC32 and size, two archives can be compared entry by entry without decompressing anything; bytes are only inflated when an entry is opened. - TreeScanner: folders and archives scan into one tree type, so JAR/EAR/WAR are browsed as directories — nested archives included (EAR -> JAR -> classes). - DirectoryComparer: layered so large trees stay fast. Cheap checks (size, CRC) can prove files identical but never that they differ, since JSON/XML with other bytes may still be equivalent; whatever they can't settle is read, or left pending under a size limit and resolved on demand. - ContentComparer: byte-for-byte, text with configurable tolerances, and semantic equality for JSON (key order irrelevant, array order significant, true != 1) and XML (attribute order and whitespace irrelevant, element order significant). 25 tests, including an EAR containing a JAR. 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:
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
.build/
|
||||
*.xcodeproj
|
||||
DerivedData/
|
||||
@@ -0,0 +1,15 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Cotejo",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
// The engine has no UI dependencies so it can also back a CLI later.
|
||||
.library(name: "CotejoEngine", targets: ["CotejoEngine"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "CotejoEngine"),
|
||||
.testTarget(name: "CotejoEngineTests", dependencies: ["CotejoEngine"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
# Cotejo
|
||||
|
||||
Comparador de ficheros, carpetas y **artefactos** (ZIP/JAR/EAR/WAR) para macOS.
|
||||
Ligero, sin dependencias externas y con el motor aislado del interfaz.
|
||||
|
||||
## Qué lo diferencia
|
||||
|
||||
- **Los archivos son carpetas.** Un JAR, un EAR o un WAR se abren y comparan como
|
||||
si fueran directorios, **incluido anidado** (EAR → JAR → clases).
|
||||
- **Comparación semántica.** Dos JSON o dos XML que significan lo mismo no se
|
||||
marcan como distintos aunque cambie el orden de claves, los atributos o el
|
||||
formato. Y puedes exigir byte a byte cuando lo necesites.
|
||||
- **Rápido por diseño.** La comparación es por niveles: el directorio central de
|
||||
un ZIP ya trae CRC32 y tamaño de cada entrada, así que **dos JAR idénticos se
|
||||
resuelven sin descomprimir nada**. Solo se lee el contenido que hace falta.
|
||||
|
||||
## Modos de comparación
|
||||
|
||||
| Modo | Cuándo |
|
||||
|---|---|
|
||||
| **Byte a byte** | Exactitud total; cualquier bit distinto cuenta |
|
||||
| **Texto** | Tolera fin de línea, espacios finales, mayúsculas o líneas en blanco (configurable) |
|
||||
| **Semántica** | JSON/XML equivalentes canónicamente, aunque los bytes difieran |
|
||||
|
||||
Por defecto cada fichero usa el modo que le corresponde según su tipo
|
||||
(JSON/XML → semántica, texto → texto, resto → binaria).
|
||||
|
||||
## Estado
|
||||
|
||||
- ✅ **Motor** (`CotejoEngine`): lector ZIP propio, escaneo de árboles, archivos
|
||||
anidados, comparación por niveles y comparadores binario/texto/JSON/XML.
|
||||
- ⏳ App SwiftUI (vista side-by-side) y extensión de Finder.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```bash
|
||||
swift test
|
||||
```
|
||||
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
|
||||
/// How two files should be considered equal.
|
||||
public enum ComparisonMode: String, CaseIterable, Sendable {
|
||||
/// Byte for byte. Always available and always exact.
|
||||
case binary
|
||||
/// Text, with configurable tolerance for whitespace and line endings.
|
||||
case text
|
||||
/// Structure-aware: two JSON/XML documents are equal when they mean the same
|
||||
/// thing, regardless of key order, insignificant whitespace or formatting.
|
||||
case semantic
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .binary: return "Byte a byte"
|
||||
case .text: return "Texto"
|
||||
case .semantic: return "Semántica (JSON/XML)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tolerances applied in text mode.
|
||||
public struct TextOptions: Sendable, Equatable {
|
||||
public var ignoreTrailingWhitespace: Bool
|
||||
public var ignoreAllWhitespace: Bool
|
||||
public var ignoreCase: Bool
|
||||
public var ignoreBlankLines: Bool
|
||||
/// Treat CRLF and LF as the same, so files that crossed platforms still match.
|
||||
public var normaliseLineEndings: Bool
|
||||
|
||||
public init(ignoreTrailingWhitespace: Bool = true,
|
||||
ignoreAllWhitespace: Bool = false,
|
||||
ignoreCase: Bool = false,
|
||||
ignoreBlankLines: Bool = false,
|
||||
normaliseLineEndings: Bool = true) {
|
||||
self.ignoreTrailingWhitespace = ignoreTrailingWhitespace
|
||||
self.ignoreAllWhitespace = ignoreAllWhitespace
|
||||
self.ignoreCase = ignoreCase
|
||||
self.ignoreBlankLines = ignoreBlankLines
|
||||
self.normaliseLineEndings = normaliseLineEndings
|
||||
}
|
||||
|
||||
public static let strict = TextOptions(ignoreTrailingWhitespace: false, normaliseLineEndings: false)
|
||||
}
|
||||
|
||||
/// The detected nature of a file, which decides what the default comparison is.
|
||||
public enum ContentKind: String, Sendable {
|
||||
case json, xml, text, binary
|
||||
|
||||
/// Guesses from the extension first (cheap) and falls back to sniffing.
|
||||
public static func detect(path: String, data: @autoclosure () -> Data?) -> ContentKind {
|
||||
switch (path as NSString).pathExtension.lowercased() {
|
||||
case "json": return .json
|
||||
case "xml", "xsd", "xsl", "xslt", "wsdl", "pom", "svg", "plist", "storyboard": return .xml
|
||||
case "txt", "md", "yml", "yaml", "properties", "csv", "log", "java", "swift", "go", "js", "ts", "sh", "sql", "html", "css":
|
||||
return .text
|
||||
default: break
|
||||
}
|
||||
guard let bytes = data() else { return .binary }
|
||||
return sniff(bytes)
|
||||
}
|
||||
|
||||
/// A NUL byte in the first block is the classic "this is binary" signal.
|
||||
static func sniff(_ data: Data) -> ContentKind {
|
||||
let sample = data.prefix(8_000)
|
||||
if sample.contains(0) { return .binary }
|
||||
guard let text = String(data: sample, encoding: .utf8) else { return .binary }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") { return .json }
|
||||
if trimmed.hasPrefix("<") { return .xml }
|
||||
return .text
|
||||
}
|
||||
|
||||
/// The comparison that makes sense by default for this kind.
|
||||
public var defaultMode: ComparisonMode {
|
||||
switch self {
|
||||
case .json, .xml: return .semantic
|
||||
case .text: return .text
|
||||
case .binary: return .binary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether two blobs are equal under a given mode.
|
||||
public enum ContentComparer {
|
||||
public enum Result: Equatable {
|
||||
case equal
|
||||
/// Bytes differ but the documents mean the same thing (JSON/XML only).
|
||||
case equivalent
|
||||
case different
|
||||
/// Semantic comparison was asked for but the content doesn't parse.
|
||||
case unsupported(String)
|
||||
}
|
||||
|
||||
public static func compare(_ left: Data, _ right: Data,
|
||||
mode: ComparisonMode,
|
||||
kind: ContentKind,
|
||||
textOptions: TextOptions = TextOptions()) -> Result {
|
||||
if left == right { return .equal }
|
||||
|
||||
switch mode {
|
||||
case .binary:
|
||||
return .different
|
||||
|
||||
case .text:
|
||||
guard let leftText = String(data: left, encoding: .utf8),
|
||||
let rightText = String(data: right, encoding: .utf8) else { return .different }
|
||||
return normalise(leftText, textOptions) == normalise(rightText, textOptions) ? .equivalent : .different
|
||||
|
||||
case .semantic:
|
||||
switch kind {
|
||||
case .json:
|
||||
return compareJSON(left, right)
|
||||
case .xml:
|
||||
return compareXML(left, right)
|
||||
case .text:
|
||||
return compare(left, right, mode: .text, kind: .text, textOptions: textOptions)
|
||||
case .binary:
|
||||
return .different
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
|
||||
static func normalise(_ text: String, _ options: TextOptions) -> String {
|
||||
var value = text
|
||||
if options.normaliseLineEndings {
|
||||
value = value.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||
}
|
||||
if options.ignoreCase { value = value.lowercased() }
|
||||
|
||||
var lines = value.components(separatedBy: "\n")
|
||||
if options.ignoreAllWhitespace {
|
||||
lines = lines.map { $0.filter { !$0.isWhitespace } }
|
||||
} else if options.ignoreTrailingWhitespace {
|
||||
lines = lines.map { line in
|
||||
var trimmed = line
|
||||
while let last = trimmed.last, last.isWhitespace { trimmed.removeLast() }
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
if options.ignoreBlankLines {
|
||||
lines = lines.filter { !$0.isEmpty }
|
||||
} else {
|
||||
// A missing final newline shouldn't count as a difference on its own.
|
||||
while lines.last == "" { lines.removeLast() }
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
static func compareJSON(_ left: Data, _ right: Data) -> Result {
|
||||
guard let leftValue = try? JSONSerialization.jsonObject(with: left, options: [.fragmentsAllowed]) else {
|
||||
return .unsupported("El fichero de la izquierda no es JSON válido")
|
||||
}
|
||||
guard let rightValue = try? JSONSerialization.jsonObject(with: right, options: [.fragmentsAllowed]) else {
|
||||
return .unsupported("El fichero de la derecha no es JSON válido")
|
||||
}
|
||||
return jsonEqual(leftValue, rightValue) ? .equivalent : .different
|
||||
}
|
||||
|
||||
/// Structural equality: object key order and formatting are irrelevant, but
|
||||
/// array order is significant (it carries meaning in JSON).
|
||||
static func jsonEqual(_ left: Any, _ right: Any) -> Bool {
|
||||
switch (left, right) {
|
||||
case let (l as [String: Any], r as [String: Any]):
|
||||
guard l.count == r.count else { return false }
|
||||
for (key, leftChild) in l {
|
||||
guard let rightChild = r[key], jsonEqual(leftChild, rightChild) else { return false }
|
||||
}
|
||||
return true
|
||||
|
||||
case let (l as [Any], r as [Any]):
|
||||
guard l.count == r.count else { return false }
|
||||
return zip(l, r).allSatisfy { jsonEqual($0, $1) }
|
||||
|
||||
case let (l as NSNumber, r as NSNumber):
|
||||
// Distinguish booleans from 0/1, which NSNumber otherwise conflates.
|
||||
let leftIsBool = CFGetTypeID(l) == CFBooleanGetTypeID()
|
||||
let rightIsBool = CFGetTypeID(r) == CFBooleanGetTypeID()
|
||||
guard leftIsBool == rightIsBool else { return false }
|
||||
return l == r
|
||||
|
||||
case let (l as String, r as String):
|
||||
return l == r
|
||||
|
||||
case (is NSNull, is NSNull):
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: XML
|
||||
|
||||
static func compareXML(_ left: Data, _ right: Data) -> Result {
|
||||
// Tidying normalises entities and insignificant whitespace for us.
|
||||
let options: XMLNode.Options = [.documentTidyXML]
|
||||
guard let leftDocument = try? XMLDocument(data: left, options: options) else {
|
||||
return .unsupported("El fichero de la izquierda no es XML válido")
|
||||
}
|
||||
guard let rightDocument = try? XMLDocument(data: right, options: options) else {
|
||||
return .unsupported("El fichero de la derecha no es XML válido")
|
||||
}
|
||||
guard let leftRoot = leftDocument.rootElement(), let rightRoot = rightDocument.rootElement() else {
|
||||
return .different
|
||||
}
|
||||
return xmlEqual(leftRoot, rightRoot) ? .equivalent : .different
|
||||
}
|
||||
|
||||
/// Canonical comparison: attribute order and insignificant whitespace don't
|
||||
/// matter, but element order does (it is significant in XML).
|
||||
static func xmlEqual(_ left: XMLElement, _ right: XMLElement) -> Bool {
|
||||
guard left.localName == right.localName, left.uri == right.uri else { return false }
|
||||
|
||||
let attributesOf = { (element: XMLElement) -> [String: String] in
|
||||
var map: [String: String] = [:]
|
||||
for attribute in element.attributes ?? [] {
|
||||
// Namespace declarations are structure, not content.
|
||||
guard let name = attribute.name, !name.hasPrefix("xmlns") else { continue }
|
||||
map[name] = attribute.stringValue ?? ""
|
||||
}
|
||||
return map
|
||||
}
|
||||
guard attributesOf(left) == attributesOf(right) else { return false }
|
||||
|
||||
let childrenOf = { (element: XMLElement) -> [XMLElement] in
|
||||
(element.children ?? []).compactMap { $0 as? XMLElement }
|
||||
}
|
||||
let leftChildren = childrenOf(left), rightChildren = childrenOf(right)
|
||||
|
||||
// A leaf's text is its value; whitespace around it is formatting.
|
||||
if leftChildren.isEmpty && rightChildren.isEmpty {
|
||||
let textOf = { (element: XMLElement) in
|
||||
(element.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return textOf(left) == textOf(right)
|
||||
}
|
||||
|
||||
guard leftChildren.count == rightChildren.count else { return false }
|
||||
return zip(leftChildren, rightChildren).allSatisfy { xmlEqual($0, $1) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
|
||||
/// Outcome of comparing one pair of nodes.
|
||||
public enum DiffStatus: Equatable, Sendable {
|
||||
/// Byte-identical (proved by size + CRC or by content).
|
||||
case identical
|
||||
/// Bytes differ but the documents mean the same under the chosen rules.
|
||||
case equivalent
|
||||
case different
|
||||
case onlyLeft
|
||||
case onlyRight
|
||||
/// Cheap checks couldn't prove equality; content hasn't been read yet.
|
||||
case pending
|
||||
|
||||
public var isDifference: Bool {
|
||||
switch self {
|
||||
case .identical, .equivalent: return false
|
||||
case .different, .onlyLeft, .onlyRight, .pending: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row in the side-by-side tree: the same path on both sides, plus a verdict.
|
||||
public final class DiffNode: @unchecked Sendable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
public let isDirectory: Bool
|
||||
public let isArchive: Bool
|
||||
public let left: Node?
|
||||
public let right: Node?
|
||||
public internal(set) var status: DiffStatus
|
||||
public internal(set) var children: [DiffNode]
|
||||
|
||||
init(name: String, path: String, isDirectory: Bool, isArchive: Bool,
|
||||
left: Node?, right: Node?, status: DiffStatus, children: [DiffNode] = []) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.isDirectory = isDirectory
|
||||
self.isArchive = isArchive
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.status = status
|
||||
self.children = children
|
||||
}
|
||||
|
||||
/// Counts, for the summary bar.
|
||||
public struct Totals: Equatable, Sendable {
|
||||
public var identical = 0, different = 0, onlyLeft = 0, onlyRight = 0, pending = 0
|
||||
public var differences: Int { different + onlyLeft + onlyRight + pending }
|
||||
}
|
||||
|
||||
public func totals() -> Totals {
|
||||
var totals = Totals()
|
||||
func walk(_ node: DiffNode) {
|
||||
if node.isDirectory {
|
||||
node.children.forEach(walk)
|
||||
return
|
||||
}
|
||||
switch node.status {
|
||||
case .identical, .equivalent: totals.identical += 1
|
||||
case .different: totals.different += 1
|
||||
case .onlyLeft: totals.onlyLeft += 1
|
||||
case .onlyRight: totals.onlyRight += 1
|
||||
case .pending: totals.pending += 1
|
||||
}
|
||||
}
|
||||
walk(self)
|
||||
return totals
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs two trees and decides what differs.
|
||||
///
|
||||
/// The comparison is layered so big trees stay fast: sizes and (for archive
|
||||
/// entries) CRC32s prove equality without reading a byte, and only what those
|
||||
/// checks can't settle is read. Note the asymmetry — cheap checks can prove two
|
||||
/// files *identical*, but never that they *differ*, because a JSON or XML with
|
||||
/// different bytes may still be semantically equal.
|
||||
public enum DirectoryComparer {
|
||||
public struct Options: Sendable {
|
||||
/// Read file contents during the scan. When false the undecided pairs are
|
||||
/// left `.pending` and resolved on demand, which keeps huge trees snappy.
|
||||
public var resolveContentEagerly: Bool
|
||||
/// Cap on eagerly-read files; larger ones stay `.pending`.
|
||||
public var eagerSizeLimit: UInt64
|
||||
public var mode: ComparisonMode?
|
||||
public var textOptions: TextOptions
|
||||
|
||||
public init(resolveContentEagerly: Bool = true,
|
||||
eagerSizeLimit: UInt64 = 4 * 1024 * 1024,
|
||||
mode: ComparisonMode? = nil,
|
||||
textOptions: TextOptions = TextOptions()) {
|
||||
self.resolveContentEagerly = resolveContentEagerly
|
||||
self.eagerSizeLimit = eagerSizeLimit
|
||||
self.mode = mode
|
||||
self.textOptions = textOptions
|
||||
}
|
||||
}
|
||||
|
||||
public static func compare(left: Node, right: Node, options: Options = Options()) -> DiffNode {
|
||||
let root = pair(left: left, right: right, name: left.name, path: "", options: options)
|
||||
return root
|
||||
}
|
||||
|
||||
private static func pair(left: Node?, right: Node?, name: String, path: String,
|
||||
options: Options) -> DiffNode {
|
||||
// Present on one side only: the whole subtree is a difference.
|
||||
guard let left, let right else {
|
||||
let present = left ?? right!
|
||||
let node = DiffNode(name: name, path: path, isDirectory: present.isDirectory,
|
||||
isArchive: present.isArchive, left: left, right: right,
|
||||
status: left == nil ? .onlyRight : .onlyLeft)
|
||||
node.children = present.children.map { child in
|
||||
pair(left: left == nil ? nil : child, right: left == nil ? child : nil,
|
||||
name: child.name, path: child.path, options: options)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
if left.isDirectory && right.isDirectory {
|
||||
let node = DiffNode(name: name, path: path, isDirectory: true,
|
||||
isArchive: left.isArchive || right.isArchive,
|
||||
left: left, right: right, status: .identical)
|
||||
node.children = pairChildren(left: left, right: right, options: options)
|
||||
// A folder differs when anything inside it does.
|
||||
node.status = node.children.contains { $0.status.isDifference } ? .different : .identical
|
||||
return node
|
||||
}
|
||||
|
||||
// A folder on one side and a file on the other is a difference in itself.
|
||||
if left.isDirectory != right.isDirectory {
|
||||
return DiffNode(name: name, path: path, isDirectory: left.isDirectory,
|
||||
isArchive: left.isArchive || right.isArchive,
|
||||
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))
|
||||
}
|
||||
|
||||
private static func pairChildren(left: Node, right: Node, options: Options) -> [DiffNode] {
|
||||
var rightByName = Dictionary(grouping: right.children, by: \.name).mapValues { $0[0] }
|
||||
var rows: [DiffNode] = []
|
||||
|
||||
for leftChild in left.children {
|
||||
let match = rightByName.removeValue(forKey: leftChild.name)
|
||||
rows.append(pair(left: leftChild, right: match, name: leftChild.name,
|
||||
path: leftChild.path, options: options))
|
||||
}
|
||||
for orphan in rightByName.values {
|
||||
rows.append(pair(left: nil, right: orphan, name: orphan.name,
|
||||
path: orphan.path, options: options))
|
||||
}
|
||||
|
||||
rows.sort { a, b in
|
||||
if a.isDirectory != b.isDirectory { return a.isDirectory }
|
||||
return a.name.localizedStandardCompare(b.name) == .orderedAscending
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
|
||||
if leftCRC == rightCRC && left.size == right.size { return .identical }
|
||||
// Different CRCs still might be semantically equal, so fall through.
|
||||
} else if left.size == right.size, left.size == 0 {
|
||||
return .identical
|
||||
}
|
||||
|
||||
// Level 2 — read and compare, unless we're staying lazy.
|
||||
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
|
||||
guard mustRead else { return .pending }
|
||||
return resolve(left: left, right: right, options: options)
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
let kind = ContentKind.detect(path: left.name, data: leftData)
|
||||
let mode = options.mode ?? kind.defaultMode
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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.
|
||||
if expandNestedArchives, ZipReader.archiveExtensions.contains((name as NSString).pathExtension.lowercased()) {
|
||||
if let nested = try? extractNested(reader: reader, entry: entry, name: name),
|
||||
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("cotejo-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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
/// Reads ZIP containers (which includes JAR, EAR and WAR) without unpacking them.
|
||||
///
|
||||
/// The central directory already records every entry's CRC32 and sizes, so two
|
||||
/// archives can be compared entry by entry *without decompressing anything*.
|
||||
/// Bytes are only inflated when a specific entry is actually opened — that's what
|
||||
/// keeps comparing large EARs fast.
|
||||
public struct ZipReader {
|
||||
/// One entry in the archive's central directory.
|
||||
public struct Entry {
|
||||
public let path: String
|
||||
public let crc32: UInt32
|
||||
public let compressedSize: UInt64
|
||||
public let uncompressedSize: UInt64
|
||||
public let compressionMethod: UInt16
|
||||
public let localHeaderOffset: UInt64
|
||||
public let isDirectory: Bool
|
||||
}
|
||||
|
||||
public enum ZipError: Error, LocalizedError {
|
||||
case notAZip
|
||||
case unsupported(String)
|
||||
case corrupt(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAZip: return "Not a ZIP archive (no end-of-central-directory record)."
|
||||
case .unsupported(let what): return "Unsupported ZIP feature: \(what)."
|
||||
case .corrupt(let why): return "Corrupt ZIP: \(why)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let url: URL
|
||||
public let entries: [Entry]
|
||||
|
||||
/// File extensions handled as archives, i.e. browsable like directories.
|
||||
public static let archiveExtensions: Set<String> = ["zip", "jar", "ear", "war", "aar", "ipa"]
|
||||
|
||||
public static func isArchive(_ url: URL) -> Bool {
|
||||
archiveExtensions.contains(url.pathExtension.lowercased())
|
||||
}
|
||||
|
||||
public init(url: URL) throws {
|
||||
self.url = url
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
self.entries = try Self.readCentralDirectory(data)
|
||||
}
|
||||
|
||||
// MARK: Central directory
|
||||
|
||||
private static let endOfCentralDirectorySignature: UInt32 = 0x06054b50
|
||||
private static let zip64LocatorSignature: UInt32 = 0x07064b50
|
||||
private static let zip64EndSignature: UInt32 = 0x06064b50
|
||||
private static let centralFileHeaderSignature: UInt32 = 0x02014b50
|
||||
|
||||
private static func readCentralDirectory(_ data: Data) throws -> [Entry] {
|
||||
guard let eocdOffset = findEndOfCentralDirectory(data) else { throw ZipError.notAZip }
|
||||
|
||||
var entryCount = Int(data.u16(eocdOffset + 10))
|
||||
var directoryOffset = Int(data.u32(eocdOffset + 16))
|
||||
let directorySize = Int(data.u32(eocdOffset + 12))
|
||||
|
||||
// ZIP64: the 32-bit fields saturate, and the real values live in the
|
||||
// ZIP64 record that the locator points at.
|
||||
if directoryOffset == 0xFFFF_FFFF || entryCount == 0xFFFF || directorySize == 0xFFFF_FFFF {
|
||||
guard let locator = findZip64Locator(data, before: eocdOffset) else {
|
||||
throw ZipError.unsupported("ZIP64 without locator")
|
||||
}
|
||||
let zip64End = Int(data.u64(locator + 8))
|
||||
guard zip64End + 56 <= data.count, data.u32(zip64End) == zip64EndSignature else {
|
||||
throw ZipError.corrupt("bad ZIP64 end record")
|
||||
}
|
||||
entryCount = Int(data.u64(zip64End + 32))
|
||||
directoryOffset = Int(data.u64(zip64End + 48))
|
||||
}
|
||||
|
||||
var entries: [Entry] = []
|
||||
entries.reserveCapacity(entryCount)
|
||||
var offset = directoryOffset
|
||||
|
||||
for _ in 0..<entryCount {
|
||||
guard offset + 46 <= data.count, data.u32(offset) == centralFileHeaderSignature else {
|
||||
throw ZipError.corrupt("bad central directory header")
|
||||
}
|
||||
let method = data.u16(offset + 10)
|
||||
let crc = data.u32(offset + 16)
|
||||
var compressed = UInt64(data.u32(offset + 20))
|
||||
var uncompressed = UInt64(data.u32(offset + 24))
|
||||
let nameLength = Int(data.u16(offset + 28))
|
||||
let extraLength = Int(data.u16(offset + 30))
|
||||
let commentLength = Int(data.u16(offset + 32))
|
||||
var localOffset = UInt64(data.u32(offset + 42))
|
||||
|
||||
let nameStart = offset + 46
|
||||
guard nameStart + nameLength <= data.count else { throw ZipError.corrupt("truncated name") }
|
||||
let name = String(decoding: data[nameStart..<nameStart + nameLength], as: UTF8.self)
|
||||
|
||||
// Oversized values are carried in the ZIP64 extra field.
|
||||
if uncompressed == 0xFFFF_FFFF || compressed == 0xFFFF_FFFF || localOffset == 0xFFFF_FFFF {
|
||||
let extraStart = nameStart + nameLength
|
||||
readZip64Extra(data, start: extraStart, length: extraLength,
|
||||
uncompressed: &uncompressed, compressed: &compressed, localOffset: &localOffset)
|
||||
}
|
||||
|
||||
entries.append(Entry(path: normalise(name),
|
||||
crc32: crc,
|
||||
compressedSize: compressed,
|
||||
uncompressedSize: uncompressed,
|
||||
compressionMethod: method,
|
||||
localHeaderOffset: localOffset,
|
||||
isDirectory: name.hasSuffix("/")))
|
||||
offset = nameStart + nameLength + extraLength + commentLength
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Entries may use either separator and some tools prefix "./".
|
||||
private static func normalise(_ name: String) -> String {
|
||||
var path = name.replacingOccurrences(of: "\\", with: "/")
|
||||
while path.hasPrefix("./") { path.removeFirst(2) }
|
||||
while path.hasSuffix("/") { path.removeLast() }
|
||||
return path
|
||||
}
|
||||
|
||||
private static func findEndOfCentralDirectory(_ data: Data) -> Int? {
|
||||
// The record is at the end but may be followed by a comment (max 64 KiB).
|
||||
let minimumSize = 22
|
||||
guard data.count >= minimumSize else { return nil }
|
||||
let searchLimit = max(0, data.count - minimumSize - 0xFFFF)
|
||||
var offset = data.count - minimumSize
|
||||
while offset >= searchLimit {
|
||||
if data.u32(offset) == endOfCentralDirectorySignature { return offset }
|
||||
offset -= 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func findZip64Locator(_ data: Data, before eocd: Int) -> Int? {
|
||||
let locator = eocd - 20
|
||||
guard locator >= 0, data.u32(locator) == zip64LocatorSignature else { return nil }
|
||||
return locator
|
||||
}
|
||||
|
||||
private static func readZip64Extra(_ data: Data, start: Int, length: Int,
|
||||
uncompressed: inout UInt64, compressed: inout UInt64,
|
||||
localOffset: inout UInt64) {
|
||||
var cursor = start
|
||||
let end = min(start + length, data.count)
|
||||
while cursor + 4 <= end {
|
||||
let headerID = data.u16(cursor)
|
||||
let size = Int(data.u16(cursor + 2))
|
||||
let body = cursor + 4
|
||||
if headerID == 0x0001 {
|
||||
var field = body
|
||||
if uncompressed == 0xFFFF_FFFF, field + 8 <= end { uncompressed = data.u64(field); field += 8 }
|
||||
if compressed == 0xFFFF_FFFF, field + 8 <= end { compressed = data.u64(field); field += 8 }
|
||||
if localOffset == 0xFFFF_FFFF, field + 8 <= end { localOffset = data.u64(field) }
|
||||
return
|
||||
}
|
||||
cursor = body + size
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reading entry bytes
|
||||
|
||||
/// Inflates one entry. Only called when the bytes are genuinely needed —
|
||||
/// comparisons normally stop at the CRC32 from the central directory.
|
||||
public func data(for entry: Entry) throws -> Data {
|
||||
let archive = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
let headerOffset = Int(entry.localHeaderOffset)
|
||||
guard headerOffset + 30 <= archive.count, archive.u32(headerOffset) == 0x04034b50 else {
|
||||
throw ZipError.corrupt("bad local header for \(entry.path)")
|
||||
}
|
||||
let nameLength = Int(archive.u16(headerOffset + 26))
|
||||
let extraLength = Int(archive.u16(headerOffset + 28))
|
||||
let start = headerOffset + 30 + nameLength + extraLength
|
||||
let end = start + Int(entry.compressedSize)
|
||||
guard end <= archive.count else { throw ZipError.corrupt("truncated data for \(entry.path)") }
|
||||
let payload = archive.subdata(in: start..<end)
|
||||
|
||||
switch entry.compressionMethod {
|
||||
case 0:
|
||||
return payload
|
||||
case 8:
|
||||
return try inflate(payload, expectedSize: Int(entry.uncompressedSize))
|
||||
default:
|
||||
throw ZipError.unsupported("compression method \(entry.compressionMethod)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw DEFLATE via the system Compression framework (no third-party zlib).
|
||||
private func inflate(_ payload: Data, expectedSize: Int) throws -> Data {
|
||||
guard expectedSize > 0 else { return Data() }
|
||||
var output = Data(count: expectedSize)
|
||||
|
||||
let written: Int = try output.withUnsafeMutableBytes { destination in
|
||||
try payload.withUnsafeBytes { source in
|
||||
guard let destinationBase = destination.bindMemory(to: UInt8.self).baseAddress,
|
||||
let sourceBase = source.bindMemory(to: UInt8.self).baseAddress else {
|
||||
throw ZipError.corrupt("empty buffer")
|
||||
}
|
||||
return compression_decode_buffer(destinationBase, expectedSize,
|
||||
sourceBase, payload.count,
|
||||
nil, COMPRESSION_ZLIB)
|
||||
}
|
||||
}
|
||||
guard written == expectedSize else {
|
||||
throw ZipError.corrupt("inflate produced \(written) of \(expectedSize) bytes")
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Little-endian reads
|
||||
|
||||
private extension Data {
|
||||
func u16(_ offset: Int) -> UInt16 {
|
||||
guard offset + 2 <= count else { return 0 }
|
||||
return UInt16(self[startIndex + offset]) | UInt16(self[startIndex + offset + 1]) << 8
|
||||
}
|
||||
|
||||
func u32(_ offset: Int) -> UInt32 {
|
||||
guard offset + 4 <= count else { return 0 }
|
||||
var value: UInt32 = 0
|
||||
for i in (0..<4).reversed() { value = value << 8 | UInt32(self[startIndex + offset + i]) }
|
||||
return value
|
||||
}
|
||||
|
||||
func u64(_ offset: Int) -> UInt64 {
|
||||
guard offset + 8 <= count else { return 0 }
|
||||
var value: UInt64 = 0
|
||||
for i in (0..<8).reversed() { value = value << 8 | UInt64(self[startIndex + offset + i]) }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import XCTest
|
||||
@testable import CotejoEngine
|
||||
|
||||
final class ContentComparerTests: XCTestCase {
|
||||
private func data(_ text: String) -> Data { Data(text.utf8) }
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
func testJSONIgnoresKeyOrderAndFormatting() {
|
||||
let left = data(#"{"a":1,"b":{"c":true,"d":null}}"#)
|
||||
let right = data("""
|
||||
{
|
||||
"b": { "d": null, "c": true },
|
||||
"a": 1
|
||||
}
|
||||
""")
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .semantic, kind: .json), .equivalent)
|
||||
// The same bytes under byte-for-byte are simply different.
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .binary, kind: .json), .different)
|
||||
}
|
||||
|
||||
func testJSONArrayOrderStillMatters() {
|
||||
let left = data(#"{"items":[1,2,3]}"#)
|
||||
let right = data(#"{"items":[3,2,1]}"#)
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .semantic, kind: .json), .different)
|
||||
}
|
||||
|
||||
func testJSONDistinguishesBooleanFromNumber() {
|
||||
let left = data(#"{"flag":true}"#)
|
||||
let right = data(#"{"flag":1}"#)
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .semantic, kind: .json), .different,
|
||||
"true must not equal 1")
|
||||
}
|
||||
|
||||
func testInvalidJSONIsReportedNotSilentlyEqual() {
|
||||
let result = ContentComparer.compare(data("{not json"), data("{}"), mode: .semantic, kind: .json)
|
||||
guard case .unsupported = result else {
|
||||
return XCTFail("expected .unsupported, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: XML
|
||||
|
||||
func testXMLIgnoresAttributeOrderAndWhitespace() {
|
||||
let left = data(#"<root><item id="1" name="uno">valor</item></root>"#)
|
||||
let right = data("""
|
||||
<root>
|
||||
<item name="uno" id="1">
|
||||
valor
|
||||
</item>
|
||||
</root>
|
||||
""")
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .semantic, kind: .xml), .equivalent)
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .binary, kind: .xml), .different)
|
||||
}
|
||||
|
||||
func testXMLDetectsRealDifferences() {
|
||||
let left = data(#"<root><item id="1">uno</item></root>"#)
|
||||
let cases = [
|
||||
#"<root><item id="2">uno</item></root>"#, // attribute value
|
||||
#"<root><item id="1">dos</item></root>"#, // text
|
||||
#"<root><otro id="1">uno</otro></root>"#, // element name
|
||||
#"<root><item id="1">uno</item><item id="2">dos</item></root>"#, // extra child
|
||||
]
|
||||
for xml in cases {
|
||||
XCTAssertEqual(ContentComparer.compare(left, data(xml), mode: .semantic, kind: .xml), .different, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func testXMLElementOrderIsSignificant() {
|
||||
let left = data("<root><a/><b/></root>")
|
||||
let right = data("<root><b/><a/></root>")
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .semantic, kind: .xml), .different)
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
|
||||
func testTextToleratesLineEndingsAndTrailingSpaces() {
|
||||
let left = data("uno\ndos\n")
|
||||
let right = data("uno \r\ndos\r\n")
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .text, kind: .text), .equivalent)
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .text, kind: .text,
|
||||
textOptions: .strict), .different)
|
||||
}
|
||||
|
||||
func testTextCaseAndBlankLineOptions() {
|
||||
let options = TextOptions(ignoreCase: true, ignoreBlankLines: true)
|
||||
let left = data("Hola\n\nMundo")
|
||||
let right = data("hola\nmundo")
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .text, kind: .text, textOptions: options),
|
||||
.equivalent)
|
||||
XCTAssertEqual(ContentComparer.compare(left, right, mode: .text, kind: .text), .different)
|
||||
}
|
||||
|
||||
func testIdenticalBytesAreEqualInEveryMode() {
|
||||
let blob = data("misma cosa")
|
||||
for mode in ComparisonMode.allCases {
|
||||
XCTAssertEqual(ContentComparer.compare(blob, blob, mode: mode, kind: .text), .equal, mode.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Detection
|
||||
|
||||
func testDetectsKindFromExtensionThenContent() {
|
||||
XCTAssertEqual(ContentKind.detect(path: "a/b/config.json", data: nil), .json)
|
||||
XCTAssertEqual(ContentKind.detect(path: "pom.xml", data: nil), .xml)
|
||||
XCTAssertEqual(ContentKind.detect(path: "notes.txt", data: nil), .text)
|
||||
|
||||
// Unknown extension falls back to sniffing.
|
||||
XCTAssertEqual(ContentKind.detect(path: "payload", data: self.data(#"{"a":1}"#)), .json)
|
||||
XCTAssertEqual(ContentKind.detect(path: "payload", data: self.data("<root/>")), .xml)
|
||||
XCTAssertEqual(ContentKind.detect(path: "payload", data: self.data("hola")), .text)
|
||||
XCTAssertEqual(ContentKind.detect(path: "payload", data: Data([0x00, 0x01, 0x02])), .binary)
|
||||
}
|
||||
|
||||
func testDefaultModePerKind() {
|
||||
XCTAssertEqual(ContentKind.json.defaultMode, .semantic)
|
||||
XCTAssertEqual(ContentKind.xml.defaultMode, .semantic)
|
||||
XCTAssertEqual(ContentKind.text.defaultMode, .text)
|
||||
XCTAssertEqual(ContentKind.binary.defaultMode, .binary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import XCTest
|
||||
@testable import CotejoEngine
|
||||
|
||||
final class DirectoryComparerTests: XCTestCase {
|
||||
private var scratch: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
scratch = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("cotejo-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: scratch)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func makeTree(_ name: String, _ files: [String: String]) throws -> URL {
|
||||
let root = scratch.appendingPathComponent(name)
|
||||
for (path, body) in files {
|
||||
let url = root.appendingPathComponent(path)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private func makeArchive(_ name: String, _ files: [String: String]) throws -> URL {
|
||||
let content = try makeTree("src-\(name)", files)
|
||||
let archive = scratch.appendingPathComponent(name)
|
||||
let zip = Process()
|
||||
zip.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
zip.arguments = ["-q", "-r", archive.path, "."]
|
||||
zip.currentDirectoryURL = content
|
||||
try zip.run()
|
||||
zip.waitUntilExit()
|
||||
return archive
|
||||
}
|
||||
|
||||
private func row(_ node: DiffNode, _ path: String) -> DiffNode? {
|
||||
if node.path == path { return node }
|
||||
for child in node.children {
|
||||
if let found = row(child, path) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Folders
|
||||
|
||||
func testClassifiesEachKindOfDifference() throws {
|
||||
let left = try makeTree("left", [
|
||||
"same.txt": "igual",
|
||||
"changed.txt": "antes",
|
||||
"only-left.txt": "solo A",
|
||||
"sub/nested.txt": "profundo",
|
||||
])
|
||||
let right = try makeTree("right", [
|
||||
"same.txt": "igual",
|
||||
"changed.txt": "después",
|
||||
"only-right.txt": "solo B",
|
||||
"sub/nested.txt": "profundo",
|
||||
])
|
||||
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right))
|
||||
|
||||
XCTAssertEqual(row(diff, "same.txt")?.status, .identical)
|
||||
XCTAssertEqual(row(diff, "changed.txt")?.status, .different)
|
||||
XCTAssertEqual(row(diff, "only-left.txt")?.status, .onlyLeft)
|
||||
XCTAssertEqual(row(diff, "only-right.txt")?.status, .onlyRight)
|
||||
XCTAssertEqual(row(diff, "sub/nested.txt")?.status, .identical)
|
||||
// A folder whose contents all match must not be flagged.
|
||||
XCTAssertEqual(row(diff, "sub")?.status, .identical)
|
||||
|
||||
let totals = diff.totals()
|
||||
XCTAssertEqual(totals.identical, 2)
|
||||
XCTAssertEqual(totals.different, 1)
|
||||
XCTAssertEqual(totals.onlyLeft, 1)
|
||||
XCTAssertEqual(totals.onlyRight, 1)
|
||||
}
|
||||
|
||||
func testFolderIsMarkedWhenSomethingInsideDiffers() throws {
|
||||
let left = try makeTree("l", ["deep/a/b/file.txt": "uno"])
|
||||
let right = try makeTree("r", ["deep/a/b/file.txt": "dos"])
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right))
|
||||
XCTAssertEqual(row(diff, "deep")?.status, .different, "difference must bubble up")
|
||||
XCTAssertEqual(row(diff, "deep/a/b/file.txt")?.status, .different)
|
||||
}
|
||||
|
||||
// MARK: Semantics
|
||||
|
||||
func testSemanticallyEqualJSONAndXMLAreNotFlagged() throws {
|
||||
let left = try makeTree("sl", [
|
||||
"config.json": #"{"a":1,"b":2}"#,
|
||||
"beans.xml": #"<beans><bean id="a" class="X"/></beans>"#,
|
||||
])
|
||||
let right = try makeTree("sr", [
|
||||
"config.json": "{\n \"b\": 2,\n \"a\": 1\n}",
|
||||
"beans.xml": "<beans>\n <bean class=\"X\" id=\"a\"/>\n</beans>",
|
||||
])
|
||||
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right))
|
||||
XCTAssertEqual(row(diff, "config.json")?.status, .equivalent, "key order shouldn't count")
|
||||
XCTAssertEqual(row(diff, "beans.xml")?.status, .equivalent, "attribute order shouldn't count")
|
||||
XCTAssertEqual(diff.totals().differences, 0)
|
||||
|
||||
// Byte-for-byte, the very same files do differ.
|
||||
let strict = DirectoryComparer.Options(mode: .binary)
|
||||
let binaryDiff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right),
|
||||
options: strict)
|
||||
XCTAssertEqual(binaryDiff.totals().differences, 2)
|
||||
}
|
||||
|
||||
// MARK: Archives
|
||||
|
||||
func testArchiveIsBrowsedAsAFolder() throws {
|
||||
let jar = try makeArchive("app.jar", [
|
||||
"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n",
|
||||
"com/example/Main.class": "clase",
|
||||
])
|
||||
let tree = try TreeScanner.scan(jar)
|
||||
|
||||
XCTAssertTrue(tree.isDirectory, "an archive must present as a folder")
|
||||
XCTAssertTrue(tree.isArchive)
|
||||
let manifest = tree.children
|
||||
.first { $0.name == "META-INF" }?.children
|
||||
.first { $0.name == "MANIFEST.MF" }
|
||||
XCTAssertNotNil(manifest, "entries should be laid out as a tree")
|
||||
XCTAssertNotNil(manifest?.crc32, "CRC comes free from the central directory")
|
||||
}
|
||||
|
||||
func testComparesTwoArchivesByEntry() throws {
|
||||
let first = try makeArchive("one.jar", [
|
||||
"a.txt": "igual",
|
||||
"b.txt": "antes",
|
||||
"solo-a.txt": "x",
|
||||
])
|
||||
let second = try makeArchive("two.jar", [
|
||||
"a.txt": "igual",
|
||||
"b.txt": "después",
|
||||
"solo-b.txt": "y",
|
||||
])
|
||||
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(first),
|
||||
right: try TreeScanner.scan(second))
|
||||
XCTAssertEqual(row(diff, "one.jar/a.txt")?.status, .identical)
|
||||
XCTAssertEqual(row(diff, "one.jar/b.txt")?.status, .different)
|
||||
XCTAssertEqual(row(diff, "one.jar/solo-a.txt")?.status, .onlyLeft)
|
||||
XCTAssertEqual(row(diff, "two.jar/solo-b.txt")?.status, .onlyRight)
|
||||
}
|
||||
|
||||
func testIdenticalArchiveEntriesResolveByCRCWithoutReading() throws {
|
||||
let files = ["big.txt": String(repeating: "dato ", count: 10_000)]
|
||||
let left = try TreeScanner.scan(try makeArchive("l.jar", files))
|
||||
let right = try TreeScanner.scan(try makeArchive("r.jar", files))
|
||||
|
||||
// Lazy mode reads nothing; CRC alone must still prove they're identical.
|
||||
let lazyOptions = DirectoryComparer.Options(resolveContentEagerly: false)
|
||||
let diff = DirectoryComparer.compare(left: left, right: right, options: lazyOptions)
|
||||
XCTAssertEqual(row(diff, "l.jar/big.txt")?.status, .identical,
|
||||
"matching CRC + size should settle it without inflating")
|
||||
}
|
||||
|
||||
func testNestedArchivesAreExpanded() throws {
|
||||
// An EAR containing a JAR — the shape of a real deployment artefact.
|
||||
let inner = try makeArchive("inner.jar", ["com/example/Service.class": "bytecode"])
|
||||
let earSource = scratch.appendingPathComponent("ear-src")
|
||||
try FileManager.default.createDirectory(at: earSource, withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: inner, to: earSource.appendingPathComponent("inner.jar"))
|
||||
try "app".write(to: earSource.appendingPathComponent("application.xml"), atomically: true, encoding: .utf8)
|
||||
|
||||
let ear = scratch.appendingPathComponent("app.ear")
|
||||
let zip = Process()
|
||||
zip.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
zip.arguments = ["-q", "-r", ear.path, "."]
|
||||
zip.currentDirectoryURL = earSource
|
||||
try zip.run()
|
||||
zip.waitUntilExit()
|
||||
|
||||
let tree = try TreeScanner.scan(ear)
|
||||
let jarNode = tree.children.first { $0.name == "inner.jar" }
|
||||
XCTAssertEqual(jarNode?.isDirectory, true, "a JAR inside an EAR must open as a folder")
|
||||
XCTAssertNotNil(jarNode?.children.first { $0.name == "com" },
|
||||
"nested archive contents should be listed")
|
||||
}
|
||||
|
||||
// MARK: Laziness
|
||||
|
||||
func testLazyModeLeavesLargeFilesPendingUntilResolved() throws {
|
||||
let left = try makeTree("ll", ["data.bin": String(repeating: "a", count: 2_000)])
|
||||
let right = try makeTree("rr", ["data.bin": String(repeating: "b", count: 2_000)])
|
||||
|
||||
let lazyOptions = DirectoryComparer.Options(resolveContentEagerly: true, eagerSizeLimit: 100)
|
||||
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
|
||||
right: try TreeScanner.scan(right),
|
||||
options: lazyOptions)
|
||||
let node = try XCTUnwrap(row(diff, "data.bin"))
|
||||
XCTAssertEqual(node.status, .pending, "over the limit it should defer reading")
|
||||
|
||||
// Resolving on demand gives the real answer.
|
||||
let resolved = DirectoryComparer.resolve(left: try XCTUnwrap(node.left),
|
||||
right: try XCTUnwrap(node.right))
|
||||
XCTAssertEqual(resolved, .different)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import XCTest
|
||||
@testable import CotejoEngine
|
||||
|
||||
final class ZipReaderTests: XCTestCase {
|
||||
/// Builds a real archive with the system `zip`, so the reader is tested
|
||||
/// against the format as produced in the wild rather than a hand-made blob.
|
||||
private func makeArchive(_ files: [String: String], name: String = "test.zip") throws -> URL {
|
||||
let root = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("cotejo-\(UUID().uuidString)")
|
||||
let content = root.appendingPathComponent("content")
|
||||
try FileManager.default.createDirectory(at: content, withIntermediateDirectories: true)
|
||||
|
||||
for (path, body) in files {
|
||||
let fileURL = content.appendingPathComponent(path)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try body.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
let archiveURL = root.appendingPathComponent(name)
|
||||
let zip = Process()
|
||||
zip.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
zip.arguments = ["-q", "-r", archiveURL.path, "."]
|
||||
zip.currentDirectoryURL = content
|
||||
try zip.run()
|
||||
zip.waitUntilExit()
|
||||
XCTAssertEqual(zip.terminationStatus, 0, "zip failed")
|
||||
return archiveURL
|
||||
}
|
||||
|
||||
func testReadsCentralDirectoryWithoutDecompressing() throws {
|
||||
let url = try makeArchive([
|
||||
"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n",
|
||||
"com/example/App.class": String(repeating: "A", count: 5_000),
|
||||
"config.json": #"{"b":2,"a":1}"#,
|
||||
])
|
||||
|
||||
let reader = try ZipReader(url: url)
|
||||
let files = reader.entries.filter { !$0.isDirectory }
|
||||
XCTAssertEqual(files.count, 3, "expected three entries, got \(files.map(\.path))")
|
||||
|
||||
let manifest = try XCTUnwrap(files.first { $0.path == "META-INF/MANIFEST.MF" })
|
||||
XCTAssertEqual(manifest.uncompressedSize, UInt64("Manifest-Version: 1.0\n".utf8.count))
|
||||
XCTAssertNotEqual(manifest.crc32, 0, "CRC32 is what lets us compare without inflating")
|
||||
}
|
||||
|
||||
func testInflatesEntryOnDemand() throws {
|
||||
let body = String(repeating: "hola mundo ", count: 500) // compressible
|
||||
let url = try makeArchive(["big.txt": body, "small.txt": "hi"])
|
||||
let reader = try ZipReader(url: url)
|
||||
|
||||
let big = try XCTUnwrap(reader.entries.first { $0.path == "big.txt" })
|
||||
XCTAssertEqual(big.compressionMethod, 8, "should be deflated")
|
||||
XCTAssertLessThan(big.compressedSize, big.uncompressedSize)
|
||||
|
||||
let data = try reader.data(for: big)
|
||||
XCTAssertEqual(String(decoding: data, as: UTF8.self), body)
|
||||
|
||||
// Stored (uncompressed) entries must work too.
|
||||
let small = try XCTUnwrap(reader.entries.first { $0.path == "small.txt" })
|
||||
XCTAssertEqual(String(decoding: try reader.data(for: small), as: UTF8.self), "hi")
|
||||
}
|
||||
|
||||
func testIdenticalArchivesMatchByCRCAlone() throws {
|
||||
let files = ["a.txt": "uno", "b/c.txt": "dos"]
|
||||
let first = try ZipReader(url: try makeArchive(files, name: "one.zip"))
|
||||
let second = try ZipReader(url: try makeArchive(files, name: "two.zip"))
|
||||
|
||||
let crcOf = { (reader: ZipReader) in
|
||||
Dictionary(uniqueKeysWithValues: reader.entries.filter { !$0.isDirectory }.map { ($0.path, $0.crc32) })
|
||||
}
|
||||
XCTAssertEqual(crcOf(first), crcOf(second),
|
||||
"same content must yield the same CRCs even in different archives")
|
||||
}
|
||||
|
||||
func testDetectsArchiveExtensions() {
|
||||
for name in ["app.jar", "bundle.EAR", "x.war", "y.zip"] {
|
||||
XCTAssertTrue(ZipReader.isArchive(URL(fileURLWithPath: name)), name)
|
||||
}
|
||||
for name in ["notes.txt", "photo.png"] {
|
||||
XCTAssertFalse(ZipReader.isArchive(URL(fileURLWithPath: name)), name)
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsNonZip() throws {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("cotejo-\(UUID().uuidString).zip")
|
||||
try Data("definitely not a zip".utf8).write(to: url)
|
||||
XCTAssertThrowsError(try ZipReader(url: url))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user