diff --git a/README.md b/README.md index 331a0ac..724366c 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,22 @@ $parser = ELPParser::fromFile('/path/to/project.elpx', $limits); The defaults are 20,000 entries, 1 GiB per entry, approximately 2 GiB total uncompressed data, 64 MiB for the project XML, and a maximum compression ratio of 1000:1. +### Fingerprints and project diffs + +Exact archive bytes and normalized logical content use separate fingerprints: + +```php +$archiveHash = $parser->getArchiveFingerprint(); +$contentHash = $parser->getContentFingerprint(); + +$same = $parser->hasSameContentAs($otherParser); +$diff = $parser->diff($otherParser); +``` + +The normalized content fingerprint ignores volatile package identity/version fields such as `odeId`, `odeVersionId` and the eXeLearning application version. It keeps parsed project structure and includes hashes of project resource bytes. This makes it suitable for change detection without treating ZIP packaging differences as content changes. + +The semantic diff reports metadata, page, block, iDevice and resource additions/removals/changes. + ### Export JSON ```php diff --git a/docs/api.md b/docs/api.md index 3703cbe..a498263 100644 --- a/docs/api.md +++ b/docs/api.md @@ -113,6 +113,16 @@ Asset references are returned only when they resolve to an entry in the project - Model wrappers: `Project`, `Page`, `Block`, `Idevice`, `Asset`, `VersionInfo`. - The typed API is additive; existing array-returning APIs remain supported. +### Fingerprints and comparison + +- `getArchiveFingerprint(string $algorithm = 'sha256'): string` +- `getArchiveEntryFingerprint(string $entryName, string $algorithm = 'sha256'): string` +- `getContentFingerprint(string $algorithm = 'sha256'): string` +- `hasSameContentAs(ELPParser $other, string $algorithm = 'sha256'): bool` +- `diff(ELPParser $other, string $algorithm = 'sha256'): array` + +Archive fingerprints hash exact ZIP bytes. Content fingerprints normalize logical project data, exclude volatile package identity/version metadata and include project resource hashes. + ### Serialization and extraction - `toArray(): array` diff --git a/src/Archive/ArchiveReader.php b/src/Archive/ArchiveReader.php index d180683..9b65461 100644 --- a/src/Archive/ArchiveReader.php +++ b/src/Archive/ArchiveReader.php @@ -148,6 +148,96 @@ public function readEntry(string $entryName, ?int $maxBytes = null): string } } + /** + * Hash the complete archive file. + * + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function hashArchive(string $algorithm = 'sha256'): string + { + $this->assertHashAlgorithm($algorithm); + + $hash = hash_file($algorithm, $this->filePath); + if ($hash === false) { + throw new InvalidArchiveException('Unable to hash project archive.'); + } + + return $hash; + } + + /** + * Hash one ZIP entry without loading it entirely into memory. + * + * @param string $entryName Entry name. + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function hashEntry( + string $entryName, + string $algorithm = 'sha256' + ): string { + $this->assertHashAlgorithm($algorithm); + $zip = $this->openArchive(); + + try { + $index = $zip->locateName($entryName); + if ($index === false) { + throw new InvalidArchiveException('ZIP entry not found: ' . $entryName); + } + + $stat = $zip->statIndex($index); + if ( + is_array($stat) + && (int) ($stat['size'] ?? 0) > $this->limits->maxEntryBytes + ) { + throw new ResourceLimitException( + 'ZIP entry exceeds the configured read limit: ' . $entryName + ); + } + + $stream = $zip->getStream($entryName); + if ($stream === false) { + throw new InvalidArchiveException('Unable to read ZIP entry: ' . $entryName); + } + + $context = hash_init($algorithm); + $read = 0; + + try { + while (!feof($stream)) { + $chunk = fread($stream, 1048576); + if ($chunk === false) { + throw new InvalidArchiveException( + 'Unable to read ZIP entry: ' . $entryName + ); + } + + if ($chunk === '') { + continue; + } + + $read += strlen($chunk); + if ($read > $this->limits->maxEntryBytes) { + throw new ResourceLimitException( + 'ZIP entry exceeds the configured read limit: ' . $entryName + ); + } + + hash_update($context, $chunk); + } + } finally { + fclose($stream); + } + + return hash_final($context); + } finally { + $zip->close(); + } + } + /** * Extract all entries while preserving configured resource limits. * @@ -250,6 +340,22 @@ public function extract(string $destinationPath): void } } + /** + * Validate a requested hash algorithm. + * + * @param string $algorithm Hash algorithm. + * + * @return void + */ + private function assertHashAlgorithm(string $algorithm): void + { + if (!in_array($algorithm, hash_algos(), true)) { + throw new InvalidArchiveException( + 'Unsupported hash algorithm: ' . $algorithm + ); + } + } + /** * Open the configured ZIP archive. * diff --git a/src/Diff/ProjectDiffer.php b/src/Diff/ProjectDiffer.php new file mode 100644 index 0000000..29ffb97 --- /dev/null +++ b/src/Diff/ProjectDiffer.php @@ -0,0 +1,305 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\Diff; + +use Exelearning\ELPParser; + +/** + * Compare two parsed eXeLearning projects semantically. + */ +final class ProjectDiffer +{ + /** + * Compare two parsed projects. + * + * @param ELPParser $left Left project. + * @param ELPParser $right Right project. + * @param string $algorithm Hash algorithm for content/assets. + * + * @return array + */ + public function diff( + ELPParser $left, + ELPParser $right, + string $algorithm = 'sha256' + ): array { + $metadata = $this->diffMetadata($left, $right); + $pages = $this->diffItems( + $this->stripNestedPages($left->getPages()), + $this->stripNestedPages($right->getPages()), + 'page' + ); + $blocks = $this->diffItems( + $this->stripBlockComponents($left->getBlocks()), + $this->stripBlockComponents($right->getBlocks()), + 'block' + ); + $idevices = $this->diffItems( + $left->getIdevices(), + $right->getIdevices(), + 'idevice' + ); + $assets = $this->diffAssets($left, $right, $algorithm); + + $leftFingerprint = $left->getContentFingerprint($algorithm); + $rightFingerprint = $right->getContentFingerprint($algorithm); + + return [ + 'changed' => $leftFingerprint !== $rightFingerprint, + 'fingerprints' => [ + 'left' => $leftFingerprint, + 'right' => $rightFingerprint, + ], + 'metadata' => $metadata, + 'pages' => $pages, + 'blocks' => $blocks, + 'idevices' => $idevices, + 'assets' => $assets, + ]; + } + + /** + * Compare core metadata fields. + * + * @param ELPParser $left Left project. + * @param ELPParser $right Right project. + * + * @return array + */ + private function diffMetadata(ELPParser $left, ELPParser $right): array + { + $leftData = [ + 'formatFamily' => $left->getFormatFamily(), + 'formatVersion' => $left->getFormatVersion(), + 'title' => $left->getTitle(), + 'description' => $left->getDescription(), + 'author' => $left->getAuthor(), + 'license' => $left->getLicense(), + 'language' => $left->getLanguage(), + 'learningResourceType' => $left->getLearningResourceType(), + ]; + $rightData = [ + 'formatFamily' => $right->getFormatFamily(), + 'formatVersion' => $right->getFormatVersion(), + 'title' => $right->getTitle(), + 'description' => $right->getDescription(), + 'author' => $right->getAuthor(), + 'license' => $right->getLicense(), + 'language' => $right->getLanguage(), + 'learningResourceType' => $right->getLearningResourceType(), + ]; + + $changes = []; + + foreach ($leftData as $key => $before) { + $after = $rightData[$key] ?? null; + + if ($before !== $after) { + $changes[$key] = [ + 'before' => $before, + 'after' => $after, + ]; + } + } + + return $changes; + } + + /** + * Compare entity arrays by ID, falling back to stable positional keys. + * + * @param array> $left Left entities. + * @param array> $right Right entities. + * @param string $prefix Fallback key prefix. + * + * @return array{added:array,removed:array,changed:array} + */ + private function diffItems( + array $left, + array $right, + string $prefix + ): array { + $leftIndex = $this->indexItems($left, $prefix); + $rightIndex = $this->indexItems($right, $prefix); + + $added = array_values( + array_diff(array_keys($rightIndex), array_keys($leftIndex)) + ); + $removed = array_values( + array_diff(array_keys($leftIndex), array_keys($rightIndex)) + ); + $changed = []; + + foreach (array_intersect(array_keys($leftIndex), array_keys($rightIndex)) as $key) { + if ($leftIndex[$key] !== $rightIndex[$key]) { + $changed[] = $key; + } + } + + sort($added); + sort($removed); + sort($changed); + + return [ + 'added' => $added, + 'removed' => $removed, + 'changed' => $changed, + ]; + } + + /** + * Index entity arrays by ID or positional fallback. + * + * @param array> $items Entities. + * @param string $prefix Fallback key prefix. + * + * @return array> + */ + private function indexItems(array $items, string $prefix): array + { + $index = []; + + foreach ($items as $position => $item) { + $id = (string) ($item['id'] ?? ''); + $key = $id !== '' ? $id : '@' . $prefix . '-' . $position; + $index[$key] = $item; + } + + ksort($index); + + return $index; + } + + /** + * Remove nested blocks/iDevices from page-level comparisons. + * + * @param array> $pages Pages. + * + * @return array> + */ + private function stripNestedPages(array $pages): array + { + foreach ($pages as &$page) { + unset($page['blocks'], $page['idevices']); + } + unset($page); + + return $pages; + } + + /** + * Remove nested components from block-level comparisons. + * + * @param array> $blocks Blocks. + * + * @return array> + */ + private function stripBlockComponents(array $blocks): array + { + foreach ($blocks as &$block) { + unset($block['components']); + } + unset($block); + + return $blocks; + } + + /** + * Compare project resource files by package path and content hash. + * + * @param ELPParser $left Left project. + * @param ELPParser $right Right project. + * @param string $algorithm Hash algorithm. + * + * @return array{added:array,removed:array,modified:array} + */ + private function diffAssets( + ELPParser $left, + ELPParser $right, + string $algorithm + ): array { + $leftAssets = $this->assetHashes($left, $algorithm); + $rightAssets = $this->assetHashes($right, $algorithm); + + $added = array_values( + array_diff(array_keys($rightAssets), array_keys($leftAssets)) + ); + $removed = array_values( + array_diff(array_keys($leftAssets), array_keys($rightAssets)) + ); + $modified = []; + + foreach (array_intersect(array_keys($leftAssets), array_keys($rightAssets)) as $path) { + if ($leftAssets[$path] !== $rightAssets[$path]) { + $modified[] = $path; + } + } + + sort($added); + sort($removed); + sort($modified); + + return [ + 'added' => $added, + 'removed' => $removed, + 'modified' => $modified, + ]; + } + + /** + * Build resource path-to-hash map. + * + * @param ELPParser $parser Parsed project. + * @param string $algorithm Hash algorithm. + * + * @return array + */ + private function assetHashes( + ELPParser $parser, + string $algorithm + ): array { + $manifest = $parser->getPackageManifest(); + $paths = $manifest['resourceFiles'] ?? []; + + if (!is_array($paths)) { + $paths = []; + } + + $paths = array_values( + array_unique( + array_merge( + $paths, + $parser->getAssets(), + $parser->getOrphanAssets() + ) + ) + ); + sort($paths); + + $hashes = []; + foreach ($paths as $path) { + if (!is_string($path) || $path === '') { + continue; + } + + $hashes[$path] = $parser->getArchiveEntryFingerprint( + $path, + $algorithm + ); + } + + return $hashes; + } +} diff --git a/src/ELPParser.php b/src/ELPParser.php index bb74a84..501d318 100644 --- a/src/ELPParser.php +++ b/src/ELPParser.php @@ -18,7 +18,9 @@ use Exelearning\Archive\ArchiveReader; use Exelearning\Asset\AssetReferenceExtractor; use Exelearning\Exception\ElpParserException; +use Exelearning\Diff\ProjectDiffer; use Exelearning\Exception\UnsupportedFormatException; +use Exelearning\Fingerprint\FingerprintBuilder; use Exelearning\Model\Project; use Exelearning\Parser\LegacyParser; use Exelearning\Parser\OdeParser; @@ -1469,6 +1471,10 @@ public function toDetailedArray(): array 'internalLinks' => $this->getInternalLinks(), 'brokenInternalLinks' => $this->getBrokenInternalLinks(), 'packageManifest' => $this->getPackageManifest(), + 'fingerprints' => [ + 'archive' => $this->getArchiveFingerprint(), + 'content' => $this->getContentFingerprint(), + ], 'archiveEntries' => $this->archiveEntries, ]; } @@ -1533,6 +1539,78 @@ public function exportDetailedJson(?string $destinationPath = null): string return $json; } + /** + * Get an exact fingerprint of the archive bytes. + * + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function getArchiveFingerprint(string $algorithm = 'sha256'): string + { + return $this->archiveReader->hashArchive($algorithm); + } + + /** + * Get a fingerprint of one archive entry without loading it into memory. + * + * @param string $entryName Entry path. + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function getArchiveEntryFingerprint( + string $entryName, + string $algorithm = 'sha256' + ): string { + return $this->archiveReader->hashEntry($entryName, $algorithm); + } + + /** + * Get a deterministic normalized logical-content fingerprint. + * + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function getContentFingerprint(string $algorithm = 'sha256'): string + { + return (new FingerprintBuilder())->build($this, $algorithm); + } + + /** + * Determine whether another parsed project has the same normalized content. + * + * @param ELPParser $other Project to compare. + * @param string $algorithm Hash algorithm. + * + * @return bool + */ + public function hasSameContentAs( + ELPParser $other, + string $algorithm = 'sha256' + ): bool { + return hash_equals( + $this->getContentFingerprint($algorithm), + $other->getContentFingerprint($algorithm) + ); + } + + /** + * Compare this project with another parsed project. + * + * @param ELPParser $other Project to compare. + * @param string $algorithm Hash algorithm. + * + * @return array + */ + public function diff( + ELPParser $other, + string $algorithm = 'sha256' + ): array { + return (new ProjectDiffer())->diff($this, $other, $algorithm); + } + /** * Get normalized metadata information. * diff --git a/src/Fingerprint/FingerprintBuilder.php b/src/Fingerprint/FingerprintBuilder.php new file mode 100644 index 0000000..5d6f420 --- /dev/null +++ b/src/Fingerprint/FingerprintBuilder.php @@ -0,0 +1,182 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\Fingerprint; + +use Exelearning\ELPParser; +use Exelearning\Exception\ElpParserException; +use JsonException; + +/** + * Build deterministic logical-content fingerprints for parsed projects. + */ +final class FingerprintBuilder +{ + /** + * Build a normalized logical-content fingerprint. + * + * Packaging files and volatile project/version metadata are excluded. + * Parsed structure and project resource bytes are included. + * + * @param ELPParser $parser Parsed project. + * @param string $algorithm Hash algorithm. + * + * @return string + */ + public function build( + ELPParser $parser, + string $algorithm = 'sha256' + ): string { + if (!in_array($algorithm, hash_algos(), true)) { + throw new ElpParserException( + 'Unsupported hash algorithm: ' . $algorithm + ); + } + + $resources = $parser->getOdeResources(); + + foreach ( + [ + 'odeId', + 'odeVersionId', + 'odeVersionName', + 'exe_version', + 'eXeVersion', + 'isDownload', + ] as $volatileKey + ) { + unset($resources[$volatileKey]); + } + + $resourceFiles = $parser->getPackageManifest()['resourceFiles'] ?? []; + if (!is_array($resourceFiles)) { + $resourceFiles = []; + } + + $assetPaths = array_values( + array_unique( + array_merge( + $resourceFiles, + $parser->getAssets(), + $parser->getOrphanAssets() + ) + ) + ); + sort($assetPaths); + + $assetFingerprints = []; + foreach ($assetPaths as $path) { + if (!is_string($path) || $path === '') { + continue; + } + + $assetFingerprints[] = [ + 'path' => $path, + 'hash' => $parser->getArchiveEntryFingerprint( + $path, + $algorithm + ), + ]; + } + + $data = [ + 'formatFamily' => $parser->getFormatFamily(), + 'formatVersion' => $parser->getFormatVersion(), + 'summary' => [ + 'title' => $parser->getTitle(), + 'description' => $parser->getDescription(), + 'author' => $parser->getAuthor(), + 'license' => $parser->getLicense(), + 'language' => $parser->getLanguage(), + 'learningResourceType' => $parser->getLearningResourceType(), + ], + 'userPreferences' => $parser->getUserPreferences(), + 'odeProperties' => $parser->getOdeProperties(), + 'odeResources' => $resources, + 'pages' => $parser->getPages(), + 'assets' => $assetFingerprints, + ]; + + $normalized = $this->normalize($data); + + try { + $json = json_encode( + $normalized, + JSON_UNESCAPED_UNICODE + | JSON_UNESCAPED_SLASHES + | JSON_THROW_ON_ERROR + ); + } catch (JsonException $exception) { + throw new ElpParserException( + 'Unable to serialize normalized project content: ' + . $exception->getMessage(), + 0, + $exception + ); + } + + return hash($algorithm, $json); + } + + /** + * Recursively sort associative arrays while preserving list order. + * + * @param mixed $value Value to normalize. + * + * @return mixed + */ + private function normalize(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + if ($this->isList($value)) { + return array_map( + fn(mixed $item): mixed => $this->normalize($item), + $value + ); + } + + ksort($value); + + foreach ($value as $key => $item) { + $value[$key] = $this->normalize($item); + } + + return $value; + } + + /** + * PHP 8.0-compatible array_is_list implementation. + * + * @param array $value Array to inspect. + * + * @return bool + */ + private function isList(array $value): bool + { + $expected = 0; + + foreach (array_keys($value) as $key) { + if ($key !== $expected) { + return false; + } + + $expected++; + } + + return true; + } +} diff --git a/tests/Unit/FingerprintDiffTest.php b/tests/Unit/FingerprintDiffTest.php new file mode 100644 index 0000000..d88e43e --- /dev/null +++ b/tests/Unit/FingerprintDiffTest.php @@ -0,0 +1,184 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\ElpParser\Tests\Unit; + +use Exelearning\ELPParser; +use RuntimeException; +use ZipArchive; + +/** + * Create a minimal fingerprint/diff project fixture. + * + * @param string $title Project title. + * @param string $projectId Project ID. + * @param string $versionId Project version ID. + * @param string $exeVersion Application version. + * @param string $assetContent Resource bytes. + * + * @return string + */ +function createFingerprintFixture( + string $title, + string $projectId, + string $versionId, + string $exeVersion, + string $assetContent +): string { + $temporaryFile = tempnam(sys_get_temp_dir(), 'elp-fingerprint-'); + if ($temporaryFile === false) { + throw new RuntimeException('Unable to create temporary file.'); + } + + @unlink($temporaryFile); + $archivePath = $temporaryFile . '.elpx'; + + $xml = '' + . '' + . '' + . 'odeId' . $projectId . '' + . 'odeVersionId' . $versionId . '' + . 'exe_version' . $exeVersion . '' + . '' + . '' + . 'pp_title' . $title . '' + . 'pp_langen' + . '' + . '' + . ''; + + $zip = new ZipArchive(); + if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create temporary ELPX archive.'); + } + + $zip->addFromString('content.xml', $xml); + $zip->addFromString('content.dtd', ''); + $zip->addFromString('content/resources/data.bin', $assetContent); + $zip->close(); + + return $archivePath; +} + +it( + 'separates exact archive fingerprints from normalized content fingerprints', + function () { + $leftPath = createFingerprintFixture( + 'Same content', + '20260919190000AAAAAA', + '20260919190100BBBBBB', + '3.0', + 'asset-v1' + ); + $rightPath = createFingerprintFixture( + 'Same content', + '20260920190000CCCCCC', + '20260920190100DDDDDD', + '4.1.0', + 'asset-v1' + ); + + try { + $left = ELPParser::fromFile($leftPath); + $right = ELPParser::fromFile($rightPath); + + expect($left->getArchiveFingerprint()) + ->not->toBe($right->getArchiveFingerprint()); + expect($left->getContentFingerprint()) + ->toBe($right->getContentFingerprint()); + expect($left->hasSameContentAs($right))->toBeTrue(); + + $diff = $left->diff($right); + expect($diff['changed'])->toBeFalse(); + expect($diff['metadata'])->toBe([]); + expect($diff['assets']['modified'])->toBe([]); + } finally { + @unlink($leftPath); + @unlink($rightPath); + } + } +); + +it( + 'includes project resource bytes in normalized content fingerprints', + function () { + $leftPath = createFingerprintFixture( + 'Resource test', + '20260919190000AAAAAA', + '20260919190100BBBBBB', + '4.0.0', + 'asset-v1' + ); + $rightPath = createFingerprintFixture( + 'Resource test', + '20260919190000AAAAAA', + '20260919190200CCCCCC', + '4.0.0', + 'asset-v2' + ); + + try { + $left = ELPParser::fromFile($leftPath); + $right = ELPParser::fromFile($rightPath); + + expect($left->getArchiveEntryFingerprint('content/resources/data.bin')) + ->toBe(hash('sha256', 'asset-v1')); + expect($left->getContentFingerprint()) + ->not->toBe($right->getContentFingerprint()); + + $diff = $left->diff($right); + expect($diff['changed'])->toBeTrue(); + expect($diff['assets']['modified'])->toBe([ + 'content/resources/data.bin', + ]); + } finally { + @unlink($leftPath); + @unlink($rightPath); + } + } +); + +it( + 'reports semantic metadata changes', + function () { + $leftPath = createFingerprintFixture( + 'Before', + '20260919190000AAAAAA', + '20260919190100BBBBBB', + '4.0.0', + 'asset-v1' + ); + $rightPath = createFingerprintFixture( + 'After', + '20260919190000AAAAAA', + '20260919190200CCCCCC', + '4.0.0', + 'asset-v1' + ); + + try { + $diff = ELPParser::fromFile($leftPath)->diff( + ELPParser::fromFile($rightPath) + ); + + expect($diff['changed'])->toBeTrue(); + expect($diff['metadata']['title'])->toBe([ + 'before' => 'Before', + 'after' => 'After', + ]); + expect($diff['assets']['modified'])->toBe([]); + } finally { + @unlink($leftPath); + @unlink($rightPath); + } + } +);