Skip to content

Commit ea48413

Browse files
[Server] Add ZIP as a second skill archive format
SkillArchiver now emits application/zip alongside application/gzip, giving hosts the two formats SEP-2640 expects every conforming host to support. The ZIP is built deterministically (sorted entries, DEFLATE, fixed 1980-01-01 timestamp) so the archive bytes -- and therefore the digest advertised in the discovery index -- stay stable across rebuilds.
1 parent 634f81e commit ea48413

3 files changed

Lines changed: 122 additions & 9 deletions

File tree

src/Server/Skill/SkillArchiver.php

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
* archive layout. Produced without temp files and with zeroed file metadata, so the same input
2121
* yields the same bytes (and therefore a stable digest).
2222
*
23-
* Currently emits gzip-compressed tar (`application/gzip`), which the SEP lists as one of the two
24-
* formats every conforming host is expected to support.
23+
* Emits gzip-compressed tar (`application/gzip`) and ZIP (`application/zip`) — the two formats
24+
* the SEP expects every conforming host to support.
2525
*
2626
* @author Johannes Wachter <johannes@sulu.io>
2727
*/
@@ -32,6 +32,7 @@ final class SkillArchiver
3232
*/
3333
public const FORMATS = [
3434
'application/gzip' => 'tar.gz',
35+
'application/zip' => 'zip',
3536
];
3637

3738
/**
@@ -46,6 +47,31 @@ public function pack(array $files, string $mimeType): string
4647
throw new InvalidArgumentException(\sprintf('Unsupported skill archive format "%s". Supported: %s.', $mimeType, implode(', ', array_keys(self::FORMATS))));
4748
}
4849

50+
return match ($mimeType) {
51+
'application/gzip' => $this->gzipTar($files),
52+
'application/zip' => $this->zip($files),
53+
};
54+
}
55+
56+
/**
57+
* @throws InvalidArgumentException if the format is unsupported
58+
*/
59+
public function extension(string $mimeType): string
60+
{
61+
if (!isset(self::FORMATS[$mimeType])) {
62+
throw new InvalidArgumentException(\sprintf('Unsupported skill archive format "%s". Supported: %s.', $mimeType, implode(', ', array_keys(self::FORMATS))));
63+
}
64+
65+
return self::FORMATS[$mimeType];
66+
}
67+
68+
/**
69+
* @param array<string, string> $files
70+
*
71+
* @throws RuntimeException if compression fails
72+
*/
73+
private function gzipTar(array $files): string
74+
{
4975
$gzip = gzencode($this->tar($files), 9);
5076
if (false === $gzip) {
5177
throw new RuntimeException('Failed to gzip skill archive.');
@@ -55,15 +81,54 @@ public function pack(array $files, string $mimeType): string
5581
}
5682

5783
/**
58-
* @throws InvalidArgumentException if the format is unsupported
84+
* Builds a deterministic ZIP archive: sorted entries, DEFLATE (method 8), and a fixed
85+
* 1980-01-01 timestamp so the same input yields the same bytes.
86+
*
87+
* @param array<string, string> $files
88+
*
89+
* @throws RuntimeException if compression fails
5990
*/
60-
public function extension(string $mimeType): string
91+
private function zip(array $files): string
6192
{
62-
if (!isset(self::FORMATS[$mimeType])) {
63-
throw new InvalidArgumentException(\sprintf('Unsupported skill archive format "%s". Supported: %s.', $mimeType, implode(', ', array_keys(self::FORMATS))));
93+
ksort($files);
94+
95+
$local = '';
96+
$central = '';
97+
$offset = 0;
98+
$count = 0;
99+
100+
foreach ($files as $path => $content) {
101+
$compressed = gzdeflate($content, 9);
102+
if (false === $compressed) {
103+
throw new RuntimeException(\sprintf('Failed to deflate skill file "%s".', $path));
104+
}
105+
106+
$crc = crc32($content) & 0xFFFFFFFF;
107+
108+
// version needed, flags, method (8 = deflate), mod time (0), mod date (0x0021 = 1980-01-01),
109+
// crc32, compressed size, uncompressed size, name length, extra length.
110+
$shared = pack('vvvvv', 20, 0, 8, 0, 0x0021)
111+
.pack('VVV', $crc, \strlen($compressed), \strlen($content))
112+
.pack('vv', \strlen($path), 0);
113+
114+
$localEntry = "PK\x03\x04".$shared.$path.$compressed;
115+
$local .= $localEntry;
116+
117+
$central .= "PK\x01\x02"
118+
.pack('v', 20) // version made by (MS-DOS, 2.0)
119+
.$shared // version needed .. extra length
120+
.pack('vvv', 0, 0, 0) // comment length, disk number start, internal attributes
121+
.pack('VV', 0, $offset) // external attributes, relative offset of local header
122+
.$path;
123+
124+
$offset += \strlen($localEntry);
125+
++$count;
64126
}
65127

66-
return self::FORMATS[$mimeType];
128+
return $local.$central."PK\x05\x06"
129+
.pack('vvvv', 0, 0, $count, $count)
130+
.pack('VV', \strlen($central), $offset)
131+
.pack('v', 0);
67132
}
68133

69134
/**

tests/Unit/Server/Skill/SkillArchiverTest.php

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,36 @@ public function testPackIsDeterministic(): void
4949
public function testExtensionForSupportedFormat(): void
5050
{
5151
$this->assertSame('tar.gz', (new SkillArchiver())->extension('application/gzip'));
52+
$this->assertSame('zip', (new SkillArchiver())->extension('application/zip'));
53+
}
54+
55+
public function testPackProducesReadableZip(): void
56+
{
57+
$files = $this->extractZip((new SkillArchiver())->pack([
58+
'SKILL.md' => "# Skill\n",
59+
'references/SECURITY.md' => "# Security\n",
60+
], 'application/zip'));
61+
62+
$this->assertSame("# Skill\n", $files['SKILL.md'] ?? null);
63+
$this->assertSame("# Security\n", $files['references/SECURITY.md'] ?? null);
64+
}
65+
66+
public function testPackZipIsDeterministic(): void
67+
{
68+
$archiver = new SkillArchiver();
69+
$files = ['SKILL.md' => 'hello', 'a/b.txt' => 'world'];
70+
71+
$this->assertSame(
72+
$archiver->pack($files, 'application/zip'),
73+
$archiver->pack($files, 'application/zip'),
74+
);
5275
}
5376

5477
public function testPackRejectsUnsupportedFormat(): void
5578
{
5679
$this->expectException(InvalidArgumentException::class);
5780

58-
(new SkillArchiver())->pack(['SKILL.md' => 'x'], 'application/zip');
81+
(new SkillArchiver())->pack(['SKILL.md' => 'x'], 'application/x-7z-compressed');
5982
}
6083

6184
/**
@@ -88,6 +111,31 @@ private function extract(string $gzippedTar): array
88111
}
89112
}
90113

114+
/**
115+
* @return array<string, string> relative path => content
116+
*/
117+
private function extractZip(string $zip): array
118+
{
119+
$path = sys_get_temp_dir().'/skill-archiver-'.bin2hex(random_bytes(6)).'.zip';
120+
file_put_contents($path, $zip);
121+
122+
try {
123+
$archive = new \ZipArchive();
124+
$this->assertTrue(true === $archive->open($path), 'Produced bytes are a readable ZIP archive');
125+
126+
$files = [];
127+
for ($i = 0; $i < $archive->numFiles; ++$i) {
128+
$name = (string) $archive->getNameIndex($i);
129+
$files[$name] = (string) $archive->getFromIndex($i);
130+
}
131+
$archive->close();
132+
133+
return $files;
134+
} finally {
135+
@unlink($path);
136+
}
137+
}
138+
91139
private function removeDirectory(string $directory): void
92140
{
93141
if (!is_dir($directory)) {

tests/Unit/Server/Skill/SkillProviderTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public function testUnsupportedArchiveFormatThrows(): void
189189
{
190190
$this->expectException(InvalidArgumentException::class);
191191

192-
(new SkillProvider())->registerInto(Server::builder(), self::FIXTURES, archiveFormats: ['application/zip']);
192+
(new SkillProvider())->registerInto(Server::builder(), self::FIXTURES, archiveFormats: ['application/x-7z-compressed']);
193193
}
194194

195195
/**

0 commit comments

Comments
 (0)