Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
106 changes: 106 additions & 0 deletions src/Archive/ArchiveReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading