PathEntryScanner.java

/*
 * Copyright © 2025 Indiana University
 * All rights reserved.
 *
 * BSD 3-Clause License
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * - Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * - Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package iu.type;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.LinkedHashSet;
import java.util.Queue;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;

import edu.iu.IuStream;

/**
 * Scans a path entry: either a {@code jar} file or filesystem directory, for
 * non-folder resources.
 */
class PathEntryScanner {

	/**
	 * Reads a single path entry.
	 * 
	 * <p>
	 * This method is not efficient for multiple reads.
	 * </p>
	 * 
	 * @param pathEntry    path entry: jar file or filesystem directory
	 * @param resourceName resource name
	 * @return full binary contents of the resource
	 * @throws IOException         if an error occurs while reading the file
	 * @throws NoSuchFileException if the resourceName doesn't match an entry in the
	 *                             jar file, or doesn't name a filesystem resource
	 *                             relative to {@code pathEntry}.
	 */
	static byte[] read(Path pathEntry, String resourceName) throws IOException {

		// TODO: evaluate this guard condition; either flesh out boundaries or remove
		// as unneeded for an internal tool. At present, only two values for
		// resourceName are possible: META-INF/iu-type.properties and
		// META-INF/iu.properties.
		// Both of these may be removed in the course of completing JEE.

		// https://github.com/indiana-university/iu-java-util/pull/50:
		// @anderjak:I'm a little confused by this. the check seems to be looking for
		// both
		// indicators of relative paths and for an absolute path. It looks like it is
		// linux/unix-specific, but if it is looking for relative paths, should it look
		// for '~' as the first character too? Then the exception says the "resourceName
		// must be relative". At first I thought maybe it should say "resourceName must
		// not be relative", but the check for a leading '/' made me wonder whether the
		// message was correct and the check was wrong or vice versa or if I'm missing
		// something.

		final var first = resourceName.charAt(0);
		if (first == '.' || first == '/' || resourceName.endsWith("/..") || resourceName.indexOf("/../") != -1)
			throw new IllegalArgumentException("resourceName must be relative");

		if (Files.isDirectory(pathEntry))
			try (final var in = Files.newInputStream(pathEntry.resolve(resourceName))) {
				return IuStream.read(in);
			}
		else
			try (final var in = Files.newInputStream(pathEntry); //
					final var jar = new JarInputStream(in)) {
				JarEntry entry;
				while ((entry = jar.getNextJarEntry()) != null)
					if (entry.getName().equals(resourceName))
						return IuStream.read(jar);
			}

		throw new NoSuchFileException(resourceName + " not found at " + pathEntry);
	}

	/**
	 * Scans a path entry.
	 * 
	 * @param pathEntry path entry: jar file or filesystem directory, to scan
	 * @return mutable set initialized with all discovered resource names; may be
	 *         modified after return
	 * @throws IOException if an I/O error occurs scanning the path entry
	 */
	static Set<String> findResources(Path pathEntry) throws IOException {
		final Set<String> allResources;
		if (Files.isDirectory(pathEntry))
			allResources = findResourcesInFolder(pathEntry);
		else
			allResources = findResourcesInJar(pathEntry);
		return allResources;
	}

	private static Set<String> findResourcesInJar(Path pathEntry) throws IOException {
		final Set<String> resourceNames = new LinkedHashSet<>();
		try (final var in = Files.newInputStream(pathEntry); final var jar = new JarInputStream(in)) {
			JarEntry entry;
			while ((entry = jar.getNextJarEntry()) != null) {
				final var name = entry.getName();
				if (name.charAt(name.length() - 1) != '/')
					resourceNames.add(name);
			}
		}
		return resourceNames;
	}

	private static Set<String> findResourcesInFolder(Path pathEntry) throws IOException {
		final int rootLength = pathEntry.toUri().toString().length();
		final Set<String> resourceNames = new LinkedHashSet<>();
		final Queue<Path> toScan = new ArrayDeque<>();
		toScan.offer(pathEntry);
		while (!toScan.isEmpty()) {
			final var next = toScan.poll();
			if (Files.isDirectory(next))
				Files.list(next).forEach(toScan::offer);
			else
				resourceNames.add(next.toUri().toString().substring(rootLength));
		}
		return resourceNames;
	}

	private PathEntryScanner() {
	}

}