From 8c0cd429140522b141aa041b4073278d2d016518 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 6 Jun 2024 15:08:08 -0500 Subject: [PATCH] Resource and Resource Loader API --- pom.xml | 1 + resource/build-release-17 | 0 resource/pom.xml | 29 ++ .../common/resource/EmptyDirectoryStream.java | 27 ++ .../common/resource/JarFileResource.java | 126 ++++++ .../resource/JarFileResourceLoader.java | 92 +++++ .../resource/MappedDirectoryStream.java | 44 ++ .../common/resource/MemoryInputStream.java | 146 +++++++ .../common/resource/MemoryResource.java | 111 +++++ .../common/resource/PathResource.java | 127 ++++++ .../common/resource/PathResourceLoader.java | 55 +++ .../io/smallrye/common/resource/Resource.java | 200 +++++++++ .../common/resource/ResourceLoader.java | 52 +++ .../resource/ResourceURLConnection.java | 66 +++ .../resource/ResourceURLStreamHandler.java | 20 + .../common/resource/ResourceUtils.java | 383 ++++++++++++++++++ .../smallrye/common/resource/URLResource.java | 70 ++++ .../common/resource/URLResourceLoader.java | 29 ++ resource/src/main/java/module-info.yml | 4 + .../resource/JarFileResourceLoaderTests.java | 154 +++++++ .../common/resource/ResourceUtilsTests.java | 92 +++++ 21 files changed, 1828 insertions(+) create mode 100644 resource/build-release-17 create mode 100644 resource/pom.xml create mode 100644 resource/src/main/java/io/smallrye/common/resource/EmptyDirectoryStream.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/JarFileResource.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/JarFileResourceLoader.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/MappedDirectoryStream.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/MemoryInputStream.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/MemoryResource.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/PathResource.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/PathResourceLoader.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/Resource.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/ResourceLoader.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/ResourceURLConnection.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/ResourceURLStreamHandler.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/ResourceUtils.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/URLResource.java create mode 100644 resource/src/main/java/io/smallrye/common/resource/URLResourceLoader.java create mode 100644 resource/src/main/java/module-info.yml create mode 100644 resource/src/test/java/io/smallrye/common/resource/JarFileResourceLoaderTests.java create mode 100644 resource/src/test/java/io/smallrye/common/resource/ResourceUtilsTests.java diff --git a/pom.xml b/pom.xml index 89202555..6e22bfe4 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,7 @@ net os ref + resource version vertx-context diff --git a/resource/build-release-17 b/resource/build-release-17 new file mode 100644 index 00000000..e69de29b diff --git a/resource/pom.xml b/resource/pom.xml new file mode 100644 index 00000000..cbb336da --- /dev/null +++ b/resource/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + + io.smallrye.common + smallrye-common-parent + 2.5.0-SNAPSHOT + + + smallrye-common-resource + + SmallRye Common: Resources + + + + ${project.groupId} + smallrye-common-constraint + + + + org.junit.jupiter + junit-jupiter-engine + test + + + \ No newline at end of file diff --git a/resource/src/main/java/io/smallrye/common/resource/EmptyDirectoryStream.java b/resource/src/main/java/io/smallrye/common/resource/EmptyDirectoryStream.java new file mode 100644 index 00000000..5a7882ca --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/EmptyDirectoryStream.java @@ -0,0 +1,27 @@ +package io.smallrye.common.resource; + +import java.nio.file.DirectoryStream; +import java.util.Collections; +import java.util.Iterator; + +/** + * An empty directory stream. + */ +public final class EmptyDirectoryStream implements DirectoryStream { + private static final EmptyDirectoryStream INSTANCE = new EmptyDirectoryStream<>(); + + private EmptyDirectoryStream() { + } + + @SuppressWarnings("unchecked") + public static EmptyDirectoryStream instance() { + return (EmptyDirectoryStream) INSTANCE; + } + + public Iterator iterator() { + return Collections.emptyIterator(); + } + + public void close() { + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/JarFileResource.java b/resource/src/main/java/io/smallrye/common/resource/JarFileResource.java new file mode 100644 index 00000000..777dbd10 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/JarFileResource.java @@ -0,0 +1,126 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.DirectoryStream; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * A resource representing an entry in a JAR file. + */ +public final class JarFileResource extends Resource { + + private final URL base; + private final JarFile jarFile; + private final JarEntry jarEntry; + private URL url; + + JarFileResource(final URL base, final JarFile jarFile, final JarEntry jarEntry) { + super(jarEntry.getName()); + this.base = base; + this.jarFile = jarFile; + this.jarEntry = jarEntry; + } + + public URL url() { + URL url = this.url; + if (url == null) { + try { + // todo: Java 20+: URL.of(new URI("jar", null, base.toURI().toASCIIString() + "!/" + pathName()), + // new ResourceURLStreamHandler(this)); + url = this.url = new URL(null, + new URI("jar", null, base.toURI().toASCIIString() + "!/" + pathName()).toASCIIString(), + new ResourceURLStreamHandler(this)); + } catch (MalformedURLException | URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + return url; + } + + /** + * {@inheritDoc} + * + * @implNote This implementation does not recognize directories that do not have an explicit entry. + * This restriction may be lifted in future versions. + */ + public boolean isDirectory() { + return jarEntry.isDirectory(); + } + + public DirectoryStream openDirectoryStream() throws IOException { + if (!isDirectory()) { + return super.openDirectoryStream(); + } + return new DirectoryStream() { + Enumeration entries; + + public Iterator iterator() { + if (entries == null) { + entries = jarFile.entries(); + return new Iterator() { + Resource next; + + public boolean hasNext() { + String ourName = jarEntry.getName(); + while (next == null) { + if (!entries.hasMoreElements()) { + return false; + } + JarEntry e = entries.nextElement(); + String name = e.getName(); + int ourLen = ourName.length(); + if (name.startsWith(ourName) && !name.equals(ourName)) { + int idx = name.indexOf('/', ourLen); + if (idx == -1 || name.length() == idx + 1) { + next = new JarFileResource(base, jarFile, e); + } + break; + } + } + return true; + } + + public Resource next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Resource next = this.next; + this.next = null; + return next; + } + }; + } + throw new IllegalStateException(); + } + + public void close() { + entries = Collections.emptyEnumeration(); + } + }; + } + + public Instant modifiedTime() { + FileTime fileTime = jarEntry.getLastModifiedTime(); + return fileTime == null ? null : fileTime.toInstant(); + } + + public InputStream openStream() throws IOException { + return jarFile.getInputStream(jarEntry); + } + + public long size() { + return jarEntry.getSize(); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/JarFileResourceLoader.java b/resource/src/main/java/io/smallrye/common/resource/JarFileResourceLoader.java new file mode 100644 index 00000000..1d10d8bc --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/JarFileResourceLoader.java @@ -0,0 +1,92 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * A resource loader which corresponds to a JAR file. + */ +public final class JarFileResourceLoader implements ResourceLoader { + private final URL base; + private final JarFile jarFile; + private Path tempFile; + + /** + * Construct a new instance. + * + * @param jarPath the path of the JAR file (must not be {@code null}) + * @throws IOException if opening the JAR file fails for some reason + */ + public JarFileResourceLoader(final Path jarPath) throws IOException { + this.base = jarPath.toUri().toURL(); + jarFile = new JarFile(jarPath.toFile()); + } + + /** + * Construct a new instance from a JAR file contained within a resource. + * + * @param resource the resource of the JAR file (must not be {@code null}) + * @throws IOException if opening the JAR file fails for some reason + */ + public JarFileResourceLoader(final Resource resource) throws IOException { + // todo: this will be replaced with a version which opens the file in-place from a buffer + base = resource.url(); + JarFile jf = null; + if (resource instanceof PathResource pr) { + try { + // avoid using a temp file, if possible + jf = new JarFile(pr.path().toFile(), true, JarFile.OPEN_READ, JarFile.runtimeVersion()); + } catch (UnsupportedOperationException ignored) { + } + } + if (jf == null) { + tempFile = Files.createTempFile("srcr-tmp-", ".jar"); + try { + resource.copyTo(tempFile); + jf = new JarFile(tempFile.toFile(), true, JarFile.OPEN_READ, JarFile.runtimeVersion()); + } catch (Throwable t) { + try { + Files.delete(tempFile); + } catch (Throwable t2) { + t.addSuppressed(t2); + } + throw t; + } + } + jarFile = jf; + } + + public Resource findResource(final String path) { + String canonPath = ResourceUtils.canonicalizeRelativePath(path); + JarEntry jarEntry = jarFile.getJarEntry(canonPath); + if (jarEntry != null) { + return new JarFileResource(base, jarFile, jarEntry); + } else { + jarEntry = jarFile.getJarEntry(canonPath + "/"); + if (jarEntry != null) { + return new JarFileResource(base, jarFile, jarEntry); + } else { + return null; + } + } + } + + public void close() { + try { + jarFile.close(); + } catch (IOException ignored) { + } + if (tempFile != null) { + try { + Files.delete(tempFile); + } catch (IOException ignored) { + } finally { + tempFile = null; + } + } + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/MappedDirectoryStream.java b/resource/src/main/java/io/smallrye/common/resource/MappedDirectoryStream.java new file mode 100644 index 00000000..037db92d --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/MappedDirectoryStream.java @@ -0,0 +1,44 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.util.Iterator; +import java.util.function.Function; + +import io.smallrye.common.constraint.Assert; + +/** + * A directory stream to map one kind of entry to another. + */ +public final class MappedDirectoryStream implements DirectoryStream { + private final DirectoryStream delegate; + private final Function mappingFunction; + + /** + * Construct a new instance. + * + * @param delegate the delegate stream (must not be {@code null}) + * @param mappingFunction the mapping function (must not be {@code null}) + */ + public MappedDirectoryStream(final DirectoryStream delegate, final Function mappingFunction) { + this.delegate = Assert.checkNotNullParam("delegate", delegate); + this.mappingFunction = Assert.checkNotNullParam("mappingFunction", mappingFunction); + } + + public Iterator iterator() { + Iterator itr = delegate.iterator(); + return new Iterator() { + public boolean hasNext() { + return itr.hasNext(); + } + + public R next() { + return mappingFunction.apply(itr.next()); + } + }; + } + + public void close() throws IOException { + delegate.close(); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/MemoryInputStream.java b/resource/src/main/java/io/smallrye/common/resource/MemoryInputStream.java new file mode 100644 index 00000000..81952ae6 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/MemoryInputStream.java @@ -0,0 +1,146 @@ +package io.smallrye.common.resource; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * An input stream over a segment of memory. + */ +public final class MemoryInputStream extends InputStream { + private static final byte[] EMPTY_BYTES = new byte[0]; + private static final ByteBuffer CLOSED = ByteBuffer.allocateDirect(0); + + ByteBuffer buf; + int mark = -1; + + /** + * Construct a new instance for a byte buffer. + * The given buffer contents are not copied. + * Consuming the stream will not affect the buffer's position or limit. + * Modifying the buffer's position or limit will not affect operation of the stream. + * + * @param buffer the byte buffer containing the stream data (must not be {@code null}) + */ + public MemoryInputStream(final ByteBuffer buffer) { + buf = buffer.duplicate(); + } + + /** + * Construct a new instance for a byte array. + * The byte array is not copied. + * + * @param bytes the byte array (must not be {@code null}) + */ + public MemoryInputStream(final byte[] bytes) { + this(ByteBuffer.wrap(bytes)); + } + + // todo: MemorySegment variation + + public int read() throws IOException { + ByteBuffer buf = this.buf; + checkClosed(buf); + return buf.hasRemaining() ? Byte.toUnsignedInt(buf.get()) : -1; + } + + public int read(final byte[] b, final int off, final int len) throws IOException { + Objects.checkFromIndexSize(off, len, b.length); + ByteBuffer buf = this.buf; + checkClosed(buf); + int rem = buf.remaining(); + if (rem == 0) { + return -1; + } + int cnt = Math.min(len, rem); + buf.get(b, off, cnt); + return cnt; + } + + public byte[] readAllBytes() throws IOException { + ByteBuffer buf = this.buf; + checkClosed(buf); + int rem = buf.remaining(); + if (rem == 0) { + return EMPTY_BYTES; + } + byte[] bytes = new byte[rem]; + buf.get(bytes); + return bytes; + } + + public long skip(final long n) throws IOException { + ByteBuffer buf = this.buf; + checkClosed(buf); + int pos = buf.position(); + int lim = buf.limit(); + int cnt = (int) Math.min(n, lim - pos); + if (cnt > 0) { + buf.position(pos + cnt); + } + return cnt; + } + + public long transferTo(final OutputStream out) throws IOException { + ByteBuffer buf = this.buf; + checkClosed(buf); + int pos = buf.position(); + int lim = buf.limit(); + if (pos == lim) { + return 0; + } else if (out instanceof FileOutputStream) { + FileOutputStream fos = (FileOutputStream) out; + return fos.getChannel().write(buf); + } + int rem = lim - pos; + if (buf.hasArray()) { + // shortcut + int offs = buf.arrayOffset() + pos; + out.write(buf.array(), offs, rem); + buf.position(lim); + return rem; + } else if (rem <= 8192) { + byte[] b = readAllBytes(); + out.write(b); + return b.length; + } else { + // not much else we can do to improve on the default case + return super.transferTo(out); + } + } + + public void mark(final int bytes) { + mark = buf.position(); + } + + public void reset() throws IOException { + ByteBuffer buf = this.buf; + checkClosed(buf); + int mark = this.mark; + if (mark == -1) { + throw new IOException("No mark set"); + } + buf.position(mark); + } + + public boolean markSupported() { + return true; + } + + public int available() { + return buf.remaining(); + } + + public void close() { + buf = CLOSED; + } + + private static void checkClosed(final ByteBuffer buf) throws IOException { + if (buf == CLOSED) { + throw new IOException("Stream closed"); + } + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/MemoryResource.java b/resource/src/main/java/io/smallrye/common/resource/MemoryResource.java new file mode 100644 index 00000000..3b309cb4 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/MemoryResource.java @@ -0,0 +1,111 @@ +package io.smallrye.common.resource; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.channels.SelectableChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; + +import io.smallrye.common.constraint.Assert; + +/** + * An in-memory resource. + */ +public final class MemoryResource extends Resource { + // todo: switch to MemorySegment + private final ByteBuffer data; + private final Instant modifiedTime = Instant.now(); + private URL url; + + /** + * Construct a new instance. + * The given buffer contents are not copied. + * Accessing the resource will not affect the buffer's position or limit. + * Modifying the buffer's position or limit will not affect the contents of the resource. + * + * @param buffer the byte buffer containing the resource data (must not be {@code null}) + * @param pathName the resource path name (must not be {@code null}) + */ + public MemoryResource(final String pathName, final ByteBuffer data) { + super(pathName); + this.data = Assert.checkNotNullParam("data", data).asReadOnlyBuffer(); + } + + /** + * Construct a new instance for a byte array. + * The byte array is not copied. + * + * @param bytes the byte array (must not be {@code null}) + * @param pathName the resource path name (must not be {@code null}) + */ + public MemoryResource(final String pathName, final byte[] data) { + this(pathName, ByteBuffer.wrap(data)); + } + + // todo: MemorySegment ctor + + public URL url() { + URL url = this.url; + if (url == null) { + try { + this.url = url = new URL("memory", null, -1, pathName(), new ResourceURLStreamHandler(this)); + } catch (MalformedURLException e) { + throw new UncheckedIOException("Unexpected URL problem", e); + } + } + return url; + } + + public MemoryInputStream openStream() { + return new MemoryInputStream(data); + } + + public ByteBuffer asBuffer() { + return data.duplicate(); + } + + public long copyTo(final Path destination) throws IOException { + try (SeekableByteChannel ch = Files.newByteChannel(destination, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + return copyTo(ch); + } + } + + public long copyTo(final WritableByteChannel channel) throws IOException { + if (channel instanceof SelectableChannel sc && !sc.isBlocking()) { + return super.copyTo(channel); + } + long cnt = 0; + ByteBuffer buf = data.duplicate(); + while (buf.hasRemaining()) { + long res = channel.write(buf); + cnt += res; + } + return cnt; + } + + public long copyTo(final OutputStream destination) throws IOException { + if (destination instanceof FileOutputStream fos) { + return copyTo(fos.getChannel()); + } else { + return super.copyTo(destination); + } + } + + public Instant modifiedTime() { + return modifiedTime; + } + + public long size() { + return data.remaining(); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/PathResource.java b/resource/src/main/java/io/smallrye/common/resource/PathResource.java new file mode 100644 index 00000000..7f58c60d --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/PathResource.java @@ -0,0 +1,127 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.time.Instant; + +import io.smallrye.common.constraint.Assert; + +/** + * A resource corresponding to a {@link Path}. + */ +public final class PathResource extends Resource { + /** + * Only allow cached mapping when the resource is very large. + */ + private static final long MAP_THRESHOLD = 128 << 20; // 128MiB + private final Path path; + private URL url; + private MappedByteBuffer mapped; + + /** + * Construct a new instance. + * + * @param pathName the relative path name (must not be {@code null}) + * @param path the path (must not be {@code null}) + */ + public PathResource(final String pathName, final Path path) { + super(pathName); + this.path = Assert.checkNotNullParam("path", path); + } + + /** + * {@return the path of this resource} + */ + public Path path() { + return path; + } + + public URL url() { + URL url = this.url; + if (url == null) { + try { + url = this.url = path.toUri().toURL(); + } catch (MalformedURLException e) { + throw new IllegalStateException("Unexpected URL problem", e); + } + } + return url; + } + + public DirectoryStream openDirectoryStream() throws IOException { + return new MappedDirectoryStream<>( + Files.newDirectoryStream(path), + p -> new PathResource(pathName() + '/' + p.getFileName().toString(), p)); + } + + public boolean isDirectory() { + return Files.isDirectory(path); + } + + public InputStream openStream() throws IOException { + return Files.newInputStream(path); + } + + public ByteBuffer asBuffer() throws IOException { + MappedByteBuffer mapped = this.mapped; + if (mapped == null) { + long size = size(); + if (size >= MAP_THRESHOLD) { + // map the (large) file into memory + synchronized (this) { + mapped = this.mapped; + if (mapped == null) { + try (FileChannel fc = FileChannel.open(path(), StandardOpenOption.READ)) { + if (fc.size() > Integer.MAX_VALUE) { + throw new OutOfMemoryError("Resource is too large to load into a buffer"); + } + mapped = this.mapped = fc.map(FileChannel.MapMode.READ_ONLY, 0, size); + } + } + } + } + } + if (mapped == null) { + // just read the bytes + return ByteBuffer.wrap(Files.readAllBytes(path)); + } else { + return mapped.duplicate(); + } + } + + public String asString(final Charset charset) throws IOException { + return Files.readString(path(), charset); + } + + public Instant modifiedTime() { + try { + FileTime fileTime = Files.getLastModifiedTime(path()); + return fileTime.toMillis() == 0 ? null : fileTime.toInstant(); + } catch (IOException e) { + return null; + } + } + + public long size() { + MappedByteBuffer mapped = this.mapped; + if (mapped != null) { + return mapped.capacity(); + } + try { + return Files.size(path); + } catch (IOException e) { + return -1; + } + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/PathResourceLoader.java b/resource/src/main/java/io/smallrye/common/resource/PathResourceLoader.java new file mode 100644 index 00000000..30a8f914 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/PathResourceLoader.java @@ -0,0 +1,55 @@ +package io.smallrye.common.resource; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Path; + +import io.smallrye.common.constraint.Assert; + +/** + * + */ +public final class PathResourceLoader implements ResourceLoader { + private final Path base; + private Closeable c; + + /** + * Construct a new instance. + * Note: to open a JAR file, use {@link JarFileResourceLoader} instead. + * + * @param base the base path (must not be {@code null}) + */ + public PathResourceLoader(final Path base) { + this.c = null; + this.base = Assert.checkNotNullParam("base", base); + } + + /** + * Construct a new instance from a filesystem's root. + * The filesystem is closed when this resource loader is closed. + * Note: to open a JAR file, use {@link JarFileResourceLoader} instead. + * + * @param fs the filesystem (must not be {@code null}) + */ + public PathResourceLoader(final FileSystem fs) { + this.c = Assert.checkNotNullParam("fs", fs); + this.base = fs.getPath("/"); + } + + public Resource findResource(final String path) { + String canon = ResourceUtils.canonicalizeRelativePath(path); + return new PathResource(canon, base.resolve(canon)); + } + + public void close() { + Closeable c = this.c; + if (c != null) { + try { + c.close(); + } catch (IOException ignored) { + } + this.c = null; + } + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/Resource.java b/resource/src/main/java/io/smallrye/common/resource/Resource.java new file mode 100644 index 00000000..2717b23f --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/Resource.java @@ -0,0 +1,200 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.channels.SelectableChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.Charset; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.ProtectionDomain; +import java.time.Instant; +import java.util.function.Function; + +import io.smallrye.common.constraint.Assert; + +/** + * A handle to a loadable resource, which usually will come from a JAR or the filesystem. + */ +public abstract class Resource { + + private final String path; + + /** + * Construct a new instance. + * + * @param path the resource path (must not be {@code null}) + */ + protected Resource(final String path) { + this.path = ResourceUtils.canonicalizeRelativePath(Assert.checkNotNullParam("path", path)); + } + + /** + * {@return the resource's relative path} + * The returned path is relative (that is, it does not start with {@code /}) + * and canonical (that is, contains no sequences of more than one consecutive {@code /}, contains + * no {@code .} or {@code ..} segments, and does not end with a {@code /}). + */ + public final String pathName() { + return path; + } + + /** + * {@return the resource URL (not null)} + * If the resource location information cannot be converted to a URL, an exception may be thrown. + */ + public abstract URL url(); + + /** + * Open an input stream to read this resource. + * + * @return the input stream (not {@code null}) + * @throws IOException if the input stream could not be opened or the resource is a directory + */ + public abstract InputStream openStream() throws IOException; + + /** + * Perform the given action on the input stream of this resource. + * + * @param function an action to perform (must not be {@code null}) + * @return the result of the action function + * @param the type of the function result + * @throws IOException if the stream could not be opened, or the resource is a directory, or the + * action throws an instance of {@link UncheckedIOException} + */ + public R readStream(Function function) throws IOException { + try (InputStream is = openStream()) { + return function.apply(is); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + /** + * Open a directory stream to read the contents of this directory. + * Not every resource implementation supports directory access. + * + * @return the directory stream (not {@code null}) + * @throws IOException if the directory could not be opened or the resource is not a directory + */ + public DirectoryStream openDirectoryStream() throws IOException { + throw new IOException("Not a directory"); + } + + /** + * {@return true if this resource represents a directory, or false otherwise} + * Not every resource implementation supports directory access. + */ + public boolean isDirectory() { + return false; + } + + /** + * {@return the bytes of this resource, as a read-only byte buffer} + * The buffer is suitable for passing to {@link ClassLoader#defineClass(String, ByteBuffer, ProtectionDomain)}. + * The default implementation reads all of the resource bytes from the stream returned by {@link #openStream()}. + * Other implementations might return a buffer for data already contained in memory, or might return a memory-mapped + * buffer if the resource is very large. + * The buffer might or might not be cached on the resource. + * Because of this, care should be taken to avoid calling this method repeatedly for a single resource. + * + * @implSpec Implementers must ensure that the returned buffer is read-only. + * + * @throws IOException if the content could not be read + * @throws OutOfMemoryError if the size of the resource is greater than the maximum allowed size of a buffer + */ + public ByteBuffer asBuffer() throws IOException { + try (InputStream is = openStream()) { + return ByteBuffer.wrap(is.readAllBytes()).asReadOnlyBuffer(); + } + } + + /** + * Copy the bytes of this resource to the given destination. + * The copy may fail before all of the bytes have been transferred; + * in this case the content and state of the destination are undefined. + *

+ * The path is opened as if with the following options: + *

    + *
  • {@link StandardOpenOption#CREATE}
  • + *
  • {@link StandardOpenOption#TRUNCATE_EXISTING}
  • + *
  • {@link StandardOpenOption#WRITE}
  • + *
+ * + * @param destination the destination path (must not be {@code null}) + * @return the number of bytes copied + * @throws IOException if the copy fails + */ + public long copyTo(Path destination) throws IOException { + try (InputStream is = openStream()) { + return Files.copy(is, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Copy the bytes of this resource to the given destination. + * The copy may fail before all of the bytes have been transferred; + * in this case the content and state of the destination are undefined. + * The destination stream is not closed. + * + * @param destination the destination stream (must not be {@code null}) + * @return the number of bytes copied + * @throws IOException if the copy fails + */ + public long copyTo(OutputStream destination) throws IOException { + try (InputStream is = openStream()) { + return is.transferTo(destination); + } + } + + /** + * Copy the bytes of this resource to the given destination. + * The copy may fail before all of the bytes have been transferred; + * in this case the content and state of the destination are undefined. + * The destination channel is not closed. + * + * @param destination the destination channel (must not be {@code null} and must not be non-blocking) + * @return the number of bytes copied + * @throws IOException if the copy fails + */ + public long copyTo(WritableByteChannel channel) throws IOException { + if (channel instanceof SelectableChannel sc && !sc.isBlocking()) { + throw new IllegalArgumentException("Channel must not be non-blocking"); + } + ByteBuffer buf = asBuffer(); + long c = 0; + while (buf.hasRemaining()) { + c += channel.write(buf); + } + return c; + } + + /** + * {@return the resource content as a string} + * + * @param charset the character set to use for decoding (must not be {@code null}) + * @throws IOException if the content could not be read + */ + public String asString(Charset charset) throws IOException { + return charset.newDecoder().decode(asBuffer()).toString(); + } + + /** + * {@return the modification time of the resource, or null if the time is unknown} + */ + public Instant modifiedTime() { + return null; + } + + /** + * {@return the size of the resource, or -1 if the size is not known} + */ + public abstract long size(); +} diff --git a/resource/src/main/java/io/smallrye/common/resource/ResourceLoader.java b/resource/src/main/java/io/smallrye/common/resource/ResourceLoader.java new file mode 100644 index 00000000..cd22e660 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/ResourceLoader.java @@ -0,0 +1,52 @@ +package io.smallrye.common.resource; + +import java.io.Closeable; +import java.io.IOException; + +/** + * A loader which can find resources by their path. + */ +public interface ResourceLoader extends Closeable { + + /** + * A resource loader containing no resources. + */ + ResourceLoader EMPTY = path -> null; + + /** + * Find a resource from this loader. + * + * @param path the resource path (must not be {@code null}) + * @return the loaded resource, or {@code null} if no resource is found at the given path + * @throws IOException if the resource could not be loaded + */ + Resource findResource(String path) throws IOException; + + /** + * Get a child resource loader for the given child path. + * This method always returns a resource loader, even if the path does not exist. + * Closing the child loader does not close the enclosing loader. + * + * @param path the relative sub-path (must not be {@code null}) + * @return the resource loader (not {@code null}, may be empty) + */ + default ResourceLoader getChildLoader(String path) { + String subPath = ResourceUtils.canonicalizeRelativePath(path); + if (subPath.isEmpty()) { + return this; + } + return p -> findResource(subPath + '/' + p); + } + + /** + * Hint that this resource loader is unlikely to be used in the near future. + */ + default void release() { + } + + /** + * Release any system resources or allocations associated with this resource loader. + */ + default void close() { + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/ResourceURLConnection.java b/resource/src/main/java/io/smallrye/common/resource/ResourceURLConnection.java new file mode 100644 index 00000000..e6b0c881 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/ResourceURLConnection.java @@ -0,0 +1,66 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.ByteBuffer; +import java.time.Instant; + +import io.smallrye.common.constraint.Assert; + +/** + * A URL connection backed by a resource. + */ +public final class ResourceURLConnection extends URLConnection { + private final Resource resource; + + ResourceURLConnection(final URL url, final Resource resource) { + super(Assert.checkNotNullParam("url", url)); + this.resource = Assert.checkNotNullParam("resource", resource); + } + + /** + * {@return the resource associated with this connection (not null)} + */ + public Resource resource() { + return resource; + } + + public void connect() { + } + + public long getContentLengthLong() { + return resource.size(); + } + + public String getContentType() { + return "application/octet-stream"; + } + + public long getLastModified() { + Instant instant = resource.modifiedTime(); + return instant == null ? 0 : instant.toEpochMilli(); + } + + public Object getContent(final Class... classes) throws IOException { + for (Class clazz : classes) { + if (clazz == ByteBuffer.class) { + return resource.asBuffer(); + } else if (clazz == byte[].class) { + return getInputStream().readAllBytes(); + } else if (clazz == Resource.class) { + return resource; + } + } + return null; + } + + public Object getContent() throws IOException { + return getContent(byte[].class); + } + + public InputStream getInputStream() throws IOException { + return resource.openStream(); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/ResourceURLStreamHandler.java b/resource/src/main/java/io/smallrye/common/resource/ResourceURLStreamHandler.java new file mode 100644 index 00000000..18a3eeb0 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/ResourceURLStreamHandler.java @@ -0,0 +1,20 @@ +package io.smallrye.common.resource; + +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * A simple resource-based URL stream handler. + */ +final class ResourceURLStreamHandler extends URLStreamHandler { + private final Resource resource; + + ResourceURLStreamHandler(final Resource resource) { + this.resource = resource; + } + + protected URLConnection openConnection(final URL u) { + return new ResourceURLConnection(u, resource); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/ResourceUtils.java b/resource/src/main/java/io/smallrye/common/resource/ResourceUtils.java new file mode 100644 index 00000000..7698ce69 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/ResourceUtils.java @@ -0,0 +1,383 @@ +package io.smallrye.common.resource; + +/** + * Miscellaneous resource-related utilities. + */ +final class ResourceUtils { + private ResourceUtils() { + } + + private static final int ST_I_INIT = 0; + private static final int ST_I_IGNORE = 1; + private static final int ST_I_D1 = 3; + private static final int ST_I_D2 = 4; + + private static final int ST_T_INIT = 5; + private static final int ST_T_SLASH = 6; + private static final int ST_T_D1 = 7; + private static final int ST_T_D2 = 8; + private static final int ST_T_CONTENT = 9; + + private static final int ST_M_INIT = 10; + private static final int ST_M_D1 = 11; + private static final int ST_M_D2 = 12; + private static final int ST_M_IGNORE = 13; + + private static final int ST_C_INIT = 0; + private static final int ST_C_CONTENT = 1; + private static final int ST_C_D1 = 2; + private static final int ST_C_D2 = 3; + + private static final int EOF = -1; + + /** + * An efficient relative path string canonicalizer which avoids copying and allocation to the maximum possible extent. + * The canonical path has no {@code .} or {@code ..} segments, + * does not contain consecutive {@code /}, + * and does not begin or end with a {@code /}. + * + * @param path the path name (must not be {@code null}) + * @return the canonical equivalent path (not {@code null}) + */ + static String canonicalizeRelativePath(String path) { + final int length = path.length(); + if (length == 0) { + return path; + } + int idx = length; + int state = ST_I_INIT; + // state-specific values + int t_end = -1, t_start = -1; + int skip = 0; + // current character + int c; + // main state machine -- iterate over string *in reverse* + for (;;) { + c = idx == 0 ? EOF : path.charAt(idx - 1); + + // on entry: + // idx is the index of the character after c (so it can be used as exclusive-end) + // t_end is the end index (exclusive) of the trailing substring to potentially return + // t_start is the start index (inclusive) of the middle substring to potentially return + // buf is the copied name buffer; the name always ends at the end of the buffer array + // buf_idx is the index (inclusive) of the first character in the copied name buffer + // skip is the number of outstanding .. segments to skip + switch (state) { + + // ST_I_*: initial states where we are not actually capturing anything yet + + case ST_I_INIT -> { + switch (c) { + case '.' -> state = ST_I_D1; + case '/' -> { + } + case EOF -> { + return ""; + } + default -> { + if (skip > 0) { + // ignore the rest + skip--; + state = ST_I_IGNORE; + } else { + // start capturing, possibly + t_end = idx; + state = ST_T_INIT; + } + } + } + } + case ST_I_IGNORE -> { + switch (c) { + case '/' -> state = ST_I_INIT; + default -> { + } + } + } + case ST_I_D1 -> { + switch (c) { + case '/' -> state = ST_I_INIT; + case '.' -> state = ST_I_D2; + case EOF -> { + return ""; + } + default -> { + if (skip > 0) { + // ignore the rest + skip--; + state = ST_I_IGNORE; + } else { + // start capturing, possibly + t_end = idx + 1; + state = ST_T_INIT; + } + } + } + } + case ST_I_D2 -> { + switch (c) { + case EOF -> { + return ""; + } + case '/' -> { + skip++; + state = ST_I_INIT; + } + default -> { + if (skip > 0) { + // ignore the rest + skip--; + state = ST_I_IGNORE; + } else { + // start capturing, possibly + t_end = idx + 2; + state = ST_T_INIT; + } + } + } + } + + // ST_T_*: path ends at t_end; at least one captured character + + case ST_T_INIT -> { + switch (c) { + case EOF -> { + // this returns `path` if t_end == length + return path.substring(0, t_end); + } + case '/' -> state = ST_T_SLASH; + case '.' -> state = ST_T_D1; + default -> state = ST_T_CONTENT; + } + } + case ST_T_SLASH -> { + switch (c) { + case EOF -> { + // path starts with single / + return path.substring(1, t_end); + } + case '.' -> state = ST_T_D1; + case '/' -> { + // string is `path[0,idx) + "//" + valid_path + path[t_end,length) + t_start = idx + 1; // idx + 1 is the character after "//" + state = ST_M_INIT; + } + default -> state = ST_T_INIT; + } + } + case ST_T_D1 -> { + switch (c) { + case EOF -> { + // path starts with ./ + return path.substring(2, t_end); + } + case '.' -> state = ST_T_D2; + case '/' -> { + // string is `path[0,idx) + "/./" + valid_path + path[t_end,length) + t_start = idx + 2; // idx + 2 is the character after "/./" + state = ST_M_INIT; + } + default -> state = ST_T_CONTENT; + } + } + case ST_T_D2 -> { + switch (c) { + case EOF -> { + // path starts with ../ + return path.substring(3, t_end); + } + case '/' -> { + // string is `path[0,idx) + "/../" + valid_path + path[t_end,length) + skip++; + t_start = idx + 3; // idx + 3 is the character after "/../" + state = ST_M_INIT; + } + default -> state = ST_T_CONTENT; + } + } + case ST_T_CONTENT -> { + switch (c) { + case EOF -> { + // this returns `path` if t_end == length + return path.substring(0, t_end); + } + case '/' -> state = ST_T_SLASH; + default -> { + } + } + } + + // ST_M_*: path starts at t_start and ends at t_end; there might be more valid path + + case ST_M_INIT -> { + switch (c) { + case EOF -> { + return path.substring(t_start, t_end); + } + case '.' -> state = ST_M_D1; + case '/' -> { + } + default -> { + if (skip > 0) { + skip--; + state = ST_M_IGNORE; + } else { + // multiple, separated path segments; go to slow path + return canonicalizeRelativePathWithCopy(path, t_start, t_end, idx); + } + } + } + } + case ST_M_D1 -> { + switch (c) { + case EOF -> { + // path starts with ./ + return path.substring(t_start, t_end); + } + case '.' -> state = ST_M_D2; + case '/' -> state = ST_M_INIT; + default -> { + if (skip > 0) { + skip--; + state = ST_M_IGNORE; + } else { + // multiple, separated path segments; go to slow path + return canonicalizeRelativePathWithCopy(path, t_start, t_end, idx + 1); + } + } + } + } + case ST_M_D2 -> { + switch (c) { + case EOF -> { + // path starts with ../ + return path.substring(t_start, t_end); + } + case '/' -> { + // string is `path[0,idx) + "/../" + path[idx+4,t_start) + valid_path + path[t_end,length) + skip++; + state = ST_M_INIT; + } + default -> { + if (skip > 0) { + skip--; + state = ST_M_IGNORE; + } else { + // multiple, separated path segments; go to slow path + return canonicalizeRelativePathWithCopy(path, t_start, t_end, idx + 2); + } + } + } + } + case ST_M_IGNORE -> { + switch (c) { + case EOF -> { + // path starts with some unclosed .. + return path.substring(t_start, t_end); + } + case '/' -> state = ST_M_INIT; + default -> { + } + } + } + default -> throw new IllegalStateException(); + } + if (idx > 0) { + idx--; + } + } + } + + private static String canonicalizeRelativePathWithCopy(final String path, final int t_start, final int t_end, + final int init_idx) { + // this is the slow path where we must copy. + char[] buf = new char[init_idx + 1 + t_end - t_start]; + int idx = init_idx; + // this is the next available index in buf (counting from end, exclusive) + int c_idx = buf.length - (t_end - t_start); + path.getChars(t_start, t_end, buf, c_idx); + // the end of the current captured content + int s_end = -1; + int skip = 0; + int state = ST_C_INIT; + // current character + int c; + // secondary state machine -- iterate over string *in reverse* + for (;;) { + c = idx == 0 ? EOF : path.charAt(idx - 1); + switch (state) { + case ST_C_INIT -> { + switch (c) { + case EOF -> { + // return buffer as-is + return new String(buf, idx, buf.length - c_idx); + } + case '.' -> state = ST_C_D1; + case '/' -> { + } + default -> { + s_end = idx; + state = ST_C_CONTENT; + } + } + } + case ST_C_D1 -> { + switch (c) { + case EOF -> { + return new String(buf, c_idx, buf.length - c_idx); + } + case '.' -> state = ST_C_D2; + case '/' -> state = ST_C_INIT; + default -> { + s_end = idx + 1; + state = ST_C_CONTENT; + } + } + } + case ST_C_D2 -> { + switch (c) { + case EOF -> { + return new String(buf, c_idx, buf.length - c_idx); + } + case '/' -> { + skip++; + state = ST_C_INIT; + } + default -> { + s_end = idx + 2; + state = ST_C_CONTENT; + } + } + } + case ST_C_CONTENT -> { + switch (c) { + case EOF -> { + if (skip == 0) { + // append segment + buf[--c_idx] = '/'; + path.getChars(0, s_end, buf, c_idx - s_end); + } + return new String(buf, c_idx - s_end, buf.length - (c_idx - s_end)); + } + case '/' -> { + if (skip == 0) { + // append segment + buf[--c_idx] = '/'; + path.getChars(idx, s_end, buf, c_idx - (s_end - idx)); + c_idx -= s_end - idx; + } else { + skip--; + } + state = ST_C_INIT; + } + default -> { + } + } + } + } + if (idx > 0) { + idx--; + } + } + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/URLResource.java b/resource/src/main/java/io/smallrye/common/resource/URLResource.java new file mode 100644 index 00000000..10cc957a --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/URLResource.java @@ -0,0 +1,70 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.time.Instant; +import java.util.Objects; + +import io.smallrye.common.constraint.Assert; + +/** + * A resource backed by a connection to a URL. + */ +public final class URLResource extends Resource { + private final URLConnection connection; + + /** + * Construct a new instance for a URL. + * + * @param pathName the resource path name (must not be {@code null}) + * @param url the URL (must not be {@code null}) + * @throws IOException if the connection could not be opened + */ + public URLResource(final String pathName, final URL url) throws IOException { + this(pathName, url.openConnection()); + } + + /** + * Construct a new instance. + * + * @param pathName the resource path name (must not be {@code null}) + * @param connection the URL connection (must not be {@code null}) + */ + public URLResource(final String pathName, final URLConnection connection) { + super(pathName); + if (connection instanceof JarURLConnection j) { + j.setUseCaches(false); + } + this.connection = Assert.checkNotNullParam("connection", connection); + } + + /** + * Construct a new instance for a JAR URL connection. + * The JAR entry name is used as the resource name. + * + * @param jarConnection the JAR URL connection (must not be {@code null}) + */ + public URLResource(final JarURLConnection jarConnection) { + this(Objects.requireNonNullElse(jarConnection.getEntryName(), ""), jarConnection); + } + + public URL url() { + return connection.getURL(); + } + + public InputStream openStream() throws IOException { + return connection.getInputStream(); + } + + public Instant modifiedTime() { + long lastModified = connection.getLastModified(); + return lastModified == 0 ? null : Instant.ofEpochMilli(lastModified); + } + + public long size() { + return connection.getContentLengthLong(); + } +} diff --git a/resource/src/main/java/io/smallrye/common/resource/URLResourceLoader.java b/resource/src/main/java/io/smallrye/common/resource/URLResourceLoader.java new file mode 100644 index 00000000..0b247ec1 --- /dev/null +++ b/resource/src/main/java/io/smallrye/common/resource/URLResourceLoader.java @@ -0,0 +1,29 @@ +package io.smallrye.common.resource; + +import java.io.IOException; +import java.net.URL; + +import io.smallrye.common.constraint.Assert; + +/** + * A resource loader for a URL base. + */ +public final class URLResourceLoader implements ResourceLoader { + private final URL base; + + /** + * Construct a new instance. + * Note: to open a JAR file, use {@link JarFileResourceLoader} instead. + * To access files on the file system, use {@link PathResourceLoader} instead. + * + * @param base the URL base (must not be {@code null}) + */ + public URLResourceLoader(final URL base) { + this.base = Assert.checkNotNullParam("base", base); + } + + public Resource findResource(final String path) throws IOException { + String canon = ResourceUtils.canonicalizeRelativePath(path); + return new URLResource(canon, new URL(base, canon)); + } +} diff --git a/resource/src/main/java/module-info.yml b/resource/src/main/java/module-info.yml new file mode 100644 index 00000000..78a0aee7 --- /dev/null +++ b/resource/src/main/java/module-info.yml @@ -0,0 +1,4 @@ +name: io.smallrye.common.resource + +requires: + - module: io.smallrye.common.constraint diff --git a/resource/src/test/java/io/smallrye/common/resource/JarFileResourceLoaderTests.java b/resource/src/test/java/io/smallrye/common/resource/JarFileResourceLoaderTests.java new file mode 100644 index 00000000..57fc58cf --- /dev/null +++ b/resource/src/test/java/io/smallrye/common/resource/JarFileResourceLoaderTests.java @@ -0,0 +1,154 @@ +package io.smallrye.common.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.util.Iterator; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +public class JarFileResourceLoaderTests { + + public static final String FILE_TXT = "This is a plain text file\nIf you can read this, it's working!\n"; + + @Test + public void testOpenJar() throws IOException { + try (JarFileResourceLoader rl = makeJar( + // keep these two first + dir("META-INF"), + deflate("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"), + // test entries + dir("dir1"), + store("dir1/file-stored.txt", FILE_TXT), + deflate("dir1/file-deflated.txt", FILE_TXT), + // keep this as last entry + dir("end"))) { + assertNull(rl.findResource("missing")); + Resource dir1_file_stored_txt = rl.findResource("dir1/file-stored.txt"); + assertNotNull(dir1_file_stored_txt); + assertEquals(FILE_TXT, dir1_file_stored_txt.asString(StandardCharsets.UTF_8)); + Resource dir1_file_deflated_txt = rl.findResource("dir1/file-deflated.txt"); + assertNotNull(dir1_file_deflated_txt); + assertEquals(FILE_TXT, dir1_file_deflated_txt.asString(StandardCharsets.UTF_8)); + Resource dir1 = rl.findResource("dir1"); + assertNotNull(dir1); + assertEquals(0, dir1.size()); + try (DirectoryStream ds = dir1.openDirectoryStream()) { + Iterator iterator = ds.iterator(); + assertTrue(iterator.hasNext()); + assertEquals("dir1/file-stored.txt", iterator.next().pathName()); + assertTrue(iterator.hasNext()); + assertEquals("dir1/file-deflated.txt", iterator.next().pathName()); + assertFalse(iterator.hasNext()); + } + } + } + + private static JarFileResourceLoader makeJar(Entry... entries) throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(os)) { + for (Entry entry : entries) + try { + entry.writeTo(zos); + } catch (Throwable t) { + t.setStackTrace(entry.stack); + throw t; + } + zos.finish(); + } + // create a temp file, force it to be deleted on exit + return new JarFileResourceLoader(new MemoryResource("test.jar", os.toByteArray())); + } + + private static Entry dir(String name) { + return new DirEntry(name); + } + + private static Entry deflate(String name, String content) { + return new DeflateEntry(name, content); + } + + private static Entry store(String name, String content) { + return new StoredEntry(name, content); + } + + private static abstract class Entry { + final String name; + final StackTraceElement[] stack = new Throwable().getStackTrace(); + + private Entry(final String name) { + this.name = name; + } + + abstract void writeTo(ZipOutputStream os) throws IOException; + } + + private static final class DeflateEntry extends Entry { + private final String content; + + private DeflateEntry(final String name, final String content) { + super(name); + this.content = content; + } + + void writeTo(final ZipOutputStream os) throws IOException { + ZipEntry e = new ZipEntry(name); + byte[] data = content.getBytes(StandardCharsets.UTF_8); + e.setMethod(ZipEntry.DEFLATED); + e.setSize(data.length); + os.putNextEntry(e); + os.write(data); + os.closeEntry(); + } + } + + private static final class StoredEntry extends Entry { + private final String content; + + private StoredEntry(final String name, final String content) { + super(name); + this.content = content; + } + + void writeTo(final ZipOutputStream os) throws IOException { + ZipEntry e = new ZipEntry(name); + byte[] data = content.getBytes(StandardCharsets.UTF_8); + e.setMethod(ZipEntry.STORED); + e.setSize(data.length); + e.setCompressedSize(data.length); + CRC32 crc32 = new CRC32(); + crc32.update(data); + e.setCrc(crc32.getValue()); + os.putNextEntry(e); + os.write(data); + os.closeEntry(); + } + } + + private static final class DirEntry extends Entry { + + private DirEntry(final String name) { + super(name.endsWith("/") ? name : name + "/"); + } + + void writeTo(final ZipOutputStream os) throws IOException { + ZipEntry e = new ZipEntry(name); + e.setMethod(ZipEntry.STORED); + e.setSize(0); + e.setCompressedSize(0); + e.setCrc(0); + os.putNextEntry(e); + os.closeEntry(); + } + } +} diff --git a/resource/src/test/java/io/smallrye/common/resource/ResourceUtilsTests.java b/resource/src/test/java/io/smallrye/common/resource/ResourceUtilsTests.java new file mode 100644 index 00000000..f4bb9160 --- /dev/null +++ b/resource/src/test/java/io/smallrye/common/resource/ResourceUtilsTests.java @@ -0,0 +1,92 @@ +package io.smallrye.common.resource; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * + */ +public final class ResourceUtilsTests { + + @Test + public void testEmpty() { + assertCanonical("", ""); + assertCanonical("", "."); + assertCanonical("", ".."); + assertCanonical("", "/"); + assertCanonical("", "/."); + assertCanonical("", "./"); + assertCanonical("", "./."); + assertCanonical("", "../."); + assertCanonical("", "../"); + assertCanonical("", "..///"); + assertCanonical("", "..//./."); + assertCanonical("", "..//././//"); + } + + @Test + public void testSimple() { + assertCanonical("foo", "foo"); + assertCanonical("foo", "./foo"); + assertCanonical("foo", "../foo"); + assertCanonical("foo", "/foo"); + assertCanonical("foo", "/foo/"); + assertCanonical("foo", "/foo/."); + assertCanonical("foo", "./foo/."); + assertCanonical("foo", "../foo/."); + assertCanonical("foo", "/foo/.//"); + assertCanonical("foo", "./foo/.//"); + assertCanonical("foo", "../foo/.//"); + assertCanonical("foo", "foo/.//"); + assertCanonical("foo", "foo//.//"); + } + + @Test + public void testDotDot() { + assertCanonical("foo", "foo/bar/.."); + assertCanonical("foo", "/foo/bar/.."); + assertCanonical("foo", "./foo/bar/.."); + assertCanonical("foo", "../foo/bar/.."); + assertCanonical("foo", "bar/../foo"); + assertCanonical("foo", "/bar/../foo"); + assertCanonical("foo", "./bar/../foo"); + assertCanonical("foo", "../bar/../foo"); + assertCanonical("foo", "../bar/../baz/../foo"); + assertCanonical("foo", "bar/baz/../../foo"); + assertCanonical("foo/bar", "foo/bat/baz/../../bar"); + assertCanonical("foo/bar", "foo/bat/../baz/../bar"); + } + + @Test + public void testCopying() { + assertCanonical("foo/bar/buz", "foo/bat/../baz/../bar/./buz/."); + assertCanonical("foo/bar/buz", "foo/././bar/././buz/."); + assertCanonical("foo/bar/buz", "./foo/././bar/././buz/."); + assertCanonical("foo/bar/buz", "./foo/././bar/././buz/"); + assertCanonical("foo/bar/buz", "./foo/././bar/././buz//"); + assertCanonical("foo/bar/buz", "./foo/.//./bar/././buz"); + assertCanonical("foo/bar/buz", "../foo/././bar/././buz/."); + } + + @Test + public void testWithDot() { + assertCanonical("Object.class", "Object.class"); + assertCanonical("Object.class", "/Object.class"); + assertCanonical("Object.class", "./Object.class"); + assertCanonical("foo.", "foo."); + assertCanonical("foo.", "foo./"); + assertCanonical("foo.", "foo./."); + assertCanonical("foo..", "foo.."); + assertCanonical("foo..", "foo../"); + assertCanonical("foo..", "foo../."); + assertCanonical("foo..", "foo.././"); + assertCanonical(".foo", ".foo"); + assertCanonical(".foo", "/.foo"); + assertCanonical(".foo", "./.foo"); + } + + static void assertCanonical(String expect, String path) { + Assertions.assertEquals(expect, ResourceUtils.canonicalizeRelativePath(path), + "Path \"" + path + "\" canonicalize to \"" + expect + "\""); + } +}