Skip to content

Commit 31b84b1

Browse files
authored
Add zip compression for artifact but never unpack it (Not a standard OCI layer mediatype) (#606)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent c068dee commit 31b84b1

6 files changed

Lines changed: 322 additions & 19 deletions

File tree

src/main/java/land/oras/OCI.java

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import land.oras.utils.ArchiveUtils;
3838
import land.oras.utils.Const;
3939
import land.oras.utils.SupportedAlgorithm;
40+
import land.oras.utils.SupportedCompression;
4041
import org.jspecify.annotations.NonNull;
4142
import org.jspecify.annotations.Nullable;
4243
import org.slf4j.Logger;
@@ -163,26 +164,42 @@ protected final List<Layer> pushLayers(T ref, Annotations annotations, boolean w
163164
try {
164165
// Create tar.gz archive for directory
165166
if (Files.isDirectory(path.getPath())) {
166-
LocalPath tempTar = ArchiveUtils.tar(path);
167-
LocalPath tempArchive = ArchiveUtils.compress(tempTar, path.getMediaType());
167+
SupportedCompression compression = SupportedCompression.fromMediaType(path.getMediaType());
168+
169+
// If source need to be packed first
170+
boolean autoUnpack = compression.isAutoUnpack();
171+
LocalPath tempSource = autoUnpack ? ArchiveUtils.tar(path) : path;
172+
LocalPath tempArchive = ArchiveUtils.compress(tempSource, path.getMediaType());
173+
168174
if (withDigest) {
169175
ref = ref.withDigest(ref.getAlgorithm().digest(tempArchive.getPath()));
170176
}
171177
try (InputStream is = Files.newInputStream(tempArchive.getPath())) {
172178
String title = path.getPath().isAbsolute()
173179
? path.getPath().getFileName().toString()
174180
: path.getPath().toString();
181+
182+
// We store the filename, based on directory name if we don't auto unpack
183+
if (!autoUnpack) {
184+
title = "%s.%s".formatted(title, compression.getFileExtension());
185+
}
175186
LOG.debug("Uploading directory as archive with title: {}", title);
176187

177188
Map<String, String> layerAnnotations = annotations.hasFileAnnotations(title)
178189
? annotations.getFileAnnotations(title)
179190
: new LinkedHashMap<>(Map.of(Const.ANNOTATION_TITLE, title));
180191

181192
// Add oras digest/unpack
182-
layerAnnotations.put(
183-
Const.ANNOTATION_ORAS_CONTENT_DIGEST,
184-
ref.getAlgorithm().digest(tempTar.getPath()));
185-
layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "true");
193+
// For example zip can be packed application/zip but never unpacked by the runtime
194+
// This is convenience method to pack zip layer as directories
195+
if (compression.isAutoUnpack()) {
196+
layerAnnotations.put(
197+
Const.ANNOTATION_ORAS_CONTENT_DIGEST,
198+
ref.getAlgorithm().digest(tempSource.getPath()));
199+
layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "true");
200+
} else {
201+
layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "false");
202+
}
186203

187204
Layer layer = pushBlob(ref, is)
188205
.withMediaType(path.getMediaType())

src/main/java/land/oras/utils/ArchiveUtils.java

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.io.IOException;
2626
import java.io.InputStream;
2727
import java.io.OutputStream;
28+
import java.nio.charset.StandardCharsets;
2829
import java.nio.file.Files;
2930
import java.nio.file.Path;
3031
import java.nio.file.Paths;
@@ -35,9 +36,14 @@
3536
import java.util.stream.Stream;
3637
import land.oras.LocalPath;
3738
import land.oras.exception.OrasException;
39+
import org.apache.commons.compress.archivers.ArchiveEntry;
3840
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
3941
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
4042
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
43+
import org.apache.commons.compress.archivers.zip.AsiExtraField;
44+
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
45+
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
46+
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
4147
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
4248
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
4349
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorInputStream;
@@ -74,6 +80,18 @@ public static Path createTempTar() {
7480
}
7581
}
7682

83+
/**
84+
* Create a temporary zip file when uploading directory layers with zip media type
85+
* @return The path to the zip file
86+
*/
87+
public static Path createTempZip() {
88+
try {
89+
return Files.createTempFile("oras", ".zip");
90+
} catch (IOException e) {
91+
throw new OrasException("Failed to create temporary zip file", e);
92+
}
93+
}
94+
7795
/**
7896
* Create a temporary directory
7997
* @return The path to the temporary directory
@@ -86,6 +104,64 @@ public static Path createTempDir() {
86104
}
87105
}
88106

107+
/**
108+
* Zip a local source dire and return a temporary zip file as a local path
109+
* @param sourceDir The source directory
110+
* @return The local path to the zip file
111+
*/
112+
public static LocalPath zip(LocalPath sourceDir) {
113+
Path zipFile = createTempZip();
114+
boolean isAbsolute = sourceDir.getPath().isAbsolute();
115+
try (OutputStream fos = Files.newOutputStream(zipFile);
116+
BufferedOutputStream bos = new BufferedOutputStream(fos);
117+
ZipArchiveOutputStream zaos = new ZipArchiveOutputStream(bos)) {
118+
try (Stream<Path> paths = Files.walk(sourceDir.getPath())) {
119+
paths.forEach(path -> {
120+
LOG.trace("Visiting path: {}", path);
121+
try {
122+
Path baseName = isAbsolute ? sourceDir.getPath().getFileName() : sourceDir.getPath();
123+
Path relativePath = baseName.resolve(sourceDir.getPath().relativize(path));
124+
if (relativePath.toString().isEmpty()) {
125+
LOG.trace("Skipping root directory: {}", path);
126+
return;
127+
}
128+
String entryName = relativePath.toString();
129+
if (Files.isSymbolicLink(path)) {
130+
LOG.trace("Adding symlink entry to zip: {}", entryName);
131+
Path linkTarget = Files.readSymbolicLink(path);
132+
ZipArchiveEntry entry = new ZipArchiveEntry(entryName);
133+
AsiExtraField asiField = new AsiExtraField();
134+
asiField.setLinkedFile(linkTarget.toString());
135+
// 0120000 = S_IFLNK (symlink file type), 0755 = permissions
136+
asiField.setMode(0120755);
137+
entry.addExtraField(asiField);
138+
entry.setSize(0);
139+
zaos.putArchiveEntry(entry);
140+
} else if (Files.isDirectory(path)) {
141+
LOG.trace("Adding directory entry to zip: {}", entryName + "/");
142+
ZipArchiveEntry entry = new ZipArchiveEntry(entryName + "/");
143+
zaos.putArchiveEntry(entry);
144+
} else {
145+
LOG.trace("Adding file entry to zip: {}", entryName);
146+
ZipArchiveEntry entry = new ZipArchiveEntry(entryName);
147+
entry.setSize(Files.size(path));
148+
zaos.putArchiveEntry(entry);
149+
try (InputStream fis = Files.newInputStream(path)) {
150+
fis.transferTo(zaos);
151+
}
152+
}
153+
zaos.closeArchiveEntry();
154+
} catch (IOException e) {
155+
throw new OrasException("Failed to create zip file", e);
156+
}
157+
});
158+
}
159+
} catch (IOException e) {
160+
throw new OrasException("Failed to create zip file", e);
161+
}
162+
return LocalPath.of(zipFile, Const.ZIP_MEDIA_TYPE);
163+
}
164+
89165
/**
90166
* Create a tar.gz file from a directory
91167
* @param sourceDir The source directory
@@ -177,7 +253,7 @@ public static LocalPath tarcompress(LocalPath sourceDir, String mediaType) {
177253
* @param target The target directory
178254
* @throws IOException
179255
*/
180-
static void ensureSafeEntry(TarArchiveEntry entry, Path target) throws IOException {
256+
static void ensureSafeEntry(ArchiveEntry entry, Path target) throws IOException {
181257
// Prevent path traversal attacks
182258
Path outputPath = target.resolve(entry.getName()).normalize();
183259
Path normalizedTarget = target.toAbsolutePath().normalize();
@@ -237,6 +313,80 @@ public static Path untar(Path path) {
237313
return tempDir;
238314
}
239315

316+
/**
317+
* Extract a zip file to a target directory
318+
* @param path The zip file
319+
* @param target The target directory
320+
*/
321+
public static void unzip(Path path, Path target) {
322+
try {
323+
unzip(Files.newInputStream(path), target);
324+
} catch (IOException e) {
325+
throw new OrasException("Failed to extract zip file", e);
326+
}
327+
}
328+
329+
/**
330+
* Unzip a file to a temporary directory and return the local path to the temporary directory
331+
* @param fis The zip file input stream
332+
* @return The local path to the temporary directory
333+
*/
334+
static LocalPath unzip(InputStream fis) {
335+
Path tempDir = createTempDir();
336+
unzip(fis, tempDir);
337+
return LocalPath.of(tempDir);
338+
}
339+
340+
/**
341+
* Extract a zip file to a target directory
342+
* @param fis The zip file input stream
343+
* @param target The target directory
344+
*/
345+
static void unzip(InputStream fis, Path target) {
346+
// Open the zip file for reading
347+
try {
348+
try (BufferedInputStream bis = new BufferedInputStream(fis);
349+
ZipArchiveInputStream zais = new ZipArchiveInputStream(bis)) {
350+
ZipArchiveEntry entry;
351+
352+
// Iterate through zip entries
353+
while ((entry = zais.getNextEntry()) != null) {
354+
355+
// Prevent path traversal attacks
356+
Path outputPath = target.resolve(entry.getName()).normalize();
357+
358+
// Check if the entry is outside the target directory
359+
ensureSafeEntry(entry, target);
360+
361+
if (entry.isDirectory()) {
362+
LOG.debug("Extracting directory: {}", entry.getName());
363+
Files.createDirectories(outputPath);
364+
}
365+
// Check symlink from AsiExtraField
366+
else {
367+
AsiExtraField asiField = (AsiExtraField) entry.getExtraField(new AsiExtraField().getHeaderId());
368+
if (entry.isUnixSymlink() || (asiField != null && asiField.isLink())) {
369+
LOG.debug("Extracting symlink: {}", entry.getName());
370+
Files.createDirectories(outputPath.getParent());
371+
String linkStr = asiField != null
372+
? asiField.getLinkedFile()
373+
: new String(zais.readAllBytes(), StandardCharsets.UTF_8);
374+
Files.createSymbolicLink(outputPath, Paths.get(linkStr));
375+
} else {
376+
LOG.debug("Extracting file: {}", entry.getName());
377+
Files.createDirectories(outputPath.getParent());
378+
try (OutputStream out = Files.newOutputStream(outputPath)) {
379+
zais.transferTo(out);
380+
}
381+
}
382+
}
383+
}
384+
}
385+
} catch (IOException e) {
386+
throw new OrasException("Failed to extract zip file", e);
387+
}
388+
}
389+
240390
/**
241391
* Extract a tar file to a target directory
242392
* @param fis The archive stream
@@ -308,7 +458,7 @@ public static LocalPath uncompress(InputStream is, String mediaType) {
308458

309459
static LocalPath compressZstd(LocalPath tarFile) {
310460
LOG.trace("Compressing tar file to zstd archive");
311-
Path tarGzFile = Paths.get(tarFile.toString() + ".gz");
461+
Path tarGzFile = Paths.get(tarFile + ".gz");
312462
try (InputStream fis = Files.newInputStream(tarFile.getPath());
313463
BufferedInputStream bis = new BufferedInputStream(fis);
314464
OutputStream fos = Files.newOutputStream(tarGzFile);

src/main/java/land/oras/utils/Const.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ private Const() {
133133
*/
134134
public static final String BLOB_DIR_ZSTD_MEDIA_TYPE = "application/vnd.oci.image.layer.v1.tar+zstd";
135135

136+
/**
137+
* Zip media type
138+
*/
139+
public static final String ZIP_MEDIA_TYPE = "application/zip";
140+
136141
/**
137142
* The default artifact media type if not specified
138143
*/

src/main/java/land/oras/utils/SupportedCompression.java

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public enum SupportedCompression {
4040
/**
4141
* No compression
4242
*/
43-
NO_COMPRESSION(Const.DEFAULT_BLOB_MEDIA_TYPE, (localPath -> localPath), (is -> {
43+
NO_COMPRESSION(Const.DEFAULT_BLOB_MEDIA_TYPE, "tar", (localPath -> localPath), (is -> {
4444
// This is just a tar we need to copy the stream to a temporary file
4545
try {
4646
Path temp = ArchiveUtils.createTempTar();
@@ -51,21 +51,31 @@ public enum SupportedCompression {
5151
}
5252
})),
5353

54+
/**
55+
* ZIP
56+
*/
57+
ZIP(Const.ZIP_MEDIA_TYPE, "zip", ArchiveUtils::zip, ArchiveUtils::unzip),
58+
5459
/**
5560
* GZIP
5661
*/
57-
GZIP(Const.DEFAULT_BLOB_DIR_MEDIA_TYPE, ArchiveUtils::compressGzip, ArchiveUtils::uncompressGzip),
62+
GZIP(Const.DEFAULT_BLOB_DIR_MEDIA_TYPE, "gz", ArchiveUtils::compressGzip, ArchiveUtils::uncompressGzip),
5863

5964
/**
6065
* ZSTD
6166
*/
62-
ZSTD(Const.BLOB_DIR_ZSTD_MEDIA_TYPE, ArchiveUtils::compressZstd, ArchiveUtils::uncompressZstd);
67+
ZSTD(Const.BLOB_DIR_ZSTD_MEDIA_TYPE, "zst", ArchiveUtils::compressZstd, ArchiveUtils::uncompressZstd);
6368

6469
/**
6570
* The media type
6671
*/
6772
private final String mediaType;
6873

74+
/**
75+
* The file extension
76+
*/
77+
private final String fileExtension;
78+
6979
/**
7080
* The compress function
7181
*/
@@ -82,13 +92,31 @@ public enum SupportedCompression {
8292
*/
8393
SupportedCompression(
8494
String mediaType,
95+
String fileExtension,
8596
Function<LocalPath, LocalPath> compressFunction,
8697
Function<InputStream, LocalPath> uncompressFunction) {
8798
this.mediaType = mediaType;
99+
this.fileExtension = fileExtension;
88100
this.compressFunction = compressFunction;
89101
this.uncompressFunction = uncompressFunction;
90102
}
91103

104+
/**
105+
* Get the file extension
106+
* @return The file extension
107+
*/
108+
public String getFileExtension() {
109+
return fileExtension;
110+
}
111+
112+
/**
113+
* Whether the media type is auto unpacked (it's an image layer, not whatever media type the user specified)
114+
* @return True if the media type is auto unpacked by OCI runtime, false otherwise
115+
*/
116+
public boolean isAutoUnpack() {
117+
return getMediaType().startsWith("application/vnd.oci.image.layer.v1.tar");
118+
}
119+
92120
/**
93121
* Get the media type
94122
* @return The media type
@@ -103,9 +131,6 @@ public String getMediaType() {
103131
* @return The compressed path
104132
*/
105133
LocalPath compress(LocalPath path) {
106-
if (!path.getMediaType().equals(Const.DEFAULT_BLOB_MEDIA_TYPE)) {
107-
throw new OrasException("Can only compress tar media type. Given " + path.getMediaType());
108-
}
109134
return compressFunction.apply(path);
110135
}
111136

@@ -123,7 +148,7 @@ LocalPath uncompress(InputStream inputStream) {
123148
* @param mediaType The media type
124149
* @return The supported algorithm
125150
*/
126-
static SupportedCompression fromMediaType(String mediaType) {
151+
public static SupportedCompression fromMediaType(String mediaType) {
127152
for (SupportedCompression compression : SupportedCompression.values()) {
128153
if (mediaType.equalsIgnoreCase(compression.getMediaType())) {
129154
return compression;

0 commit comments

Comments
 (0)