Portar el motor de comparación a TypeScript para la versión web
Mismo motor que la app de escritorio, pero client-side: los ficheros nunca salen del navegador. Listar un EAR sólo lee la cola del fichero y las entradas se inflan bajo demanda con DecompressionStream, que es lo que hace usable un archivo grande en una pestaña. - zip.ts: lector del directorio central, ZIP64, detección por firma - content.ts: binario/texto/semántico; JSON por estructura y XML canónico con hermanos repetidos tratados como conjunto - diff.ts: alineación LCS con recorte de prefijo/sufijo y clasificación de la diferencia (espacios / comentario / contenido) - tree.ts: árboles de carpetas y archivos, emparejado por nombre y CRC32 del directorio central para resolver iguales sin leer bytes 34 tests portados de los del motor Swift: existen para probar que ambas implementaciones dan el mismo veredicto. Closes #5 Closes #6 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
Generated
+2292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "kotej-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"fflate": "^0.8.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Deciding whether two blobs are equal, and under which rules. Ported from the
|
||||
* desktop engine so both give the same verdict; the browser supplies the two
|
||||
* hardest parts for free — DOMParser and JSON.parse.
|
||||
*/
|
||||
|
||||
export type ComparisonMode = 'binary' | 'text' | 'semantic';
|
||||
export type ContentKind = 'json' | 'xml' | 'text' | 'binary';
|
||||
export type CompareResult = 'equal' | 'equivalent' | 'different' | 'unsupported';
|
||||
|
||||
export interface TextOptions {
|
||||
ignoreTrailingWhitespace: boolean;
|
||||
ignoreAllWhitespace: boolean;
|
||||
ignoreCase: boolean;
|
||||
ignoreBlankLines: boolean;
|
||||
/** Treat CRLF and LF as the same, so files that crossed platforms still match. */
|
||||
normaliseLineEndings: boolean;
|
||||
}
|
||||
|
||||
export const defaultTextOptions: TextOptions = {
|
||||
ignoreTrailingWhitespace: true,
|
||||
ignoreAllWhitespace: false,
|
||||
ignoreCase: false,
|
||||
ignoreBlankLines: false,
|
||||
normaliseLineEndings: true,
|
||||
};
|
||||
|
||||
const XML_EXTENSIONS = new Set([
|
||||
'xml', 'xsd', 'xsl', 'xslt', 'wsdl', 'pom', 'svg', 'plist', 'storyboard',
|
||||
]);
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt', 'md', 'yml', 'yaml', 'properties', 'csv', 'log', 'java', 'swift', 'go',
|
||||
'js', 'ts', 'sh', 'sql', 'html', 'css',
|
||||
]);
|
||||
|
||||
/** Guesses from the extension first (cheap) and falls back to sniffing. */
|
||||
export function detectKind(path: string, data?: Uint8Array): ContentKind {
|
||||
const dot = path.lastIndexOf('.');
|
||||
const ext = dot > 0 ? path.slice(dot + 1).toLowerCase() : '';
|
||||
if (ext === 'json') return 'json';
|
||||
if (XML_EXTENSIONS.has(ext)) return 'xml';
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text';
|
||||
if (!data) return 'binary';
|
||||
return sniff(data);
|
||||
}
|
||||
|
||||
/** A NUL byte in the first block is the classic "this is binary" signal. */
|
||||
export function sniff(data: Uint8Array): ContentKind {
|
||||
const sample = data.subarray(0, 8000);
|
||||
if (sample.includes(0)) return 'binary';
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(sample);
|
||||
} catch {
|
||||
return 'binary';
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json';
|
||||
if (trimmed.startsWith('<')) return 'xml';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** The comparison that makes sense by default for this kind. */
|
||||
export function defaultMode(kind: ContentKind): ComparisonMode {
|
||||
if (kind === 'json' || kind === 'xml') return 'semantic';
|
||||
if (kind === 'text') return 'text';
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function compare(left: Uint8Array, right: Uint8Array, mode: ComparisonMode,
|
||||
kind: ContentKind,
|
||||
textOptions: TextOptions = defaultTextOptions): CompareResult {
|
||||
if (bytesEqual(left, right)) return 'equal';
|
||||
|
||||
switch (mode) {
|
||||
case 'binary':
|
||||
return 'different';
|
||||
case 'text':
|
||||
return normaliseText(decoder.decode(left), textOptions)
|
||||
=== normaliseText(decoder.decode(right), textOptions) ? 'equivalent' : 'different';
|
||||
case 'semantic':
|
||||
if (kind === 'json') return compareJSON(left, right);
|
||||
if (kind === 'xml') return compareXML(left, right);
|
||||
if (kind === 'text') return compare(left, right, 'text', 'text', textOptions);
|
||||
return 'different';
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
|
||||
export function normaliseText(text: string, options: TextOptions): string {
|
||||
let value = text;
|
||||
if (options.normaliseLineEndings) value = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (options.ignoreCase) value = value.toLowerCase();
|
||||
|
||||
let lines = value.split('\n');
|
||||
if (options.ignoreAllWhitespace) {
|
||||
lines = lines.map((line) => line.replace(/\s/g, ''));
|
||||
} else if (options.ignoreTrailingWhitespace) {
|
||||
lines = lines.map((line) => line.replace(/\s+$/, ''));
|
||||
}
|
||||
if (options.ignoreBlankLines) {
|
||||
lines = lines.filter((line) => line !== '');
|
||||
} else {
|
||||
// A missing final newline shouldn't count as a difference on its own.
|
||||
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
export function compareJSON(left: Uint8Array, right: Uint8Array): CompareResult {
|
||||
let leftValue: unknown;
|
||||
let rightValue: unknown;
|
||||
try {
|
||||
leftValue = JSON.parse(decoder.decode(left));
|
||||
} catch {
|
||||
return 'unsupported';
|
||||
}
|
||||
try {
|
||||
rightValue = JSON.parse(decoder.decode(right));
|
||||
} catch {
|
||||
return 'unsupported';
|
||||
}
|
||||
return jsonEqual(leftValue, rightValue) ? 'equivalent' : 'different';
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality: object key order and formatting are irrelevant, but array
|
||||
* order is significant, and true is not 1.
|
||||
*/
|
||||
export function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === null || right === null) return left === right;
|
||||
if (typeof left !== typeof right) return false;
|
||||
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((item, index) => jsonEqual(item, right[index]));
|
||||
}
|
||||
if (typeof left === 'object') {
|
||||
const l = left as Record<string, unknown>;
|
||||
const r = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(l);
|
||||
if (leftKeys.length !== Object.keys(r).length) return false;
|
||||
return leftKeys.every((key) =>
|
||||
Object.prototype.hasOwnProperty.call(r, key) && jsonEqual(l[key], r[key]));
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
|
||||
// MARK: XML
|
||||
|
||||
export function compareXML(left: Uint8Array, right: Uint8Array): CompareResult {
|
||||
const leftRoot = parseXML(decoder.decode(left));
|
||||
if (!leftRoot) return 'unsupported';
|
||||
const rightRoot = parseXML(decoder.decode(right));
|
||||
if (!rightRoot) return 'unsupported';
|
||||
return canonicalXML(leftRoot) === canonicalXML(rightRoot) ? 'equivalent' : 'different';
|
||||
}
|
||||
|
||||
function parseXML(text: string): Element | null {
|
||||
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||
if (doc.getElementsByTagName('parsererror').length > 0) return null;
|
||||
return doc.documentElement ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A normalised rendering, built so two documents that mean the same thing render
|
||||
* identically. Attribute order, insignificant whitespace and the order of
|
||||
* *repeated* siblings don't matter; the order of differently named siblings does,
|
||||
* since a sequence of distinct elements can carry meaning.
|
||||
*
|
||||
* Comparing canonical strings also avoids matching children pairwise, which would
|
||||
* be quadratic on exactly the long repeated lists this is for.
|
||||
*/
|
||||
export function canonicalXML(element: Element): string {
|
||||
let out = `<${element.namespaceURI ?? ''}|${element.localName}`;
|
||||
|
||||
const attributes: string[] = [];
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
// Namespace declarations are structure, not content.
|
||||
if (attribute.name.startsWith('xmlns')) continue;
|
||||
attributes.push(`${attribute.name}=${attribute.value}`);
|
||||
}
|
||||
out += ` ${attributes.sort().join(' ')}>`;
|
||||
|
||||
const children = Array.from(element.children);
|
||||
if (children.length === 0) {
|
||||
return `${out}${(element.textContent ?? '').trim()}</>`;
|
||||
}
|
||||
|
||||
// Group siblings by name, keeping the order names first appear.
|
||||
const order: string[] = [];
|
||||
const groups = new Map<string, Element[]>();
|
||||
for (const child of children) {
|
||||
const key = `${child.namespaceURI ?? ''}|${child.localName}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
order.push(key);
|
||||
}
|
||||
groups.get(key)!.push(child);
|
||||
}
|
||||
|
||||
for (const key of order) {
|
||||
const rendered = groups.get(key)!.map(canonicalXML);
|
||||
// Repeated entries are a set: sorting makes their order irrelevant.
|
||||
out += rendered.length > 1 ? rendered.sort().join('') : rendered.join('');
|
||||
}
|
||||
return `${out}</>`;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Line-by-line alignment for the side-by-side view, plus telling apart what kind
|
||||
* of change a line actually holds. Ported from the desktop engine.
|
||||
*/
|
||||
|
||||
export type RowKind = 'equal' | 'changed' | 'added' | 'removed';
|
||||
|
||||
/** What a changed line differs in — enough to skip the cosmetic ones. */
|
||||
export type ChangeKind = 'whitespace' | 'comment' | 'content';
|
||||
|
||||
export interface DiffRow {
|
||||
kind: RowKind;
|
||||
leftNumber: number | null;
|
||||
rightNumber: number | null;
|
||||
left: string;
|
||||
right: string;
|
||||
changeKind: ChangeKind;
|
||||
/** Character range that actually differs, so the exact characters can be marked. */
|
||||
leftHighlight: [number, number] | null;
|
||||
rightHighlight: [number, number] | null;
|
||||
}
|
||||
|
||||
export interface CommentSyntax {
|
||||
linePrefixes: string[];
|
||||
blockOpen?: string;
|
||||
blockClose?: string;
|
||||
}
|
||||
|
||||
export const C_LIKE: CommentSyntax = { linePrefixes: ['//'], blockOpen: '/*', blockClose: '*/' };
|
||||
export const HASH: CommentSyntax = { linePrefixes: ['#'] };
|
||||
export const SQL: CommentSyntax = { linePrefixes: ['--'], blockOpen: '/*', blockClose: '*/' };
|
||||
export const MARKUP: CommentSyntax = { linePrefixes: [], blockOpen: '<!--', blockClose: '-->' };
|
||||
|
||||
/**
|
||||
* Best guess from the extension. Unknown types get null, and then a change is
|
||||
* never reported as comment-only — better to under-claim than to hide a real one.
|
||||
*/
|
||||
export function commentSyntaxFor(path: string): CommentSyntax | null {
|
||||
const dot = path.lastIndexOf('.');
|
||||
const ext = dot > 0 ? path.slice(dot + 1).toLowerCase() : '';
|
||||
if (['swift', 'java', 'js', 'ts', 'jsx', 'tsx', 'go', 'c', 'h', 'cpp', 'hpp', 'cs',
|
||||
'kt', 'scala', 'rs', 'css', 'json5', 'gradle', 'groovy'].includes(ext)) return C_LIKE;
|
||||
if (['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'properties', 'conf', 'cfg',
|
||||
'ini', 'toml', 'dockerfile', 'makefile', 'pl', 'r'].includes(ext)) return HASH;
|
||||
if (ext === 'sql') return SQL;
|
||||
if (['xml', 'html', 'htm', 'xsd', 'xsl', 'xslt', 'wsdl', 'svg', 'vue'].includes(ext)) return MARKUP;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The line with its comments removed; block comments only when they close on it. */
|
||||
export function stripComments(line: string, syntax: CommentSyntax): string {
|
||||
let result = line;
|
||||
const { blockOpen, blockClose } = syntax;
|
||||
if (blockOpen && blockClose) {
|
||||
for (;;) {
|
||||
const start = result.indexOf(blockOpen);
|
||||
if (start < 0) break;
|
||||
const end = result.indexOf(blockClose, start + blockOpen.length);
|
||||
if (end < 0) break;
|
||||
result = result.slice(0, start) + result.slice(end + blockClose.length);
|
||||
}
|
||||
}
|
||||
for (const prefix of syntax.linePrefixes) {
|
||||
const at = result.indexOf(prefix);
|
||||
if (at >= 0) {
|
||||
result = result.slice(0, at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The differing middle of two lines, after trimming what they share at each end. */
|
||||
export function inlineRanges(left: string, right: string):
|
||||
[[number, number] | null, [number, number] | null] {
|
||||
if (!left.length || !right.length) return [null, null];
|
||||
|
||||
let prefix = 0;
|
||||
while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix++;
|
||||
|
||||
let suffix = 0;
|
||||
while (suffix < left.length - prefix && suffix < right.length - prefix
|
||||
&& left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix++;
|
||||
|
||||
const leftRange: [number, number] = [prefix, left.length - suffix];
|
||||
const rightRange: [number, number] = [prefix, right.length - suffix];
|
||||
if (leftRange[0] >= leftRange[1] && rightRange[0] >= rightRange[1]) return [null, null];
|
||||
return [leftRange, rightRange];
|
||||
}
|
||||
|
||||
/** Classifies a changed pair and locates the differing characters. */
|
||||
export function describe(left: string, right: string, syntax: CommentSyntax | null) {
|
||||
const [leftRange, rightRange] = inlineRanges(left, right);
|
||||
|
||||
// Whitespace first: it subsumes the others when nothing else moved.
|
||||
if (left.replace(/\s/g, '') === right.replace(/\s/g, '')) {
|
||||
return { changeKind: 'whitespace' as ChangeKind, leftRange, rightRange };
|
||||
}
|
||||
if (syntax) {
|
||||
if (stripComments(left, syntax).trim() === stripComments(right, syntax).trim()) {
|
||||
return { changeKind: 'comment' as ChangeKind, leftRange, rightRange };
|
||||
}
|
||||
}
|
||||
return { changeKind: 'content' as ChangeKind, leftRange, rightRange };
|
||||
}
|
||||
|
||||
function row(kind: RowKind, leftNumber: number | null, rightNumber: number | null,
|
||||
left: string, right: string, syntax: CommentSyntax | null = null): DiffRow {
|
||||
if (kind !== 'changed') {
|
||||
return { kind, leftNumber, rightNumber, left, right,
|
||||
changeKind: 'content', leftHighlight: null, rightHighlight: null };
|
||||
}
|
||||
const { changeKind, leftRange, rightRange } = describe(left, right, syntax);
|
||||
return { kind, leftNumber, rightNumber, left, right,
|
||||
changeKind, leftHighlight: leftRange, rightHighlight: rightRange };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns both texts. Common prefixes and suffixes are matched cheaply first, so a
|
||||
* one-line change in a big file costs almost nothing; only the middle needs the
|
||||
* quadratic pass, and beyond `limit` lines that middle is paired positionally
|
||||
* rather than hanging the UI.
|
||||
*/
|
||||
export function diffRows(left: string, right: string,
|
||||
syntax: CommentSyntax | null = null, limit = 3000): DiffRow[] {
|
||||
const leftLines = split(left);
|
||||
const rightLines = split(right);
|
||||
|
||||
let prefix = 0;
|
||||
while (prefix < leftLines.length && prefix < rightLines.length
|
||||
&& leftLines[prefix] === rightLines[prefix]) prefix++;
|
||||
|
||||
let suffix = 0;
|
||||
while (suffix < leftLines.length - prefix && suffix < rightLines.length - prefix
|
||||
&& leftLines[leftLines.length - 1 - suffix] === rightLines[rightLines.length - 1 - suffix]) {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const rows: DiffRow[] = [];
|
||||
for (let i = 0; i < prefix; i++) {
|
||||
rows.push(row('equal', i + 1, i + 1, leftLines[i], rightLines[i]));
|
||||
}
|
||||
|
||||
const leftMiddle = leftLines.slice(prefix, leftLines.length - suffix);
|
||||
const rightMiddle = rightLines.slice(prefix, rightLines.length - suffix);
|
||||
|
||||
rows.push(...(leftMiddle.length > limit || rightMiddle.length > limit
|
||||
? blockRows(leftMiddle, rightMiddle, prefix, prefix, syntax)
|
||||
: align(leftMiddle, rightMiddle, prefix, prefix, syntax)));
|
||||
|
||||
for (let i = 0; i < suffix; i++) {
|
||||
const leftIndex = leftLines.length - suffix + i;
|
||||
const rightIndex = rightLines.length - suffix + i;
|
||||
rows.push(row('equal', leftIndex + 1, rightIndex + 1,
|
||||
leftLines[leftIndex], rightLines[rightIndex]));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function split(text: string): string[] {
|
||||
return text.replace(/\r\n/g, '\n').split('\n');
|
||||
}
|
||||
|
||||
/** Longest common subsequence over the differing middle section. */
|
||||
function align(left: string[], right: string[], leftStart: number, rightStart: number,
|
||||
syntax: CommentSyntax | null): DiffRow[] {
|
||||
if (!left.length && !right.length) return [];
|
||||
|
||||
// lengths[i][j] = LCS length of left[i...] and right[j...]
|
||||
const lengths: number[][] = Array.from({ length: left.length + 1 },
|
||||
() => new Array<number>(right.length + 1).fill(0));
|
||||
for (let i = left.length - 1; i >= 0; i--) {
|
||||
for (let j = right.length - 1; j >= 0; j--) {
|
||||
lengths[i][j] = left[i] === right[j]
|
||||
? lengths[i + 1][j + 1] + 1
|
||||
: Math.max(lengths[i + 1][j], lengths[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0, j = 0;
|
||||
// Pending one-sided runs are paired up as "changed" so a modified line shows
|
||||
// opposite its old version instead of as a delete plus an insert.
|
||||
let removed: Array<[number, string]> = [];
|
||||
let added: Array<[number, string]> = [];
|
||||
|
||||
const flush = () => {
|
||||
const shared = Math.min(removed.length, added.length);
|
||||
for (let k = 0; k < shared; k++) {
|
||||
rows.push(row('changed', removed[k][0], added[k][0], removed[k][1], added[k][1], syntax));
|
||||
}
|
||||
for (let k = shared; k < removed.length; k++) {
|
||||
rows.push(row('removed', removed[k][0], null, removed[k][1], ''));
|
||||
}
|
||||
for (let k = shared; k < added.length; k++) {
|
||||
rows.push(row('added', null, added[k][0], '', added[k][1]));
|
||||
}
|
||||
removed = [];
|
||||
added = [];
|
||||
};
|
||||
|
||||
while (i < left.length && j < right.length) {
|
||||
if (left[i] === right[j]) {
|
||||
flush();
|
||||
rows.push(row('equal', leftStart + i + 1, rightStart + j + 1, left[i], right[j]));
|
||||
i++; j++;
|
||||
} else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
|
||||
removed.push([leftStart + i + 1, left[i]]); i++;
|
||||
} else {
|
||||
added.push([rightStart + j + 1, right[j]]); j++;
|
||||
}
|
||||
}
|
||||
while (i < left.length) { removed.push([leftStart + i + 1, left[i]]); i++; }
|
||||
while (j < right.length) { added.push([rightStart + j + 1, right[j]]); j++; }
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Fallback for very large differing sections: pair the lines positionally. */
|
||||
function blockRows(left: string[], right: string[], leftStart: number, rightStart: number,
|
||||
syntax: CommentSyntax | null): DiffRow[] {
|
||||
const rows: DiffRow[] = [];
|
||||
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
||||
const l = index < left.length ? left[index] : null;
|
||||
const r = index < right.length ? right[index] : null;
|
||||
if (l !== null && r !== null) {
|
||||
rows.push(l === r
|
||||
? row('equal', leftStart + index + 1, rightStart + index + 1, l, r)
|
||||
: row('changed', leftStart + index + 1, rightStart + index + 1, l, r, syntax));
|
||||
} else if (l !== null) {
|
||||
rows.push(row('removed', leftStart + index + 1, null, l, ''));
|
||||
} else if (r !== null) {
|
||||
rows.push(row('added', null, rightStart + index + 1, '', r));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Building comparison trees from folders and archives, and pairing two of them.
|
||||
*
|
||||
* Everything stays client-side: files are read from `File` handles the user
|
||||
* picked, never uploaded. Archives are browsed in place — listing one only reads
|
||||
* the tail of the file, which is what keeps a large EAR usable in a browser.
|
||||
*/
|
||||
|
||||
import {
|
||||
ZipReader, type ZipEntry, isArchive, mayBeNestedArchive, looksLikeArchive,
|
||||
} from './zip';
|
||||
import {
|
||||
compare as compareContent, defaultMode, detectKind, defaultTextOptions,
|
||||
type ComparisonMode, type TextOptions,
|
||||
} from './content';
|
||||
|
||||
/** Where a node's bytes come from. */
|
||||
export type NodeSource =
|
||||
| { kind: 'file'; file: File }
|
||||
| { kind: 'entry'; reader: ZipReader; entry: ZipEntry }
|
||||
| { kind: 'folder' };
|
||||
|
||||
export interface Node {
|
||||
name: string;
|
||||
/** Path relative to the root of its side, used to pair the two trees. */
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
/** CRC32 from the archive's central directory: a content fingerprint for free. */
|
||||
crc32?: number;
|
||||
/** True when this folder is really an archive shown as a folder. */
|
||||
isArchive: boolean;
|
||||
source: NodeSource;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
export async function nodeData(node: Node): Promise<Uint8Array> {
|
||||
if (node.source.kind === 'file') {
|
||||
return new Uint8Array(await node.source.file.arrayBuffer());
|
||||
}
|
||||
if (node.source.kind === 'entry') {
|
||||
return node.source.reader.data(node.source.entry);
|
||||
}
|
||||
throw new Error('folders have no contents of their own');
|
||||
}
|
||||
|
||||
function sortChildren(node: Node) {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
});
|
||||
node.children.forEach(sortChildren);
|
||||
}
|
||||
|
||||
/** Scans a single file, opening it as a folder when it turns out to be an archive. */
|
||||
export async function scanFile(file: File, path = file.name): Promise<Node> {
|
||||
if (await isArchive(file, file.name)) {
|
||||
try {
|
||||
return await scanArchive(file, path);
|
||||
} catch {
|
||||
// Not really readable as an archive: fall through and treat it as a file.
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: file.name, path, isDirectory: false, size: file.size,
|
||||
isArchive: false, source: { kind: 'file', file }, children: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree from files carrying a relative path, which is what both the
|
||||
* directory picker and a dropped folder give us.
|
||||
*/
|
||||
export async function scanFiles(files: Array<{ file: File; relativePath: string }>,
|
||||
rootName: string): Promise<Node> {
|
||||
const root: Node = {
|
||||
name: rootName, path: '', isDirectory: true, size: 0,
|
||||
isArchive: false, source: { kind: 'folder' }, children: [],
|
||||
};
|
||||
const folders = new Map<string, Node>([['', root]]);
|
||||
|
||||
const folderFor = (path: string): Node => {
|
||||
const existing = folders.get(path);
|
||||
if (existing) return existing;
|
||||
const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||||
const parent = folderFor(parentPath);
|
||||
const node: Node = {
|
||||
name: path.slice(path.lastIndexOf('/') + 1), path, isDirectory: true, size: 0,
|
||||
isArchive: false, source: { kind: 'folder' }, children: [],
|
||||
};
|
||||
parent.children.push(node);
|
||||
folders.set(path, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
for (const { file, relativePath } of files) {
|
||||
const parentPath = relativePath.includes('/')
|
||||
? relativePath.slice(0, relativePath.lastIndexOf('/')) : '';
|
||||
folderFor(parentPath).children.push(await scanFile(file, relativePath));
|
||||
}
|
||||
sortChildren(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Turns an archive's flat entry list into a tree of folders and files. */
|
||||
export async function scanArchive(blob: Blob, rootPath: string,
|
||||
name = rootPath.split('/').pop() ?? rootPath): Promise<Node> {
|
||||
const reader = await ZipReader.open(blob);
|
||||
const root: Node = {
|
||||
name, path: rootPath, isDirectory: true, size: blob.size,
|
||||
isArchive: true, source: { kind: 'folder' }, children: [],
|
||||
};
|
||||
const folders = new Map<string, Node>([['', root]]);
|
||||
|
||||
const folderFor = (path: string): Node => {
|
||||
const existing = folders.get(path);
|
||||
if (existing) return existing;
|
||||
const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||||
const parent = folderFor(parentPath);
|
||||
const node: Node = {
|
||||
name: path.slice(path.lastIndexOf('/') + 1), path: `${rootPath}/${path}`,
|
||||
isDirectory: true, size: 0, isArchive: false,
|
||||
source: { kind: 'folder' }, children: [],
|
||||
};
|
||||
parent.children.push(node);
|
||||
folders.set(path, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
for (const entry of reader.entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const parentPath = entry.path.includes('/')
|
||||
? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
|
||||
const entryName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
||||
const childPath = `${rootPath}/${entry.path}`;
|
||||
const parent = folderFor(parentPath);
|
||||
|
||||
// A JAR inside an EAR, or any renamed ZIP: open it as a folder too.
|
||||
if (mayBeNestedArchive(entryName, entry.uncompressedSize)) {
|
||||
try {
|
||||
const bytes = await reader.data(entry);
|
||||
if (looksLikeArchive(bytes.subarray(0, 4))) {
|
||||
const blob = new Blob([bytes.slice().buffer as ArrayBuffer]);
|
||||
parent.children.push(await scanArchive(blob, childPath, entryName));
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable as an archive: fall through and treat it as a plain entry.
|
||||
}
|
||||
}
|
||||
|
||||
parent.children.push({
|
||||
name: entryName, path: childPath, isDirectory: false,
|
||||
size: entry.uncompressedSize, crc32: entry.crc32, isArchive: false,
|
||||
source: { kind: 'entry', reader, entry }, children: [],
|
||||
});
|
||||
}
|
||||
sortChildren(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
// MARK: Comparing
|
||||
|
||||
export type DiffStatus =
|
||||
| 'identical' | 'equivalent' | 'different' | 'onlyLeft' | 'onlyRight' | 'pending';
|
||||
|
||||
export function isDifference(status: DiffStatus): boolean {
|
||||
return status !== 'identical' && status !== 'equivalent';
|
||||
}
|
||||
|
||||
export interface DiffNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
isArchive: boolean;
|
||||
left: Node | null;
|
||||
right: Node | null;
|
||||
status: DiffStatus;
|
||||
/** Which comparison settled this file, so "Automatic" can show what it chose. */
|
||||
appliedMode?: ComparisonMode;
|
||||
children: DiffNode[];
|
||||
}
|
||||
|
||||
export interface CompareOptions {
|
||||
mode?: ComparisonMode;
|
||||
textOptions?: TextOptions;
|
||||
/** Files bigger than this are left pending and resolved on demand. */
|
||||
eagerSizeLimit?: number;
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
identical: number; different: number; onlyLeft: number; onlyRight: number; pending: number;
|
||||
}
|
||||
|
||||
export function totals(node: DiffNode): Totals {
|
||||
const out: Totals = { identical: 0, different: 0, onlyLeft: 0, onlyRight: 0, pending: 0 };
|
||||
const walk = (n: DiffNode) => {
|
||||
if (n.isDirectory) { n.children.forEach(walk); return; }
|
||||
if (n.status === 'identical' || n.status === 'equivalent') out.identical++;
|
||||
else if (n.status === 'different') out.different++;
|
||||
else if (n.status === 'onlyLeft') out.onlyLeft++;
|
||||
else if (n.status === 'onlyRight') out.onlyRight++;
|
||||
else out.pending++;
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs two trees. Layered so large trees stay fast: sizes and CRC32s prove
|
||||
* equality without reading a byte. Note the asymmetry — the cheap checks can
|
||||
* prove two files *identical* but never that they *differ*, because JSON or XML
|
||||
* with other bytes may still be equivalent.
|
||||
*/
|
||||
export async function compareTrees(left: Node, right: Node,
|
||||
options: CompareOptions = {}): Promise<DiffNode> {
|
||||
return pair(left, right, left.name, '', options);
|
||||
}
|
||||
|
||||
async function pair(left: Node | null, right: Node | null, name: string, path: string,
|
||||
options: CompareOptions): Promise<DiffNode> {
|
||||
if (!left || !right) {
|
||||
const present = (left ?? right)!;
|
||||
const node: DiffNode = {
|
||||
name, path, isDirectory: present.isDirectory, isArchive: present.isArchive,
|
||||
left, right, status: left ? 'onlyLeft' : 'onlyRight', children: [],
|
||||
};
|
||||
node.children = await Promise.all(present.children.map((child) =>
|
||||
pair(left ? child : null, left ? null : child, child.name, child.path, options)));
|
||||
return node;
|
||||
}
|
||||
|
||||
if (left.isDirectory && right.isDirectory) {
|
||||
const node: DiffNode = {
|
||||
name, path, isDirectory: true, isArchive: left.isArchive || right.isArchive,
|
||||
left, right, status: 'identical', children: [],
|
||||
};
|
||||
node.children = await pairChildren(left, right, options);
|
||||
// A folder differs when anything inside it does.
|
||||
node.status = node.children.some((child) => isDifference(child.status)) ? 'different' : 'identical';
|
||||
return node;
|
||||
}
|
||||
|
||||
if (left.isDirectory !== right.isDirectory) {
|
||||
return {
|
||||
name, path, isDirectory: left.isDirectory, isArchive: left.isArchive || right.isArchive,
|
||||
left, right, status: 'different', children: [],
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await fileOutcome(left, right, options);
|
||||
return {
|
||||
name, path, isDirectory: false, isArchive: false, left, right,
|
||||
status: outcome.status, appliedMode: outcome.mode, children: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function pairChildren(left: Node, right: Node, options: CompareOptions): Promise<DiffNode[]> {
|
||||
const rightByName = new Map(right.children.map((child) => [child.name, child]));
|
||||
const rows: DiffNode[] = [];
|
||||
|
||||
for (const leftChild of left.children) {
|
||||
const match = rightByName.get(leftChild.name) ?? null;
|
||||
rightByName.delete(leftChild.name);
|
||||
rows.push(await pair(leftChild, match, leftChild.name, leftChild.path, options));
|
||||
}
|
||||
for (const orphan of rightByName.values()) {
|
||||
rows.push(await pair(null, orphan, orphan.name, orphan.path, options));
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The status plus which comparison produced it. */
|
||||
async function fileOutcome(left: Node, right: Node, options: CompareOptions) {
|
||||
// Level 1 — CRC32 straight from the archive directory: free and decisive.
|
||||
if (left.crc32 !== undefined && right.crc32 !== undefined) {
|
||||
if (left.crc32 === right.crc32 && left.size === right.size) {
|
||||
return { status: 'identical' as DiffStatus, mode: undefined };
|
||||
}
|
||||
} else if (left.size === right.size && left.size === 0) {
|
||||
return { status: 'identical' as DiffStatus, mode: undefined };
|
||||
}
|
||||
|
||||
const limit = options.eagerSizeLimit ?? 4 * 1024 * 1024;
|
||||
if (Math.max(left.size, right.size) > limit) {
|
||||
return { status: 'pending' as DiffStatus, mode: undefined };
|
||||
}
|
||||
return resolve(left, right, options);
|
||||
}
|
||||
|
||||
/** Reads both sides and applies the chosen (or inferred) comparison mode. */
|
||||
export async function resolve(left: Node, right: Node, options: CompareOptions = {}) {
|
||||
let leftData: Uint8Array;
|
||||
let rightData: Uint8Array;
|
||||
try {
|
||||
[leftData, rightData] = await Promise.all([nodeData(left), nodeData(right)]);
|
||||
} catch {
|
||||
return { status: 'different' as DiffStatus, mode: undefined };
|
||||
}
|
||||
|
||||
const kind = detectKind(left.name, leftData);
|
||||
const mode = options.mode ?? defaultMode(kind);
|
||||
const result = compareContent(leftData, rightData, mode, kind,
|
||||
options.textOptions ?? defaultTextOptions);
|
||||
const status: DiffStatus =
|
||||
result === 'equal' ? 'identical' : result === 'equivalent' ? 'equivalent' : 'different';
|
||||
return { status, mode };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Reads ZIP containers (JAR, EAR, WAR too) straight from a File, in the browser.
|
||||
*
|
||||
* Same design as the desktop engine: the central directory already records every
|
||||
* entry's CRC32 and sizes, so two archives can be compared entry by entry without
|
||||
* decompressing anything. Only the tail of the file is read to list an archive —
|
||||
* which is what keeps a 500 MB EAR usable client-side.
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
path: string;
|
||||
crc32: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
compressionMethod: number;
|
||||
localHeaderOffset: number;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
|
||||
const ZIP64_END_SIGNATURE = 0x06064b50;
|
||||
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_HEADER_SIGNATURE = 0x04034b50;
|
||||
|
||||
/** Extensions treated as containers without looking at the bytes. */
|
||||
export const ARCHIVE_EXTENSIONS = new Set([
|
||||
'zip', 'jar', 'ear', 'war', 'aar', 'ipa', 'sar', 'par',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extensions that are definitely not containers, so the thousands of small
|
||||
* entries inside a real EAR aren't inflated just to check for a signature.
|
||||
*/
|
||||
const NEVER_ARCHIVE_EXTENSIONS = new Set([
|
||||
'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',
|
||||
]);
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function hasArchiveExtension(name: string): boolean {
|
||||
return ARCHIVE_EXTENSIONS.has(extensionOf(name));
|
||||
}
|
||||
|
||||
/** The local-file-header signature, plus the empty and spanned variants. */
|
||||
export function looksLikeArchive(head: Uint8Array): boolean {
|
||||
if (head.length < 4) return false;
|
||||
const [a, b, c, d] = head;
|
||||
if (a !== 0x50 || b !== 0x4b) return false;
|
||||
return (c === 0x03 && d === 0x04) || (c === 0x05 && d === 0x06) || (c === 0x07 && d === 0x08);
|
||||
}
|
||||
|
||||
/** A ZIP renamed to anything else is still a ZIP, so fall back to the signature. */
|
||||
export async function isArchive(blob: Blob, name: string): Promise<boolean> {
|
||||
if (hasArchiveExtension(name)) return true;
|
||||
if (blob.size < 22) return false;
|
||||
const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
|
||||
return looksLikeArchive(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entry inside an archive is worth opening as one. Known extensions
|
||||
* always are; clearly non-container ones never are; the rest are only inflated
|
||||
* when small enough that guessing wrong is cheap.
|
||||
*/
|
||||
export function mayBeNestedArchive(name: string, uncompressedSize: number,
|
||||
inflateLimit = 64 * 1024 * 1024): boolean {
|
||||
const ext = extensionOf(name);
|
||||
if (ARCHIVE_EXTENSIONS.has(ext)) return true;
|
||||
if (ext && NEVER_ARCHIVE_EXTENSIONS.has(ext)) return false;
|
||||
return uncompressedSize >= 22 && uncompressedSize <= inflateLimit;
|
||||
}
|
||||
|
||||
export class ZipError extends Error {}
|
||||
|
||||
/** Reads a ZIP's central directory, touching only the bytes it needs. */
|
||||
export class ZipReader {
|
||||
private constructor(readonly blob: Blob, readonly entries: ZipEntry[]) {}
|
||||
|
||||
static async open(blob: Blob): Promise<ZipReader> {
|
||||
return new ZipReader(blob, await readCentralDirectory(blob));
|
||||
}
|
||||
|
||||
/** Inflates one entry. Comparisons normally stop at the CRC32 instead. */
|
||||
async data(entry: ZipEntry): Promise<Uint8Array> {
|
||||
const headerSlice = new DataView(
|
||||
await this.blob.slice(entry.localHeaderOffset, entry.localHeaderOffset + 30).arrayBuffer());
|
||||
if (headerSlice.getUint32(0, true) !== LOCAL_HEADER_SIGNATURE) {
|
||||
throw new ZipError(`bad local header for ${entry.path}`);
|
||||
}
|
||||
const nameLength = headerSlice.getUint16(26, true);
|
||||
const extraLength = headerSlice.getUint16(28, true);
|
||||
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
const payload = this.blob.slice(start, start + entry.compressedSize);
|
||||
|
||||
if (entry.compressionMethod === 0) {
|
||||
return new Uint8Array(await payload.arrayBuffer());
|
||||
}
|
||||
if (entry.compressionMethod !== 8) {
|
||||
throw new ZipError(`unsupported compression method ${entry.compressionMethod}`);
|
||||
}
|
||||
// Raw DEFLATE, decompressed by the browser itself — no bundled zlib.
|
||||
const stream = payload.stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
async function readCentralDirectory(blob: Blob): Promise<ZipEntry[]> {
|
||||
const eocd = await findEndOfCentralDirectory(blob);
|
||||
if (!eocd) throw new ZipError('Not a ZIP archive (no end-of-central-directory record).');
|
||||
|
||||
let entryCount = eocd.view.getUint16(eocd.offset + 10, true);
|
||||
let directoryOffset = eocd.view.getUint32(eocd.offset + 16, true);
|
||||
const directorySize = eocd.view.getUint32(eocd.offset + 12, true);
|
||||
|
||||
// ZIP64: the 32-bit fields saturate and the real values live elsewhere.
|
||||
if (directoryOffset === 0xffffffff || entryCount === 0xffff || directorySize === 0xffffffff) {
|
||||
const locatorOffset = eocd.offset - 20;
|
||||
if (locatorOffset < 0 || eocd.view.getUint32(locatorOffset, true) !== ZIP64_LOCATOR_SIGNATURE) {
|
||||
throw new ZipError('ZIP64 without locator');
|
||||
}
|
||||
const zip64End = Number(eocd.view.getBigUint64(locatorOffset + 8, true));
|
||||
const record = new DataView(await blob.slice(zip64End, zip64End + 56).arrayBuffer());
|
||||
if (record.getUint32(0, true) !== ZIP64_END_SIGNATURE) throw new ZipError('bad ZIP64 end record');
|
||||
entryCount = Number(record.getBigUint64(32, true));
|
||||
directoryOffset = Number(record.getBigUint64(48, true));
|
||||
}
|
||||
|
||||
const directory = new Uint8Array(await blob.slice(directoryOffset).arrayBuffer());
|
||||
const view = new DataView(directory.buffer);
|
||||
const decoder = new TextDecoder();
|
||||
const entries: ZipEntry[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
if (offset + 46 > directory.length || view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE) {
|
||||
throw new ZipError('bad central directory header');
|
||||
}
|
||||
const method = view.getUint16(offset + 10, true);
|
||||
const crc = view.getUint32(offset + 16, true);
|
||||
let compressed = view.getUint32(offset + 20, true);
|
||||
let uncompressed = view.getUint32(offset + 24, true);
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
let localOffset = view.getUint32(offset + 42, true);
|
||||
|
||||
const nameStart = offset + 46;
|
||||
const rawName = decoder.decode(directory.subarray(nameStart, nameStart + nameLength));
|
||||
|
||||
if (uncompressed === 0xffffffff || compressed === 0xffffffff || localOffset === 0xffffffff) {
|
||||
({ uncompressed, compressed, localOffset } = readZip64Extra(
|
||||
view, nameStart + nameLength, extraLength, uncompressed, compressed, localOffset));
|
||||
}
|
||||
|
||||
entries.push({
|
||||
path: normalise(rawName),
|
||||
crc32: crc,
|
||||
compressedSize: compressed,
|
||||
uncompressedSize: uncompressed,
|
||||
compressionMethod: method,
|
||||
localHeaderOffset: localOffset,
|
||||
isDirectory: rawName.endsWith('/'),
|
||||
});
|
||||
offset = nameStart + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Entries may use either separator and some tools prefix "./". */
|
||||
function normalise(name: string): string {
|
||||
let path = name.replace(/\\/g, '/');
|
||||
while (path.startsWith('./')) path = path.slice(2);
|
||||
while (path.endsWith('/')) path = path.slice(0, -1);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function findEndOfCentralDirectory(blob: Blob) {
|
||||
const minimum = 22;
|
||||
if (blob.size < minimum) return null;
|
||||
// The record sits at the end, possibly followed by a comment (max 64 KiB).
|
||||
const tailLength = Math.min(blob.size, minimum + 0xffff);
|
||||
const tail = new DataView(await blob.slice(blob.size - tailLength).arrayBuffer());
|
||||
const base = blob.size - tailLength;
|
||||
|
||||
for (let offset = tail.byteLength - minimum; offset >= 0; offset--) {
|
||||
if (tail.getUint32(offset, true) === EOCD_SIGNATURE) {
|
||||
// Re-read from the EOCD so offsets in the record are absolute-friendly.
|
||||
return { view: tail, offset, base };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readZip64Extra(view: DataView, start: number, length: number,
|
||||
uncompressed: number, compressed: number, localOffset: number) {
|
||||
let cursor = start;
|
||||
const end = start + length;
|
||||
while (cursor + 4 <= end) {
|
||||
const headerID = view.getUint16(cursor, true);
|
||||
const size = view.getUint16(cursor + 2, true);
|
||||
let field = cursor + 4;
|
||||
if (headerID === 0x0001) {
|
||||
if (uncompressed === 0xffffffff) { uncompressed = Number(view.getBigUint64(field, true)); field += 8; }
|
||||
if (compressed === 0xffffffff) { compressed = Number(view.getBigUint64(field, true)); field += 8; }
|
||||
if (localOffset === 0xffffffff) { localOffset = Number(view.getBigUint64(field, true)); }
|
||||
break;
|
||||
}
|
||||
cursor = field + size;
|
||||
}
|
||||
return { uncompressed, compressed, localOffset };
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Ported from the desktop engine's tests. They exist to prove the web version
|
||||
* gives the *same verdicts* as the Mac app — that's the whole point of having two
|
||||
* implementations, so any divergence has to show up here.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
import {
|
||||
compare, detectKind, defaultMode, jsonEqual, normaliseText, defaultTextOptions,
|
||||
canonicalXML,
|
||||
} from '../src/engine/content';
|
||||
import { diffRows, inlineRanges, describe as describeChange, commentSyntaxFor, C_LIKE, HASH } from '../src/engine/diff';
|
||||
import {
|
||||
ZipReader, isArchive, mayBeNestedArchive, hasArchiveExtension, looksLikeArchive,
|
||||
} from '../src/engine/zip';
|
||||
import { scanArchive, scanFiles, compareTrees, totals, type DiffNode } from '../src/engine/tree';
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
function makeZip(files: Record<string, string>): Blob {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const [path, body] of Object.entries(files)) entries[path] = bytes(body);
|
||||
return new Blob([zipSync(entries)]);
|
||||
}
|
||||
|
||||
function fileFor(path: string, body: string) {
|
||||
return { file: new File([bytes(body)], path.split('/').pop()!), relativePath: path };
|
||||
}
|
||||
|
||||
function row(node: DiffNode, path: string): DiffNode | undefined {
|
||||
if (node.path === path) return node;
|
||||
for (const child of node.children) {
|
||||
const found = row(child, path);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
describe('JSON', () => {
|
||||
it('ignores key order and formatting', () => {
|
||||
const left = bytes('{"a":1,"b":{"c":true,"d":null}}');
|
||||
const right = bytes('{\n "b": { "d": null, "c": true },\n "a": 1\n}');
|
||||
expect(compare(left, right, 'semantic', 'json')).toBe('equivalent');
|
||||
expect(compare(left, right, 'binary', 'json')).toBe('different');
|
||||
});
|
||||
|
||||
it('keeps array order significant', () => {
|
||||
expect(compare(bytes('{"i":[1,2,3]}'), bytes('{"i":[3,2,1]}'), 'semantic', 'json'))
|
||||
.toBe('different');
|
||||
});
|
||||
|
||||
it('does not treat true as 1', () => {
|
||||
expect(jsonEqual(true, 1)).toBe(false);
|
||||
expect(compare(bytes('{"f":true}'), bytes('{"f":1}'), 'semantic', 'json')).toBe('different');
|
||||
});
|
||||
|
||||
it('reports invalid JSON instead of calling it equal', () => {
|
||||
expect(compare(bytes('{not json'), bytes('{}'), 'semantic', 'json')).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: XML
|
||||
|
||||
describe('XML', () => {
|
||||
it('ignores attribute order and whitespace', () => {
|
||||
const left = bytes('<root><item id="1" name="uno">valor</item></root>');
|
||||
const right = bytes('<root>\n <item name="uno" id="1">\n valor\n </item>\n</root>');
|
||||
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
|
||||
expect(compare(left, right, 'binary', 'xml')).toBe('different');
|
||||
});
|
||||
|
||||
it('treats repeated siblings as a set', () => {
|
||||
const left = bytes(`<Config>
|
||||
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
|
||||
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
|
||||
</Config>`);
|
||||
const right = bytes(`<Config>
|
||||
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
|
||||
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
|
||||
</Config>`);
|
||||
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
|
||||
});
|
||||
|
||||
it('still catches a changed value inside a reordered list', () => {
|
||||
const left = bytes('<C><P><k>a</k><v>1</v></P><P><k>b</k><v>2</v></P></C>');
|
||||
const right = bytes('<C><P><k>b</k><v>2</v></P><P><k>a</k><v>9</v></P></C>');
|
||||
expect(compare(left, right, 'semantic', 'xml')).toBe('different');
|
||||
});
|
||||
|
||||
it('catches a dropped entry and a collapsed duplicate', () => {
|
||||
expect(compare(bytes('<C><P>a</P><P>b</P><P>c</P></C>'),
|
||||
bytes('<C><P>c</P><P>a</P></C>'), 'semantic', 'xml')).toBe('different');
|
||||
expect(compare(bytes('<C><P>a</P><P>a</P></C>'),
|
||||
bytes('<C><P>a</P></C>'), 'semantic', 'xml')).toBe('different');
|
||||
});
|
||||
|
||||
it('keeps the order of differently named siblings significant', () => {
|
||||
expect(compare(bytes('<root><a/><b/></root>'), bytes('<root><b/><a/></root>'),
|
||||
'semantic', 'xml')).toBe('different');
|
||||
});
|
||||
|
||||
it('handles a long reordered list', () => {
|
||||
const entries = Array.from({ length: 400 }, (_, i) => `<P><k>key${i}</k><v>${i}</v></P>`);
|
||||
const left = bytes(`<C>${entries.join('')}</C>`);
|
||||
const right = bytes(`<C>${[...entries].reverse().join('')}</C>`);
|
||||
expect(compare(left, right, 'semantic', 'xml')).toBe('equivalent');
|
||||
});
|
||||
|
||||
it('renders a canonical string that ignores attribute order', () => {
|
||||
const parse = (xml: string) =>
|
||||
new DOMParser().parseFromString(xml, 'application/xml').documentElement;
|
||||
expect(canonicalXML(parse('<a x="1" y="2"/>')))
|
||||
.toBe(canonicalXML(parse('<a y="2" x="1"/>')));
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: Text
|
||||
|
||||
describe('text', () => {
|
||||
it('tolerates line endings and trailing spaces', () => {
|
||||
expect(compare(bytes('uno\ndos\n'), bytes('uno \r\ndos\r\n'), 'text', 'text'))
|
||||
.toBe('equivalent');
|
||||
expect(compare(bytes('uno\ndos\n'), bytes('uno \r\ndos\r\n'), 'text', 'text',
|
||||
{ ...defaultTextOptions, ignoreTrailingWhitespace: false, normaliseLineEndings: false }))
|
||||
.toBe('different');
|
||||
});
|
||||
|
||||
it('honours case and blank-line options', () => {
|
||||
const options = { ...defaultTextOptions, ignoreCase: true, ignoreBlankLines: true };
|
||||
expect(normaliseText('Hola\n\nMundo', options)).toBe(normaliseText('hola\nmundo', options));
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: Detection
|
||||
|
||||
describe('kind detection', () => {
|
||||
it('uses the extension first, then the content', () => {
|
||||
expect(detectKind('a/config.json')).toBe('json');
|
||||
expect(detectKind('pom.xml')).toBe('xml');
|
||||
expect(detectKind('notes.txt')).toBe('text');
|
||||
expect(detectKind('payload', bytes('{"a":1}'))).toBe('json');
|
||||
expect(detectKind('payload', bytes('<root/>'))).toBe('xml');
|
||||
expect(detectKind('payload', new Uint8Array([0, 1, 2]))).toBe('binary');
|
||||
});
|
||||
|
||||
it('picks a sensible default mode per kind', () => {
|
||||
expect(defaultMode('json')).toBe('semantic');
|
||||
expect(defaultMode('xml')).toBe('semantic');
|
||||
expect(defaultMode('text')).toBe('text');
|
||||
expect(defaultMode('binary')).toBe('binary');
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: Line diff
|
||||
|
||||
describe('line diff', () => {
|
||||
it('pairs a changed line instead of delete plus insert', () => {
|
||||
const rows = diffRows('uno\ndos\ntres', 'uno\nDOS\ntres');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[1].kind).toBe('changed');
|
||||
expect(rows[1].left).toBe('dos');
|
||||
expect(rows[1].right).toBe('DOS');
|
||||
});
|
||||
|
||||
it('reports insertions and deletions', () => {
|
||||
expect(diffRows('a\nc', 'a\nb\nc').map((r) => r.kind)).toEqual(['equal', 'added', 'equal']);
|
||||
expect(diffRows('a\nb\nc', 'a\nc').map((r) => r.kind)).toEqual(['equal', 'removed', 'equal']);
|
||||
});
|
||||
|
||||
it('stays cheap on a big file with one change', () => {
|
||||
const common = Array.from({ length: 20000 }, (_, i) => `line ${i}`);
|
||||
const modified = [...common];
|
||||
modified[10000] = 'line 10000 CHANGED';
|
||||
const rows = diffRows(common.join('\n'), modified.join('\n'));
|
||||
expect(rows).toHaveLength(20000);
|
||||
expect(rows.filter((r) => r.kind !== 'equal')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('highlights only the differing characters', () => {
|
||||
const [left, right] = inlineRanges('let total = 3', 'let total = 4');
|
||||
expect('let total = 3'.slice(left![0], left![1])).toBe('3');
|
||||
expect('let total = 4'.slice(right![0], right![1])).toBe('4');
|
||||
});
|
||||
|
||||
it('classifies whitespace, comment and content changes', () => {
|
||||
expect(describeChange(' let x = 1', '\t\tlet x = 1', C_LIKE).changeKind).toBe('whitespace');
|
||||
expect(describeChange('let t = 3 // vieja', 'let t = 3 // nueva', C_LIKE).changeKind).toBe('comment');
|
||||
expect(describeChange('PORT=8080 # a', 'PORT=8080 # b', HASH).changeKind).toBe('comment');
|
||||
expect(describeChange('let t = 3 // n', 'let t = 4 // n', C_LIKE).changeKind).toBe('content');
|
||||
// Without a known syntax nothing is claimed as a comment.
|
||||
expect(describeChange('v = 3 // a', 'v = 3 // b', null).changeKind).toBe('content');
|
||||
});
|
||||
|
||||
it('infers comment syntax from the extension', () => {
|
||||
expect(commentSyntaxFor('App.swift')).toBe(C_LIKE);
|
||||
expect(commentSyntaxFor('deploy.sh')).toBe(HASH);
|
||||
expect(commentSyntaxFor('data.bin')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: Archives
|
||||
|
||||
describe('archives', () => {
|
||||
it('reads the central directory without decompressing', async () => {
|
||||
const zip = makeZip({
|
||||
'META-INF/MANIFEST.MF': 'Manifest-Version: 1.0\n',
|
||||
'com/example/App.class': 'A'.repeat(5000),
|
||||
});
|
||||
const reader = await ZipReader.open(zip);
|
||||
expect(reader.entries).toHaveLength(2);
|
||||
const manifest = reader.entries.find((e) => e.path === 'META-INF/MANIFEST.MF')!;
|
||||
expect(manifest.uncompressedSize).toBe(22);
|
||||
expect(manifest.crc32).not.toBe(0);
|
||||
});
|
||||
|
||||
it('inflates an entry on demand', async () => {
|
||||
const body = 'hola mundo '.repeat(500);
|
||||
const reader = await ZipReader.open(makeZip({ 'big.txt': body }));
|
||||
const entry = reader.entries.find((e) => e.path === 'big.txt')!;
|
||||
expect(new TextDecoder().decode(await reader.data(entry))).toBe(body);
|
||||
});
|
||||
|
||||
it('gives identical archives identical CRCs', async () => {
|
||||
const files = { 'a.txt': 'uno', 'b/c.txt': 'dos' };
|
||||
const first = await ZipReader.open(makeZip(files));
|
||||
const second = await ZipReader.open(makeZip(files));
|
||||
const crcs = (r: ZipReader) => r.entries.map((e) => `${e.path}:${e.crc32}`).sort();
|
||||
expect(crcs(first)).toEqual(crcs(second));
|
||||
});
|
||||
|
||||
it('detects archives by extension and by signature', async () => {
|
||||
expect(hasArchiveExtension('app.jar')).toBe(true);
|
||||
expect(hasArchiveExtension('notes.txt')).toBe(false);
|
||||
const zip = makeZip({ 'a.txt': 'x' });
|
||||
// A ZIP renamed to something unfamiliar is still a ZIP.
|
||||
expect(await isArchive(zip, 'bundle.customext')).toBe(true);
|
||||
expect(await isArchive(new Blob([bytes('solo texto que no es un zip')]), 'notes.unknown'))
|
||||
.toBe(false);
|
||||
expect(looksLikeArchive(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips entries that cannot be archives', () => {
|
||||
expect(mayBeNestedArchive('Service.class', 5000)).toBe(false);
|
||||
expect(mayBeNestedArchive('beans.xml', 900)).toBe(false);
|
||||
expect(mayBeNestedArchive('lib.jar', 5000)).toBe(true);
|
||||
expect(mayBeNestedArchive('mystery.bundle', 5000)).toBe(true);
|
||||
expect(mayBeNestedArchive('huge.bundle', 900_000_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('browses an archive as a folder', async () => {
|
||||
const tree = await scanArchive(makeZip({
|
||||
'META-INF/MANIFEST.MF': 'Manifest-Version: 1.0\n',
|
||||
'com/example/Main.class': 'clase',
|
||||
}), 'app.jar');
|
||||
expect(tree.isDirectory).toBe(true);
|
||||
expect(tree.isArchive).toBe(true);
|
||||
const manifest = tree.children.find((c) => c.name === 'META-INF')
|
||||
?.children.find((c) => c.name === 'MANIFEST.MF');
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest!.crc32).toBeDefined();
|
||||
});
|
||||
|
||||
it('expands a renamed ZIP nested inside a JAR', async () => {
|
||||
const inner = makeZip({ 'deep/Service.class': 'bytecode' });
|
||||
const innerBytes = new Uint8Array(await inner.arrayBuffer());
|
||||
const outer = new Blob([zipSync({ 'inner.zipball': innerBytes, 'MANIFEST.MF': bytes('m') })]);
|
||||
|
||||
const tree = await scanArchive(outer, 'lib.jar');
|
||||
const nested = tree.children.find((c) => c.name === 'inner.zipball');
|
||||
expect(nested?.isDirectory).toBe(true);
|
||||
expect(nested?.children.find((c) => c.name === 'deep')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// MARK: Comparing trees
|
||||
|
||||
describe('comparing trees', () => {
|
||||
it('classifies each kind of difference', async () => {
|
||||
const left = await scanFiles([
|
||||
fileFor('same.txt', 'igual'),
|
||||
fileFor('changed.txt', 'antes'),
|
||||
fileFor('only-left.txt', 'solo A'),
|
||||
fileFor('sub/nested.txt', 'profundo'),
|
||||
], 'left');
|
||||
const right = await scanFiles([
|
||||
fileFor('same.txt', 'igual'),
|
||||
fileFor('changed.txt', 'después'),
|
||||
fileFor('only-right.txt', 'solo B'),
|
||||
fileFor('sub/nested.txt', 'profundo'),
|
||||
], 'right');
|
||||
|
||||
const diff = await compareTrees(left, right);
|
||||
expect(row(diff, 'same.txt')?.status).toBe('identical');
|
||||
expect(row(diff, 'changed.txt')?.status).toBe('different');
|
||||
expect(row(diff, 'only-left.txt')?.status).toBe('onlyLeft');
|
||||
expect(row(diff, 'only-right.txt')?.status).toBe('onlyRight');
|
||||
expect(row(diff, 'sub')?.status).toBe('identical');
|
||||
|
||||
const counts = totals(diff);
|
||||
expect(counts).toMatchObject({ identical: 2, different: 1, onlyLeft: 1, onlyRight: 1 });
|
||||
});
|
||||
|
||||
it('bubbles a difference up to its folders', async () => {
|
||||
const left = await scanFiles([fileFor('deep/a/b/file.txt', 'uno')], 'l');
|
||||
const right = await scanFiles([fileFor('deep/a/b/file.txt', 'dos')], 'r');
|
||||
const diff = await compareTrees(left, right);
|
||||
expect(row(diff, 'deep')?.status).toBe('different');
|
||||
});
|
||||
|
||||
it('does not flag semantically equal JSON and XML, and reports the mode used', async () => {
|
||||
const left = await scanFiles([
|
||||
fileFor('config.json', '{"a":1,"b":2}'),
|
||||
fileFor('beans.xml', '<beans><bean id="a" class="X"/></beans>'),
|
||||
], 'sl');
|
||||
const right = await scanFiles([
|
||||
fileFor('config.json', '{\n "b": 2,\n "a": 1\n}'),
|
||||
fileFor('beans.xml', '<beans>\n <bean class="X" id="a"/>\n</beans>'),
|
||||
], 'sr');
|
||||
|
||||
const diff = await compareTrees(left, right);
|
||||
expect(row(diff, 'config.json')?.status).toBe('equivalent');
|
||||
expect(row(diff, 'config.json')?.appliedMode).toBe('semantic');
|
||||
expect(row(diff, 'beans.xml')?.status).toBe('equivalent');
|
||||
|
||||
// Byte for byte, the very same files do differ.
|
||||
const strict = await compareTrees(left, right, { mode: 'binary' });
|
||||
expect(row(strict, 'config.json')?.status).toBe('different');
|
||||
expect(row(strict, 'config.json')?.appliedMode).toBe('binary');
|
||||
});
|
||||
|
||||
it('compares two archives entry by entry', async () => {
|
||||
const first = await scanArchive(makeZip({
|
||||
'a.txt': 'igual', 'b.txt': 'antes', 'solo-a.txt': 'x',
|
||||
}), 'one.jar');
|
||||
const second = await scanArchive(makeZip({
|
||||
'a.txt': 'igual', 'b.txt': 'después', 'solo-b.txt': 'y',
|
||||
}), 'two.jar');
|
||||
|
||||
const diff = await compareTrees(first, second);
|
||||
expect(row(diff, 'one.jar/a.txt')?.status).toBe('identical');
|
||||
expect(row(diff, 'one.jar/b.txt')?.status).toBe('different');
|
||||
expect(row(diff, 'one.jar/solo-a.txt')?.status).toBe('onlyLeft');
|
||||
expect(row(diff, 'two.jar/solo-b.txt')?.status).toBe('onlyRight');
|
||||
});
|
||||
|
||||
it('settles identical archive entries by CRC without reading them', async () => {
|
||||
const files = { 'big.txt': 'dato '.repeat(10000) };
|
||||
const left = await scanArchive(makeZip(files), 'l.jar');
|
||||
const right = await scanArchive(makeZip(files), 'r.jar');
|
||||
// A tiny eager limit means nothing may be read; the CRC still decides.
|
||||
const diff = await compareTrees(left, right, { eagerSizeLimit: 1 });
|
||||
expect(row(diff, 'l.jar/big.txt')?.status).toBe('identical');
|
||||
});
|
||||
|
||||
it('leaves large differing files pending', async () => {
|
||||
const left = await scanFiles([fileFor('data.bin', 'a'.repeat(2000))], 'll');
|
||||
const right = await scanFiles([fileFor('data.bin', 'b'.repeat(2000))], 'rr');
|
||||
const diff = await compareTrees(left, right, { eagerSizeLimit: 100 });
|
||||
expect(row(diff, 'data.bin')?.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Tests run on Node, not jsdom: jsdom ships a partial `Blob` whose `slice()`
|
||||
* result has no `arrayBuffer()`, and reading the tail of a blob is exactly what
|
||||
* the ZIP reader does. Node's own Blob/File/DecompressionStream match the
|
||||
* browser, so we only borrow jsdom's XML parser.
|
||||
*/
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const { window } = new JSDOM('', { contentType: 'text/html' });
|
||||
globalThis.DOMParser = window.DOMParser;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user