ArchiveSource.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.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Reads entries from a {@link JarInputStream} for validating and initializing a
 * {@link ComponentArchive}.
 */
class ArchiveSource implements AutoCloseable {

	private final InputStream in;
	private final JarInputStream jar;
	private final boolean sealed;
	private final List<String> classPath;
	private final List<ComponentVersion> dependencies;
	private boolean seenManifest;
	private Optional<ComponentEntry> next;
	private ComponentEntry last;
	private boolean closed;

	/**
	 * Constructs an {@link ArchiveSource} for an {@link InputStream}.
	 * 
	 * <p>
	 * The input stream provided is opened and validated as a jar file with a valid
	 * manifest. The source is considered sealed if {@code Sealed} appears in the
	 * manifest. If the manifest provides a class path or extension list, those
	 * attributes are processed.
	 * </p>
	 * 
	 * @param in input stream; this method is not responsible for closing the stream
	 * @throws IOException If an I/O error occurs
	 */
	ArchiveSource(InputStream in) throws IOException {
		this.in = in;
		jar = new JarInputStream(in);

		var manifest = jar.getManifest();
		if (manifest == null)
			throw new IllegalArgumentException("Missing META-INF/MANIFEST.MF");

		var attributes = manifest.getMainAttributes();
		if (attributes.getValue(Name.MANIFEST_VERSION) == null)
			throw new IllegalArgumentException(
					"Missing " + Name.MANIFEST_VERSION + " attribute in META-INF/MANIFEST.MF");

		sealed = "true".equals(attributes.getValue(Name.SEALED));

		var classPathAttribute = manifest.getMainAttributes().getValue(Name.CLASS_PATH);
		if (classPathAttribute == null)
			classPath = List.of();
		else
			classPath = List.of(classPathAttribute.split(" "));

		var extensionListAttribute = attributes.getValue(Name.EXTENSION_LIST);
		if (extensionListAttribute == null)
			dependencies = List.of();
		else
			this.dependencies = Stream.of(extensionListAttribute.split(" "))
					.map(extension -> new ComponentVersion(extension, attributes)).collect(Collectors.toList());
	}

	/**
	 * If packages in this archive should be sealed.
	 * 
	 * @return true if packages should be sealed; else false
	 */
	boolean sealed() {
		return sealed;
	}

	/**
	 * Gets the class path defined in the {@link Manifest}.
	 * 
	 * @return {@link Name#CLASS_PATH} {@link Manifest#getMainAttributes() mainfest
	 *         main attribute}.
	 */
	List<String> classPath() {
		return classPath;
	}

	/**
	 * Gets the component's dependencies named in the {@link Name#EXTENSION_LIST
	 * Extension-List} {@link Manifest#getMainAttributes() manifest main
	 * attributes}.
	 * 
	 * @return dependency versions
	 */
	List<ComponentVersion> dependencies() {
		return dependencies;
	}

	/**
	 * Same behavior as {@link Iterator#hasNext()}, but can throw
	 * {@link IOException} if there is an error reading from the jar file.
	 * 
	 * <p>
	 * Note that this method closes the last entry returned from {@link #next()} and
	 * positions the jar file for reading the next entry as part of determining
	 * whether or not there is a next entry. So, this should only be called after
	 * all interactions with the last entry are complete.
	 * </p>
	 * 
	 * @return see {@link Iterator#hasNext()}
	 * @throws IOException If there is an error reading the next entry from the jar
	 *                     file.
	 */
	boolean hasNext() throws IOException {
		if (closed)
			return false;

		if (next == null) {
			if (last != null) {
				jar.closeEntry();
				last.close();
				last = null;
			}

			if (!seenManifest) {
				final var manifest = jar.getManifest();
				final var manifestOut = new ByteArrayOutputStream();
				manifest.write(manifestOut);
				next = Optional.of(new ComponentEntry("META-INF/MANIFEST.MF",
						new ByteArrayInputStream(manifestOut.toByteArray())));
				seenManifest = true;
			} else {
				JarEntry jarEntry = jar.getNextJarEntry();
				if (jarEntry == null) {
					close();
					return false;
				}

				next = Optional.of(new ComponentEntry(jarEntry.getName(), jar));
			}
		}

		return true;
	}

	/**
	 * Same behavior as {@link Iterator#next()}, but can throw {@link IOException}
	 * if there is an error reading from the jar file.
	 * 
	 * @return see {@link Iterator#next()}
	 * @throws IOException from {@link #hasNext()}
	 */
	ComponentEntry next() throws IOException {
		if (!hasNext())
			throw new NoSuchElementException();

		last = next.get();
		next = null;

		return last;
	}

	@Override
	public void close() throws IOException {
		if (!closed) {
			jar.close();
			in.close();

			if (next != null) {
				next.get().close();
				next = null;
			}

			if (last != null) {
				last.close();
				last = null;
			}

			closed = true;
		}
	}

	@Override
	public String toString() {
		return "ArchiveSource [sealed=" + sealed + ", classPath=" + classPath + ", dependencies=" + dependencies
				+ (next == null ? "" : ", next=" + next) + (last == null ? "" : ", last=" + last) + ", closed=" + closed
				+ "]";
	}

}