/*
 * 
 * Copyright 2004 Sun Microsystems, Inc. All Rights Reserved.
 * 
 * The contents of this file are subject to the Sun Community Source
 * License v3.0/Jini Technology Specific Attachment v 1.0 (the
 * "License"). You may not use this file except in compliance with the
 * License. You may obtain a copy of the License at
 * http://wwws.sun.com/software/jini/licensing/SCSL3_JiniTSA1.html.
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See
 * the License for the specific language governing rights and limitations
 * under the License.
 * 
 * The Reference Code is Jini Technology Core Platform code, v2.0. The
 * Developer of the Reference Code is Sun Microsystems, Inc.
 * 
 * Contributor(s): Sun Microsystems, Inc.
 * 
 * The contents of this file comply with the Jini Technology Core
 * Platform Compatibility Kit, v2.0A.
 * 
 * Tester(s): Sun Microsystems, Inc.
 * 
 * Test Platform(s):
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Solaris(TM)SPARC/x86
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Linux (Intel x86)
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Windows (Intel Platform)
 * 
 */

package com.sun.jini.tool;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A tool for generating "wrapper" JAR files.  A wrapper JAR file contains a
 * <code>Class-Path</code> manifest attribute listing a group of JAR files to
 * be loaded from a common codebase.  It may also, depending on applicability
 * and selected options, contain a JAR index file, a preferred class list
 * and/or a <code>Main-Class</code> manifest entry for the grouped JAR files.
 * <p>
 * The following items are discussed below:
 * <ul>
 *   <li> <a href="#applicability">Applicability</a>
 *   <li> {@linkplain #main Command line options}
 *   <li> <a href="#logging">Logging</a>
 *   <li> <a href="#examples">Examples for running JarWrapper</a>
 * </ul>
 * <p>
 * <a name="applicability"><h3>Applicability</h3></a>
 * <p>
 * The <code>JarWrapper</code> tool is applicable in the following deployment
 * situations, which may overlap:
 * <ul>
 *   <li> If a codebase contains multiple JAR files which declare preferred
 *	  resources, <code>JarWrapper</code> can be used to produce a wrapper
 *	  JAR file with a combined preferred list.  Preferred resources are
 *	  described in the documentation for the {@link net.jini.loader.pref}
 *	  package.
 *        <p>
 *   <li> If a codebase contains multiple JAR files and requires integrity
 *	  protection, <code>JarWrapper</code> can be used to produce a wrapper
 *	  JAR file with a <code>Class-Path</code> attribute that uses HTTPMD
 *	  URLs.  HTTPMD URLs are described in the documentation for the
 *	  {@link net.jini.url.httpmd} package.
 *        <p>
 *   <li> If an application or service packaged as an executable JAR file
 *	  refers to classes specified at deployment time (e.g., via a
 *	  {@link net.jini.config.Configuration Configuration}) which are not
 *	  present in the JAR file or its <code>Class-Path</code>,
 *	  <code>JarWrapper</code> can be used to produce a wrapper JAR file
 *	  which includes the extra classes in its <code>Class-Path</code> while
 *	  retaining the original <code>Main-Class</code> declaration; the
 *	  wrapper JAR file can then be executed in place of the original JAR
 *	  file.
 * </ul>
 * <p>
 * <a name="logging"><h3>Logging</h3></a>
 * <p>
 * <code>JarWrapper</code> uses the {@link Logger} named
 * <code>com.sun.jini.tool.JarWrapper</code> to log information at the
 * following logging levels:
 * <p>
 * <table border="1" cellpadding="5"
 * 	  summary="Describes logging performed by JarWrapper at different
 *		   logging levels">
 * <caption halign="center" valign="top"><b><code>
 *    com.sun.jini.tool.JarWrapper</code></b></caption>
 *
 *   <tr> <th scope="col"> Level   <th scope="col"> Description </tr>
 *   <tr>
 *     <td> {@link Level#WARNING WARNING}
 *     <td> Generated JAR index entries that do not end in <code>".jar"</code>
 *   </tr>
 *   <tr>
 *     <td> {@link Level#FINE FINE}
 *     <td> Names of processed source JAR files and output wrapper JAR file
 *   </tr>
 *   <tr>
 *     <td> {@link Level#FINER FINER}
 *     <td> Processing of <code>Main-Class</code> and <code>Class-Path</code>
 *          attributes, and presence of preferred lists and JAR indexes
 *   </tr>
 *   <tr>
 *     <td> {@link Level#FINEST FINEST}
 *     <td> Processing and compilation of preferred lists and JAR indexes
 *   </tr>
 * </table>
 * <p>
 * <a name="examples"><h3>Examples for running JarWrapper</h3></a>
 * <p>
 * <code>JarWrapper</code> can be run directly from the
 * {@linkplain #main command line} or can be invoked programmatically using the
 * {@link #wrap wrap} method.
 * <p>
 * An example of running directly from the command line is:
 * <blockquote><pre>
 * % java -classpath <var><b>install_dir</b></var>/lib/tools.jar com.sun.jini.tool.JarWrapper
 *        -httpmd=MD5 wrapper.jar base_dir src1.jar src2.jar
 * </pre></blockquote>
 * where <var><b>install_dir</b></var> is the directory where the starter kit
 * is installed.  This command line would result in the creation of a wrapper
 * JAR file, <code>wrapper.jar</code>, in the current working directory, whose
 * contents would be based on the source JAR files <code>src1.jar</code> and
 * <code>src2.jar</code> (as well as any other JAR files referenced
 * transitively through their <code>Class-Path</code> attributes or JAR
 * indexes).  The paths for <code>src1.jar</code> and <code>src2.jar</code>, as
 * well as any transitively referenced JAR files, would be resolved relative to
 * the <code>base_dir</code> directory.  The <code>Class-Path</code> attribute
 * of <code>wrapper.jar</code> would use HTTPMD URLs with MD5 digests.
 * <p>
 * The equivalent programmatic invocation of <code>JarWrapper</code> would be:
 * <blockquote><pre>
 * JarWrapper.wrap("wrapper.jar", "base_dir", new String[]{ "src1.jar", "src2.jar" }, "MD5", true);
 * </pre></blockquote>
 *
 * @author Sun Microsystems, Inc.
 * @since 2.0
 */
public class JarWrapper {

    private static ResourceBundle resources;
    private static final Object resourcesLock = new Object();
    private static final Logger logger =
	Logger.getLogger(JarWrapper.class.getName());

    private final File destJar;
    private final File baseDir;
    private final SourceJarURL[] srcJars;
    private final MessageDigest digest;
    private final JarIndexWriter indexWriter;
    private final PreferredListWriter prefWriter = new PreferredListWriter();
    private final StringBuffer classPath = new StringBuffer();
    private String mainClass = null;
    private final Set seenJars = new HashSet();

    /**
     * Initializes JarWrapper based on the given values.
     */
    private JarWrapper(String destJar,
		       String baseDir,
		       String[] srcJars,
		       String httpmdAlg,
		       boolean index)
    {
	this.destJar = new File(destJar);
	if (this.destJar.exists()) {
	    throw new LocalizedIllegalArgumentException(
		"jarwrapper.fileexists", destJar);
	}
	this.baseDir = new File(baseDir);
	if (!this.baseDir.isDirectory()) {
	    throw new LocalizedIllegalArgumentException(
		"jarwrapper.invalidbasedir", baseDir);
	}
	this.srcJars = new SourceJarURL[srcJars.length];
	for (int i = 0; i < srcJars.length; i++) {
	    try {
		SourceJarURL url = new SourceJarURL(srcJars[i]);
		if (url.algorithm != null) {
		    throw new LocalizedIllegalArgumentException(
			"jarwrapper.urlhasdigest", url);
		}
		this.srcJars[i] = url;
	    } catch (LocalizedIOException e) {
		throw new LocalizedIllegalArgumentException(e);
	    } catch (IOException e) {
		throw (IllegalArgumentException)
		    new IllegalArgumentException(e.getMessage()).initCause(e);
	    }
	}
	if (httpmdAlg != null) {
	    try {
		digest = MessageDigest.getInstance(httpmdAlg);
	    } catch (NoSuchAlgorithmException e) {
		throw (IllegalArgumentException)
		    new LocalizedIllegalArgumentException(
			"jarwrapper.invalidhttpmdalg", httpmdAlg).initCause(e);
	    }
	} else {
	    digest = null;
	}
	indexWriter = index ? new JarIndexWriter() : null;
    }

    /**
     * Generates a wrapper JAR file for the specified JAR files.  The command
     * line arguments are:
     * <pre>
     * [ <var>options</var> ] <var>dest-jar</var> <var>base-dir</var> <var>src-jar</var> [ <var>src-jar</var> ...]
     * </pre>
     * The <var>dest-jar</var> argument specifies the name of the wrapper JAR
     * file to generate.  The <var>base-dir</var> argument specifies the base
     * directory from which to locate source JAR files to wrap.  The
     * <var>src-jar</var> arguments are non-absolute URLs to "top-level" source
     * JAR files relative to <var>base-dir</var>; they also constitute the
     * basis of the <code>Class-Path</code> attribute included in the generated
     * wrapper JAR file.  JAR files not present in the command line but
     * indirectly referenced via JAR index or <code>Class-Path</code> entries
     * in source JAR files will themselves be used as source JAR files, and
     * will appear alongside the top-level source JAR files in the
     * <code>Class-Path</code> attribute of the wrapper JAR file in depth-first
     * order, with JAR index references appearing before
     * <code>Class-Path</code> references.  This utility does not modify any
     * source JAR files.
     * <p>
     * If any of the top-level source JAR files contain preferred resources (as
     * indicated by a preferred list in the JAR file), then a preferred list
     * describing resource preferences across all source JAR files will be
     * included in the wrapper JAR file.  The preferred list of a top-level
     * source JAR file is interpreted as applying to that JAR file along with
     * all JAR files transitively referenced by it through JAR index or
     * <code>Class-Path</code> entries, excluding JAR files that have already
     * been encountered in the processing of preceding top-level JAR files.  If
     * a given top-level source JAR file does not contain a preferred list,
     * then all resources contained in it and its transitively referenced JAR
     * files (again, excluding those previously encountered) are considered not
     * preferred.  Preferred lists are described further in the documentation
     * for {@link net.jini.loader.pref.PreferredClassLoader
     * PreferredClassLoader}.
     * <p>
     * If any of the top-level source JAR files declare a
     * <code>Main-Class</code> manifest entry, then the wrapper JAR file will
     * include a <code>Main-Class</code> manifest entry whose value is that of
     * the first top-level source JAR file listed on the command line which
     * defines a <code>Main-Class</code> entry.
     * <p>
     * Supported options for this tool include:
     * <p>
     * <dl>
     *   <dt> <code>-verbose</code>
     *   <dd> Sets the level of the <code>com.sun.jini.tool.JarWrapper</code>
     * 	      logger to <code>Level.FINER</code>.
     *	      <p>
     *   <dt> <code>-httpmd[=algorithm]</code>
     *   <dd> Use (relative) HTTPMD URLs in the <code>Class-Path</code>
     *        attribute of the generated wrapper JAR file.  The default is to
     *        use HTTP URLs.  Digests for HTTPMD URLs are calculated using the
     *        given algorithm, or MD5 if none is specified.
     *	      <p>
     *   <dt> <code>-noindex</code>
     *   <dd> Do not include a JAR index in the generated wrapper JAR file.  The
     *	      default is to compile an index based on the contents of the
     *	      source JAR files.
     * </dl>
     */
    public static void main(String[] args) {
	String destJar;
	String baseDir;
	String[] srcJars;
	String httpmdAlg = null;
	boolean index = true;

	int i = 0;
	while (i < args.length && args[i].startsWith("-")) {
	    String s = args[i++];
	    if (s.equals("-help")) {
		System.err.println(localize("jarwrapper.usage"));
		System.exit(0);
	    } else if (s.equals("-verbose")) {
		setLoggingLevel(Level.FINER);
	    } else if (s.equals("-debug")) {
		setLoggingLevel(Level.ALL);
	    } else if (s.equals("-httpmd") || s.startsWith("-httpmd=")) {
		if (httpmdAlg != null) {
		    System.err.println(localize("jarwrapper.multiplehttpmd"));
		    System.err.println(localize("jarwrapper.usage"));
		    System.exit(1);
		}
		int split = s.indexOf('=');
		httpmdAlg = (split != -1) ? s.substring(split + 1) : "MD5";
	    } else if (s.equals("-noindex")) {
		index = false;
	    } else {
		System.err.println(localize("jarwrapper.badoption", s));
		System.err.println(localize("jarwrapper.usage"));
		System.exit(1);
	    }
	}
	if (args.length - i < 3) {
	    System.err.println(localize("jarwrapper.insufficientargs"));
	    System.err.println(localize("jarwrapper.usage"));
	    System.exit(1);
	}
	destJar = args[i++];
	baseDir = args[i++];
	srcJars = new String[args.length - i];
	System.arraycopy(args, i, srcJars, 0, srcJars.length);

	try {
	    wrap(destJar, baseDir, srcJars, httpmdAlg, index);
	} catch (Throwable t) {
	    if (t instanceof LocalizedIllegalArgumentException ||
		t instanceof LocalizedIOException)
	    {
		System.err.println(t.getMessage());
	    } else {
		System.err.println(localize("jarwrapper.fatalexception"));
		t.printStackTrace();
	    }
	    System.exit(1);
	}
    }

    /**
     * Generates a wrapper JAR file based on the provided values in the same
     * manner as described in the documentation for {@link #main}.  The only
     * difference between this method and <code>main</code> is that it receives
     * its values as explicit arguments instead of in a command line, and
     * indicates failure by throwing an exception.
     *
     * @param destJar name of the wrapper JAR file to generate
     * @param baseDir base directory from which to locate source JAR
     * files to wrap
     * @param srcJars list of top-level source JAR files to process
     * @param httpmdAlg name of algorithm to use for generating HTTPMD URLs, or
     * <code>null</code> if plain HTTP URLs should be used
     * @param index if <code>true</code>, generate a JAR index; if
     * <code>false</code>, do not generate one
     * @throws IOException if an I/O error occurs while processing source JAR
     * files or generating the wrapper JAR file
     * @throws IllegalArgumentException if the provided values are invalid
     * @throws NullPointerException if <code>destJar</code>,
     * <code>baseDir</code>, <code>srcJars</code>, or any element of
     * <code>srcJars</code> is <code>null</code>
     */
    public static void wrap(String destJar,
			    String baseDir,
			    String[] srcJars,
			    String httpmdAlg,
			    boolean index)
	throws IOException
    {
	new JarWrapper(destJar, baseDir, srcJars, httpmdAlg, index).wrap();
    }

    /**
     * Processes source JAR files and outputs wrapper JAR file.
     */
    private void wrap() throws IOException {
	for (int i = 0; i < srcJars.length; i++) {
	    process(srcJars[i], null);
	}
	outputWrapperJar();
    }

    /**
     * Processes source JAR file indicated by the given URL, determining
     * preferred resources using the provided preferred list reader.  If the
     * preferred list reader is null, then the URL is for a top-level source
     * JAR file, in which case the preferred list of the JAR file should be
     * read, if the JAR file has not already been processed.
     */
    private void process(SourceJarURL url, PreferredListReader prefReader)
	throws IOException
    {
	File file = url.toFile(baseDir);
	boolean seen = seenJars.contains(file);
	boolean checkMainClass = mainClass == null && prefReader == null;
	if (seen && !checkMainClass) {
	    return;
	}

	if (logger.isLoggable(Level.FINE)) {
	    logger.log(Level.FINE, "processing {0}", new Object[]{ file });
	}
	if (!file.exists()) {
	    throw new LocalizedIOException("jarwrapper.filenotfound", file);
	}
	JarFile jar = new JarFile(file, false);

	if (checkMainClass) {
	    mainClass = getMainClass(jar);
	}
	if (!seen) {
	    seenJars.add(file);

	    if (digest != null) {
		url = new SourceJarURL(
		    url.path,
		    digest.getAlgorithm(),
		    getDigestString(digest, file),
		    null);
	    }
	    if (classPath.length() > 0) {
		classPath.append(' ');
	    }
	    classPath.append(url);

	    if (indexWriter != null) {
		indexWriter.addEntries(jar, url);
	    }
	    if (prefReader == null) {
		prefReader = new PreferredListReader(jar);
	    }
	    prefWriter.addEntries(jar, prefReader);

	    List l = new ArrayList();
	    l.addAll(new JarIndexReader(jar).getJars());
	    l.addAll(getClassPath(jar));
	    for (Iterator i = l.iterator(); i.hasNext(); ) {
		SourceJarURL u = (SourceJarURL) i.next();
		u = url.resolve(new SourceJarURL(u.path, null, null, null));
		process(u, prefReader);
	    }
	}
    }

    /**
     * Returns URLs contained in the Class-Path attribute (if any) of the given
     * JAR file, as a list of SourceJarURL instances.
     */
    private List getClassPath(JarFile jar) throws IOException {
	Manifest mf = jar.getManifest();
	if (mf == null) {
	    return Collections.EMPTY_LIST;
	}
	Attributes atts = mf.getMainAttributes();
	String cp = atts.getValue(Name.CLASS_PATH);
	if (cp == null) {
	    return Collections.EMPTY_LIST;
	}
	if (logger.isLoggable(Level.FINER)) {
	    logger.log(Level.FINER, "Class-Path: {0}", new Object[]{ cp });
	}
	List l = new ArrayList();
	for (StringTokenizer tok = new StringTokenizer(cp, " ");
	     tok.hasMoreTokens(); )
	{
	    SourceJarURL url = new SourceJarURL(tok.nextToken());
	    if (digest != null && url.algorithm == null) {
		throw new LocalizedIOException("jarwrapper.nonhttpmdurl", url);
	    }
	    l.add(url);
	}
	return l;
    }


    /**
     * Writes wrapper JAR file based on information from processed source JARs.
     */
    private void outputWrapperJar() throws IOException {
	if (logger.isLoggable(Level.FINE)) {
	    logger.log(Level.FINE, "writing {0}", new Object[]{ destJar });
	}
	Manifest mf = new Manifest();
	Attributes atts = mf.getMainAttributes();
	atts.put(Name.MANIFEST_VERSION, "1.0");
	atts.put(new Name("Created-By"), JarWrapper.class.getName());
	atts.put(Name.CLASS_PATH, classPath.toString());
	if (mainClass != null) {
	    atts.put(Name.MAIN_CLASS, mainClass);
	}

	boolean completed = false;
	try {
	    JarOutputStream jout =
		new JarOutputStream(new FileOutputStream(destJar), mf);
	    if (indexWriter != null) {
		indexWriter.write(jout);
	    }
	    prefWriter.write(jout);
	    jout.close();
	    completed = true;
	} finally {
	    if (!completed) {
		deleteWrapperJar();
	    }
	}
    }

    /**
     * Attempts to delete wrapper JAR file.
     */
    private void deleteWrapperJar() {
	try {
	    if (!destJar.delete() && logger.isLoggable(Level.WARNING)) {
		logger.log(
		    Level.WARNING,
		    "failed to delete {0}",
		    new Object[]{ destJar });
	    }
	} catch (Throwable t) {
	    logger.log(
		Level.WARNING, "exception deleting wrapper JAR file", t);
	}
    }

    /**
     * Returns the value of the Main-Class attribute of the given JAR file, or
     * null if none is present.
     */
    private static String getMainClass(JarFile jar) throws IOException {
	Manifest mf = jar.getManifest();
	if (mf == null) {
	    return null;
	}
	Attributes atts = mf.getMainAttributes();
	String mc = atts.getValue(Name.MAIN_CLASS);
	if (mc != null && logger.isLoggable(Level.FINER)) {
	    logger.log(Level.FINER, "Main-Class: {0}", new Object[]{ mc });
	}
	return mc;
    }

    /**
     * Returns a string representation of the message digest of the given file.
     */
    private static String getDigestString(MessageDigest digest, File file)
	throws IOException
    {
	FileInputStream fin = new FileInputStream(file);
	byte[] buf = new byte[2048];
	int n;
	while ((n = fin.read(buf)) >= 0) {
	    digest.update(buf, 0, n);
	}
	buf = digest.digest();
	fin.close();

	StringBuffer sb = new StringBuffer(buf.length * 2);
	for (int i = 0; i < buf.length; i++) {
	    byte b = buf[i];
	    sb.append(Character.forDigit((b >> 4) & 0xf, 16));
	    sb.append(Character.forDigit(b & 0xf, 16));
	}
	return sb.toString();
    }

    /**
     * Sets logging and console handler output level.
     */
    private static void setLoggingLevel(Level level) {
	logger.setLevel(level);
	for (Logger l = logger; l != null; l = l.getParent()) {
	    Handler[] handlers = l.getHandlers();
	    for (int i = 0; i < handlers.length; i++) {
		if (handlers[i] instanceof ConsoleHandler) {
		    handlers[i].setLevel(level);
		}
	    }
	    if (!l.getUseParentHandlers()) {
		break;
	    }
	}
    }

    /**
     * Returns localized message text corresponding to the given key string.
     */
    static String localize(String key) {
	String fmt = getResourceString(key);
	if (fmt == null) {
	    fmt = "no text found: \"" + key + "\"";
	}
	// REMIND: format even without arguments?
	return MessageFormat.format(fmt, null);
    }

    /**
     * Returns localized message text corresponding to the given key string,
     * passing the provided value as an argument when formatting the message.
     */
    static String localize(String key, Object val) {
	String fmt = getResourceString(key);
	if (fmt == null) {
	    fmt = "no text found: \"" + key + "\" {0}";
	}
	return MessageFormat.format(fmt, new Object[]{ val });
    }

    /**
     * Returns localized format string, obtained from the resource bundle for
     * JarWrapper, that corresponds to the given key, or null if the resource
     * bundle does not contain a corresponding string.
     */
    private static String getResourceString(String key) {
	synchronized (resourcesLock) {
	    if (resources == null) {
		resources = ResourceBundle.getBundle(
		    "com.sun.jini.tool.resources.jarwrapper");
	    }
	}
	try {
	    return resources.getString(key);
	} catch (MissingResourceException e) {
	    return null;
	}
    }

    /**
     * IllegalArgumentException with a localized detail message.
     */
    private static class LocalizedIllegalArgumentException
	extends IllegalArgumentException
    {
	private static final long serialVersionUID = 0L;

	/**
	 * Creates exception with localized message text corresponding to the
	 * given key string, passing the provided value as an argument when
	 * formatting the message.
	 */
	LocalizedIllegalArgumentException(String key, Object val) {
	    super(localize(key, val));
	}

	/**
	 * Creates exception with the localized message text of the given
	 * cause.
	 */
	LocalizedIllegalArgumentException(LocalizedIOException cause) {
	    super(cause.getMessage());
	    initCause(cause);
	}
    }

    /**
     * IOException with a localized detail message.
     */
    private static class LocalizedIOException extends IOException {

	private static final long serialVersionUID = 0L;

	/**
	 * Creates exception with localized message text corresponding to the
	 * given key string, passing the provided value as an argument when
	 * formatting the message.
	 */
	LocalizedIOException(String key, Object val) {
	    super(localize(key, val));
	}
    }

    /**
     * Represents URL to a source JAR file.  Source JAR URLs must be relative,
     * and may contain HTTPMD digests.
     */
    private static class SourceJarURL {

	private static final Pattern httpmdPattern =
	    Pattern.compile("(.*);(.+?)=(.+?)(?:,(.*))?$");

	/** raw URL string, including HTTPMD information (if any) */
	final String raw;
	/** URL path component, excluding any HTTPMD information */
	final String path;
	/** HTTPMD digest algorithm, or null if non-HTTPMD URL */
	final String algorithm;
	/** HTTPMD digest value, or null if non-HTTPMD URL */
	final String digest;
	/** HTTPMD digest comment, or null if non-HTTPMD URL */
	final String comment;

	/**
	 * Creates SourceJarURL based on given raw URL string.
	 */
	SourceJarURL(String raw) throws IOException {
	    try {
		this.raw = raw;
		Matcher m = httpmdPattern.matcher(raw);
		if (m.matches()) {
		    path = m.group(1);
		    algorithm = m.group(2);
		    digest = m.group(3);
		    comment = m.group(4);
		} else {
		    path = raw;
		    algorithm = null;
		    digest = null;
		    comment = null;
		}

		URI uri = new URI(path);
		if (uri.getScheme() != null) {
		    throw new LocalizedIOException(
			"jarwrapper.urlhasscheme", raw);
		} else if (uri.getAuthority() != null) {
		    throw new LocalizedIOException(
			"jarwrapper.urlhasauthority", raw);
		}
		String p = uri.getPath();
		if (p == null || p.length() == 0) {
		    throw new LocalizedIOException(
			"jarwrapper.urlemptypath", raw);
		} else if (p.startsWith("/")) {
		    throw new LocalizedIOException(
			"jarwrapper.urlabsolute", raw);
		}
	    } catch (URISyntaxException e) {
		throw (IOException) new LocalizedIOException(
		    "jarwrapper.invalidurlsyntax", raw).initCause(e);
	    }
	}

	/**
	 * Creates SourceJarURL based on given components.
	 */
	SourceJarURL(String path, 
		     String algorithm, 
		     String digest,
		     String comment) 
	{
	    if (algorithm != null) {
		raw = path + ';' + algorithm + '=' + digest +
		      ((comment != null) ? ',' + comment : "");
	    } else {
		raw = path;
	    }
	    this.path = path;
	    this.algorithm = algorithm;
	    this.digest = digest;
	    this.comment = comment;
	}

	/**
	 * Resolves given URL relative to this URL.
	 */
	SourceJarURL resolve(SourceJarURL other) {
	    try {
		// hack around URI bug 4548698 by temporarily prepending slash
		URI uri = new URI('/' + path);
		String p = uri.resolve(other.path).getPath().substring(1);
		return new SourceJarURL(
		    p, other.algorithm, other.digest, other.comment);
	    } catch (URISyntaxException e) {
		throw new AssertionError(e);
	    }
	}

	/**
	 * Returns file represented by this URL.
	 */
	File toFile(File base) {
	    try {
		String p = new URI(path).getPath();	// decode path
		return new File(base, p.replace('/', File.separatorChar));
	    } catch (URISyntaxException e) {
		throw (Error) new InternalError().initCause(e);
	    }
	}

	public boolean equals(Object obj) {
	    return obj instanceof SourceJarURL && 
		   raw.equals(((SourceJarURL) obj).raw);
	}

	public int hashCode() {
	    return raw.hashCode();
	}

	public String toString() {
	    return raw;
	}
    }

    /**
     * Parses JAR indexes.
     */
    private static class JarIndexReader {

	private static final Pattern headerPattern = 
	    Pattern.compile("^JarIndex-Version:\\s*(.*?)$");
	private static final Pattern versionPattern =
	    Pattern.compile("^1(\\.\\d+)*$");

	private final List jars;

	/**
	 * Parses the given JAR file's JAR index, if any.
	 */
	JarIndexReader(JarFile jar) throws IOException {
	    List l = new ArrayList();
	    jars = Collections.unmodifiableList(l);
	    JarEntry ent = jar.getJarEntry("META-INF/INDEX.LIST");
	    if (ent == null) {
		return;
	    }
	    logger.finer("reading JAR index");
	    BufferedReader r = new BufferedReader(
		new InputStreamReader(jar.getInputStream(ent), "UTF8"));

	    String s = r.readLine();
	    if (s == null) {
		throw new IOException("missing JAR index header");
	    }
	    s = s.trim();
	    Matcher m = headerPattern.matcher(s);
	    if (!m.matches()) {
		throw new IOException("illegal JAR index header: " + s);
	    }
	    s = m.group(1);
	    if (!versionPattern.matcher(s).matches()) {
		throw new IOException("unsupported JAR index version: " + s);
	    }

	    s = r.readLine();
	    if (s == null) {
		throw new IOException("truncated JAR index");
	    }
	    s = s.trim();
	    if (s.length() > 0) {
		throw new IOException(
		    "non-empty line after JAR index header: " + s);
	    }

	    while ((s = r.readLine()) != null) {
		SourceJarURL url = new SourceJarURL(s.trim());
		if (logger.isLoggable(Level.FINEST)) {
		    logger.log(
			Level.FINEST,
			"JAR index references {0}",
			new Object[]{ url });
		}
		l.add(url);
		do {
		    s = r.readLine();
		} while (s != null && s.trim().length() > 0);
	    }
	    if (l.isEmpty()) {
		throw new IOException("empty JAR index");
	    }
	}

	/**
	 * Returns list of SourceJarURLs representing the JAR files referenced
	 * in the JAR index.
	 */
	List getJars() {
	    return jars;
	}
    }

    /**
     * Assembles and writes JAR indexes.
     */
    private static class JarIndexWriter {

	private final List urls = new ArrayList();
	private final Map contentMap = new HashMap();

	JarIndexWriter() {
	}

	/**
	 * Tabulates contents of the given JAR file, associating them with the
	 * provided URL for the JAR file.
	 */
	void addEntries(JarFile jar, SourceJarURL url) {
	    Set contents = new HashSet();
	    for (Enumeration e = jar.entries(); e.hasMoreElements();) {
		String name = ((JarEntry) e.nextElement()).getName();
		if (!(name.startsWith("META-INF") || name.endsWith("/"))) {
		    int pos = name.lastIndexOf("/");
		    contents.add((pos != -1) ? name.substring(0, pos) : name);
		}
	    }
	    if (!contents.isEmpty()) {
		urls.add(url);
		contentMap.put(url, contents);
	    }
	}

	/**
	 * Writes JAR index to the given output stream.
	 */
	void write(JarOutputStream jout) throws IOException {
	    if (contentMap.isEmpty()) {
		logger.finer("omitting empty JAR index");
		return;
	    }
	    logger.finer("writing JAR index");
	    jout.putNextEntry(new JarEntry("META-INF/INDEX.LIST"));
	    Writer w = 
		new BufferedWriter(new OutputStreamWriter(jout, "UTF8"));
	    w.write("JarIndex-Version: 1.0\n\n");

	    // preserve original insertion order
	    for (Iterator i = urls.iterator(); i.hasNext();) {
		SourceJarURL url = (SourceJarURL) i.next();
		Set contents = (Set) contentMap.get(url);

		if (!url.raw.endsWith(".jar")) {
		    if (url.algorithm != null) {
			url = new SourceJarURL(
			    url.path, url.algorithm, url.digest, ".jar");
		    } else if (logger.isLoggable(Level.WARNING)) {
			logger.log(
			    Level.WARNING,
			    "JAR index entry {0} does not end in .jar",
			    new Object[]{ url });
		    }
		}
		if (logger.isLoggable(Level.FINEST)) {
		    logger.log(
			Level.FINEST,
			"writing JAR index entry {0}: {1}",
			new Object[]{ url, contents });
		}
		w.write(url + "\n");
		for (Iterator j = contents.iterator(); j.hasNext(); ) {
		    w.write(j.next() + "\n");
		}
		w.write("\n");
	    }
	    w.flush();
	    jout.closeEntry();
	}
    }

    /**
     * Parses preferred lists.
     */
    private static class PreferredListReader {

	private static final Pattern headerPattern = 
	    Pattern.compile("^PreferredResources-Version:\\s*(.*?)$");
	private static final Pattern versionPattern =
	    Pattern.compile("^1\\.\\d+$");
	private static final Pattern namePattern =
	    Pattern.compile("^Name:\\s*(.*)$");
	private static final Pattern preferredPattern =
	    Pattern.compile("^Preferred:\\s*(.*)$");

	private final boolean defaultPref;
	private final Map namePrefs = new HashMap();
	private final Map packagePrefs = new HashMap();
	private final Map subtreePrefs = new HashMap();

	/**
	 * Parses the given JAR file's preferred list, if any.
	 */
	PreferredListReader(JarFile jar) throws IOException {
	    JarEntry ent = jar.getJarEntry("META-INF/PREFERRED.LIST");
	    if (ent == null) {
		defaultPref = false;
		return;
	    }
	    logger.finer("reading preferred list");
	    BufferedReader r = new BufferedReader(
		new InputStreamReader(jar.getInputStream(ent), "UTF8"));

	    String s = r.readLine();
	    if (s == null) {
		throw new IOException("missing preferred list header");
	    }
	    s = s.trim();
	    Matcher m = headerPattern.matcher(s);
	    if (!m.matches()) {
		throw new IOException("illegal preferred list header: " + s);
	    }
	    s = m.group(1);
	    if (!versionPattern.matcher(s).matches()) {
		throw new IOException(
		    "unsupported preferred list version: " + s);
	    }

	    s = nextNonBlankLine(r);
	    if (s == null) {
		throw new IOException("empty preferred list");
	    }
	    if ((m = preferredPattern.matcher(s)).matches()) {
		defaultPref = Boolean.valueOf(m.group(1)).booleanValue();
		s = nextNonBlankLine(r);
	    } else {
		defaultPref = false;
	    }

	    while (s != null) {
		if (!(m = namePattern.matcher(s)).matches()) {
		    throw new IOException(
			"expected preferred entry name: " + s);
		}
		String name = m.group(1);

		s = nextNonBlankLine(r);
		if (s == null) {
		    throw new IOException("EOF before preferred entry");
		}
		if (!(m = preferredPattern.matcher(s)).matches()) {
		    throw new IOException("expected preferred entry: " + s);
		}
		Boolean pref = Boolean.valueOf(m.group(1));

		String key;
		Map map;
		if (name.endsWith("/*")) {
		    key = name.substring(0, name.length() - 2);
		    map = packagePrefs;
		} else if (name.endsWith("/")) {
		    key = name.substring(0, name.length() - 1);
		    map = packagePrefs;
		} else if (name.endsWith("/-")) {
		    key = name.substring(0, name.length() - 2);
		    map = subtreePrefs;
		} else {
		    key = name;
		    map = namePrefs;
		}
		if (key.length() == 0) {
		    throw new IOException(
			"invalid preferred entry name: " + name);
		}
		map.put(key, pref);
		if (logger.isLoggable(Level.FINEST)) {
		    logger.log(
			Level.FINEST,
			"read preferred list entry {0}: {1}",
			new Object[]{ name, pref });
		}

		s = nextNonBlankLine(r);
	    }
	}

	/**
	 * Returns true if list prefers given entry, or false otherwise.
	 */
	 boolean isPreferred(String entry) {
	     Boolean b = (Boolean) namePrefs.get(entry);
	     if (b != null) {
		 return b.booleanValue();
	     }

	     if (entry.endsWith(".class")) {
		 int i = entry.lastIndexOf('$');
		 while (i >= 0) {
		     String outer = entry.substring(0, i) + ".class";
		     if ((b = (Boolean) namePrefs.get(outer)) != null) {
			 return b.booleanValue();
		     }
		     i = entry.lastIndexOf('$', i - 1);
		 }
	     }

	     int i = entry.lastIndexOf('/');
	     if (i >= 0) {
		 String base = entry.substring(0, i);
		 if ((b = (Boolean) packagePrefs.get(base)) != null) {
		     return b.booleanValue();
		 }

		 for (;;) {
		     if ((b = (Boolean) subtreePrefs.get(base)) != null) {
			 return b.booleanValue();
		     }
		     if ((i = base.lastIndexOf('/')) < 0) {
			 break;
		     }
		     base = base.substring(0, i);
		 }
	     }

	     return defaultPref;
	 }

	/**
	 * Returns next non-blank, non-comment line, or null if end of file has
	 * been reached.
	 */
	private static String nextNonBlankLine(BufferedReader reader)
	    throws IOException
	{
	    String s;
	    while ((s = reader.readLine()) != null) {
		s = s.trim();
		if (s.length() > 0 && s.charAt(0) != '#') {
		    return s;
		}
	    }
	    return null;
	}
    }

    /**
     * Compiles and writes combined preferred lists.
     */
    private static class PreferredListWriter {

	private static final int NAME_LEN      = "Name: ".length();
	private static final int PREFERRED_LEN = "Preferred: ".length();
	private static final int TRUE_LEN      = "true".length();
	private static final int FALSE_LEN     = "false".length();
	private static final int NEWLINE_LEN   = "\n".length();

	private final HashMap pathMap = new HashMap();
	private final DirNode rootNode = new DirNode("");
	private int numPrefs = 0;

	PreferredListWriter() {
	    pathMap.put("", rootNode);
	}

	/**
	 * Records preferred status of each file entry in the given JAR file,
	 * determined using the provided preferred list reader.
	 */
	void addEntries(JarFile jar, PreferredListReader prefReader)
	    throws IOException
	{
	    for (Enumeration e = jar.entries(); e.hasMoreElements(); ) {
		String path = ((JarEntry) e.nextElement()).getName();
		if (!(path.startsWith("META-INF") || path.endsWith("/"))) {
		    boolean pref = prefReader.isPreferred(path);
		    if (logger.isLoggable(Level.FINEST)) {
			logger.log(
			    Level.FINEST,
			    pref ? "preferred: {0}" : "not preferred: {0}",
			    new Object[]{ path });
		    }
		    addFile(path, pref);
		}
	    }
	}

	/**
	 * Writes minimal combined preferred list to given output stream.
	 */
	void write(JarOutputStream jout) throws IOException {
	    if (numPrefs == 0) {
		logger.finer("omitting empty preferred list");
		return;
	    }
	    logger.finer("writing preferred list");

	    jout.putNextEntry(new JarEntry("META-INF/PREFERRED.LIST"));
	    Writer w = 
		new BufferedWriter(new OutputStreamWriter(jout, "UTF8"));
	    w.write("PreferredResources-Version: 1.0\n");

	    rootNode.compileList();
	    rootNode.writeList(w);

	    w.flush();
	    jout.closeEntry();
	}

	/**
	 * Records the preferred setting of the given file entry.
	 */
	private void addFile(String path, boolean preferred)
	    throws IOException
	{
	    FileNode fn = (FileNode) pathMap.get(path);
	    if (fn != null) {
		if (fn.preferred != preferred) {
		    throw new LocalizedIOException(
			"jarwrapper.prefconflict", path);
		}
		return;
	    }

	    fn = new FileNode(path, preferred);
	    pathMap.put(path, fn);
	    if (preferred) {
		numPrefs++;
	    }

	    path = parentPath(path);
	    DirNode dn = (DirNode) pathMap.get(path);
	    if (dn != null) {
		dn.files.add(fn);
		return;
	    }
	    dn = new DirNode(path);
	    pathMap.put(path, dn);
	    dn.files.add(fn);

	    for (path = parentPath(path); ; path = parentPath(path)) {
		DirNode pn = (DirNode) pathMap.get(path);
		if (pn != null) {
		    pn.subdirs.add(dn);
		    return;
		}
		pn = new DirNode(path);
		pathMap.put(path, pn);
		pn.subdirs.add(dn);
		dn = pn;
	    }
	}

	/**
	 * Returns path of the parent directory of the indicated JAR entry.
	 */
	private static String parentPath(String path) {
	    if (path.endsWith("/")) {
		path = path.substring(0, path.length() - 1);
	    }
	    int i = path.lastIndexOf('/');
	    return (i >= 0) ? path.substring(0, i + 1) : "";
	}

	static int min(int i1, int i2, int i3) {
	    return Math.min(i1, Math.min(i2, i3));
	}

	/**
	 * Returns the number of characters needed to write a preferred list
	 * entry with the given name and preferred setting.  If the given name
	 * is null, then the length of a "default" preferred list entry (i.e.,
	 * an entry without a name) is returned.
	 */
	static int calcEntryLength(String name, boolean pref) {
	    int len = NEWLINE_LEN;
	    if (name != null) {
		len += NAME_LEN + name.length() + NEWLINE_LEN;
	    }
	    len += PREFERRED_LEN + (pref ? TRUE_LEN : FALSE_LEN) + NEWLINE_LEN;
	    return len;
	}

	/**
	 * Writes preferred list entry with the given name and preferred
	 * setting.  If the given name is null, then a "default" preferred list
	 * entry is written.
	 */
	static void writeEntry(Writer w, String name, boolean pref)
	    throws IOException
	{
	    if (logger.isLoggable(Level.FINEST)) {
		logger.log(
		    Level.FINEST,
		    "writing preferred list entry {0}: {1}",
		    new Object[]{
			(name != null) ? name : "<default>",
			Boolean.valueOf(pref) });
	    }
	    w.write("\n");
	    if (name != null) {
		w.write("Name: " + name + "\n");
	    }
	    w.write("Preferred: " + pref + "\n");
	}

	/**
	 * Stores file preference state.
	 */
	private static class FileNode {

	    /* action constants */
	    static final int NONE    = 0;
	    static final int SKIP    = 1;
	    static final int INCLUDE = 2;

	    final String path;
	    final boolean preferred;
	    int action;

	    FileNode(String path, boolean preferred) {
		this.path = path;
		this.preferred = preferred;
	    }
	}

	/**
	 * Represents JAR-internal directory.
	 */
	private class DirNode {

	    final String path;
	    final List subdirs = new ArrayList();
	    final List files = new ArrayList();

	    /*
	     * The length, in characters, of the preferred list covering this
	     * directory subtree if the default preferred setting for the
	     * entire subtree is true.
	     */
	    int prefSubtreeLen;
	    /*
	     * The length, in characters, of the preferred list covering this
	     * directory subtree if the default preferred setting for the
	     * immediate directory is true, but the default preferred setting
	     * for the subtree as a whole is false.
	     */
	    int prefPackageLen;
	    /*
	     * The length, in characters, of the preferred list covering this
	     * directory subtree if the default preferred setting for the
	     * entire subtree is false.
	     */
	    int unprefSubtreeLen;
	    /*
	     * The length, in characters, of the preferred list covering this
	     * directory subtree if the default preferred setting for the
	     * immediate directory is false, but the default preferred setting
	     * for the subtree as a whole is true.
	     */
	    int unprefPackageLen;

	    DirNode(String path) {
		this.path = path;
	    }

	    /**
	     * Computes minimal list length using dynamic programming.
	     */
	    void compileList() {
		int prefLen = 0, unprefLen = 0;
		for (Iterator i = files.iterator(); i.hasNext(); ) {
		    FileNode fn = (FileNode) i.next();

		    for (int j = fn.path.lastIndexOf('$'); 
			 j != -1;
			 j = fn.path.lastIndexOf('$', j - 1))
		    {
			FileNode fn2 = (FileNode) pathMap.get(
			    fn.path.substring(0, j) + ".class");
			if (fn2 != null) {
			    fn.action = (fn.preferred == fn2.preferred) ?
				FileNode.SKIP : FileNode.INCLUDE;
			    break;
			}
		    }

		    int entryLen = calcEntryLength(fn.path, fn.preferred);
		    if (fn.action == FileNode.SKIP) {
			// won't list, so don't increment length counts
		    } else if (fn.action == FileNode.INCLUDE) {
			prefLen += entryLen;
			unprefLen += entryLen;
		    } else if (fn.preferred) {
			unprefLen += entryLen;
		    } else {
			prefLen += entryLen;
		    }
		}
		prefSubtreeLen = prefLen;
		prefPackageLen = prefLen;
		unprefSubtreeLen = unprefLen;
		unprefPackageLen = unprefLen;

		for (Iterator i = subdirs.iterator(); i.hasNext();) {
		    DirNode dn = (DirNode) i.next();
		    dn.compileList();
		    String subtreePath = dn.path + "-";

		    prefSubtreeLen += min(
			dn.prefSubtreeLen,
			dn.unprefSubtreeLen +
			    calcEntryLength(subtreePath, false),
			dn.unprefPackageLen + calcEntryLength(dn.path, false));
		    prefPackageLen += min(
			dn.prefSubtreeLen + calcEntryLength(subtreePath, true),
			dn.prefPackageLen + calcEntryLength(dn.path, true),
			dn.unprefSubtreeLen);
		    unprefSubtreeLen += min(
			dn.prefSubtreeLen + calcEntryLength(subtreePath, true),
			dn.prefPackageLen + calcEntryLength(dn.path, true),
			dn.unprefSubtreeLen);
		    unprefPackageLen += min(
			dn.prefSubtreeLen,
			dn.unprefSubtreeLen +
			    calcEntryLength(subtreePath, false),
			dn.unprefPackageLen + calcEntryLength(dn.path, false));
		}
	    }

	    /**
	     * Writes preferred list.  This method is only called on the 
	     * root node.
	     */
	    void writeList(Writer w) throws IOException {
		int totalPrefSubtreeLen =
		    prefSubtreeLen + calcEntryLength(null, true);
		boolean defaultPref = totalPrefSubtreeLen < unprefSubtreeLen;
		if (defaultPref) {
		    writeEntry(w, null, true);
		}
		writeFiles(w, defaultPref);
		for (Iterator i = subdirs.iterator(); i.hasNext();) {
		    ((DirNode) i.next()).writeDir(w, defaultPref);
		}
	    }

	    /**
	     * Writes preferred list entries (if any) for this directory, which
	     * inherits the given preferred value as its default.
	     */
	    void writeDir(Writer w, boolean contextPref) throws IOException {
		boolean dirPref;
		boolean subdirPref;
		String subtreePath = path + "-";
		if (contextPref) {
		    int totalUnprefPackageLen =
			unprefPackageLen + calcEntryLength(path, false);
		    int totalUnprefSubtreeLen =
			unprefSubtreeLen + calcEntryLength(subtreePath, false);
		    int best = min(
			prefSubtreeLen,
			totalUnprefPackageLen,
			totalUnprefSubtreeLen);
		    if (best == prefSubtreeLen) {
			dirPref = true;
			subdirPref = true;
		    } else if (best == totalUnprefPackageLen) {
			writeEntry(w, path, false);
			dirPref = false;
			subdirPref = true;
		    } else {
			writeEntry(w, subtreePath, false);
			dirPref = false;
			subdirPref = false;
		    }
		} else {
		    int totalPrefPackageLen =
			prefPackageLen + calcEntryLength(path, true);
		    int totalPrefSubtreeLen =
			prefSubtreeLen + calcEntryLength(subtreePath, true);
		    int best = min(
			unprefSubtreeLen,
			totalPrefPackageLen,
			totalPrefSubtreeLen);
		    if (best == unprefSubtreeLen) {
			dirPref = false;
			subdirPref = false;
		    } else if (best == totalPrefPackageLen) {
			writeEntry(w, path, true);
			dirPref = true;
			subdirPref = false;
		    } else {
			writeEntry(w, subtreePath, true);
			dirPref = true;
			subdirPref = true;
		    }
		}
		writeFiles(w, dirPref);
		for (Iterator i = subdirs.iterator(); i.hasNext(); ) {
		    ((DirNode) i.next()).writeDir(w, subdirPref);
		}
	    }

	    /**
	     * Writes preferred list entries (if any) for files in this
	     * directory, which has the given preferred value as its default.
	     */
	    void writeFiles(Writer w, boolean contextPref) throws IOException {
		for (Iterator i = files.iterator(); i.hasNext(); ) {
		    FileNode fn = (FileNode) i.next();
		    if (fn.action != FileNode.SKIP &&
			(fn.action == FileNode.INCLUDE ||
			 fn.preferred != contextPref))
		    {
			writeEntry(w, fn.path, fn.preferred);
		    }
		}
	    }
	}
    }
}
