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 /**
0 commit comments