/* * $Id$ * * Copyright (c) 2009 by Joel Uckelman * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Library General Public * License (LGPL) as published by the Free Software Foundation. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Library General Public License for more details. * * You should have received a copy of the GNU Library General Public * License along with this library; if not, copies are available * at http://www.opensource.org. */ package VASSAL.tools.io; import static VASSAL.tools.IterableEnumeration.iterate; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.zip.CRC32; import java.util.zip.CheckedOutputStream; import java.util.zip.Checksum; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import org.apache.commons.io.FileUtils; import VASSAL.Info; import VASSAL.tools.concurrent.CountingReadWriteLock; /** * @author Joel Uckelman * @since 3.2.0 */ public class ZipArchive implements FileArchive { private final File archiveFile; private ZipFile zipFile; private boolean modified = false; private boolean closed = true; private static class Entry { public ZipEntry ze; public File file; public Entry(ZipEntry ze, File file) { this.ze = ze; this.file = file; } @Override public String toString() { return getClass().getName() + "[file=\"" + file + "\", ze=\"" + ze + "\"]"; } } private final Map<String,Entry> entries = new HashMap<String,Entry>(); private final ReadWriteLock rwl = new CountingReadWriteLock(); private final Lock r = rwl.readLock(); private final Lock w = rwl.writeLock(); /** * Opens a ZIP archive. * * @param path the name of the archive * @throws IOException */ public ZipArchive(String path) throws IOException { this(path, false); } /** * Opens a ZIP archive. * * @param file the name of the archive * @throws IOException */ public ZipArchive(File file) throws IOException { this(file, false); } /** * Opens a ZIP archive. * * @param path the name of the archive * @param truncate if <code>true</code>, truncate the archive file on open * @throws IOException */ public ZipArchive(String path, boolean truncate) throws IOException { this(new File(path), truncate); } /** * Opens a ZIP archive. * * @param file the name of the archive * @param truncate if <code>true</code>, truncate the archive file on open * @throws IOException */ public ZipArchive(File file, boolean truncate) throws IOException { if (file == null) throw new IllegalArgumentException(); this.archiveFile = file; if (truncate) { archiveFile.delete(); } } /** * Copies a ZIP archive. * * @param src the name of the source archive * @param dst the name of the destination archive * @throws IOException */ public ZipArchive(FileArchive src, String dst) throws IOException { this(src, new File(dst)); } /** * Copies a ZIP archive. * * @param src the name of the source archive * @param dst the name of the destination archive * @throws IOException */ public ZipArchive(FileArchive src, File dst) throws IOException { this(dst, true); final byte[] buf = new byte[8192]; // copy each entry to the new archive for (String name : src.getFiles()) { InputStream in = null; try { in = src.getInputStream(name); OutputStream out = null; try { out = getOutputStream(name); IOUtils.copy(in, out, buf); out.close(); } finally { IOUtils.closeQuietly(out); } in.close(); } finally { IOUtils.closeQuietly(in); } } flush(); } /** {@inheritDoc} */ public String getName() { return archiveFile.getPath(); } /** {@inheritDoc} */ public File getFile() { return archiveFile; } /** {@inheritDoc} */ public boolean isClosed() { return closed; } /** {@inheritDoc} */ public boolean isModified() { return modified; } /** * {@inheritDoc} * * <b>Note:</b> It is impeative the that calling code ensures that this * stream is eventually closed, since the returned stream holds a read * lock on the archive. */ public InputStream getInputStream(String path) throws IOException { r.lock(); try { openIfClosed(); final Entry e = entries.get(path); if (e == null) { throw new FileNotFoundException(path + " not in archive"); } InputStream in = null; if (e.file != null) { in = new FileInputStream(e.file); } else if (zipFile != null) { // NB: Undocumented, but ZipFile.getInputStream can return null! in = zipFile.getInputStream(e.ze); } if (in == null) { throw new FileNotFoundException(path + " not in archive"); } return new ZipArchiveInputStream(in); } catch (IOException ex) { r.unlock(); throw ex; } } /** * {@inheritDoc} * * <b>Note:</b> It is imperative the that calling code ensures that this * stream is eventually closed, since the returned stream holds a write * lock on the archive. */ public OutputStream getOutputStream(String path) throws IOException { return getOutputStream(path, true); } /** * Gets an {@link OutputStream} to write to the given file. * * <b>Note:</b> It is imperative the that calling code ensures that this * stream is eventually closed, since the returned stream holds a write * lock on the archive. * * @param path the path to the file in the archive * @param compress whether to compress the file * @return an <code>OutputStream</code> for the requested file * @throws IOException */ public OutputStream getOutputStream(String path, boolean compress) throws IOException { w.lock(); try { openIfClosed(); modified = true; // set up new ZipEntry final ZipEntry ze = new ZipEntry(path); ze.setMethod(compress ? ZipEntry.DEFLATED : ZipEntry.STORED); // create new temp file final File tf = File.createTempFile("zip", ".tmp", Info.getTempDir()); // set up new Entry final Entry e = new Entry(ze, tf); final Entry old = entries.put(path, e); // clean up old temp file if (old != null && old.file != null) { old.file.delete(); } return new ZipArchiveOutputStream( new FileOutputStream(e.file), new CRC32(), e.ze ); } catch (IOException ex) { w.unlock(); throw ex; } } /** {@inheritDoc} */ public void add(String path, String extPath) throws IOException { add(path, new File(extPath)); } /** {@inheritDoc} */ public void add(String path, File extPath) throws IOException { FileInputStream in = null; try { in = new FileInputStream(extPath); add(path, in); in.close(); } finally { IOUtils.closeQuietly(in); } } /** {@inheritDoc} */ public void add(String path, byte[] bytes) throws IOException { add(path, new ByteArrayInputStream(bytes)); } /** {@inheritDoc} */ public void add(String path, InputStream in) throws IOException { OutputStream out = null; try { out = getOutputStream(path); IOUtils.copy(in, out); out.close(); } finally { IOUtils.closeQuietly(out); } } /** {@inheritDoc} */ public boolean remove(String path) throws IOException { w.lock(); try { openIfClosed(); final Entry e = entries.remove(path); if (e != null) { modified = true; if (e.file != null) { e.file.delete(); } } return e != null; } finally { w.unlock(); } } /** {@inheritDoc} */ public void revert() throws IOException { w.lock(); try { if (!modified) { return; } // delete all temporary files for (Entry e : entries.values()) { if (e != null && e.file != null) { e.file.delete(); } } modified = false; } finally { w.unlock(); } } /** {@inheritDoc} */ public void flush() throws IOException { w.lock(); try { if (modified) { writeToDisk(); } } finally { w.unlock(); } } /** {@inheritDoc} */ public void close() throws IOException { w.lock(); try { if (closed) { return; } else if (modified) { writeToDisk(); } else if (zipFile != null) { zipFile.close(); zipFile = null; closed = true; entries.clear(); } } finally { w.unlock(); } } private void writeToDisk() throws IOException { // write all files to a temporary zip archive final File tmpFile = File.createTempFile("tmp", ".zip", archiveFile.getParentFile()); ZipOutputStream out = null; try { out = new ZipOutputStream( new BufferedOutputStream( new FileOutputStream(tmpFile))); out.setLevel(9); final byte[] buf = new byte[8192]; if (zipFile != null) { zipFile.close(); zipFile = null; // copy unmodified file into the temp archive ZipInputStream in = null; try { in = new ZipInputStream( new BufferedInputStream( new FileInputStream(archiveFile))); ZipEntry ze = null; while ((ze = in.getNextEntry()) != null) { // skip modified or removed entries final Entry e = entries.get(ze.getName()); if (e == null || e.file != null) continue; // We can't reuse entries for compressed files because there's // no way to reset all fields to acceptable values. if (ze.getMethod() == ZipEntry.DEFLATED) { ze = new ZipEntry(ze.getName()); ze.setTime(ze.getTime()); } out.putNextEntry(ze); IOUtils.copy(in, out, buf); entries.remove(ze.getName()); } in.close(); } finally { IOUtils.closeQuietly(in); } } for (Entry e : entries.values()) { // skip removed or unmodified files if (e == null || e.file == null) continue; // write new or modified file into the temp archive FileInputStream in = null; try { in = new FileInputStream(e.file); out.putNextEntry(e.ze); IOUtils.copy(in, out, buf); in.close(); } finally { IOUtils.closeQuietly(in); } } out.close(); } finally { IOUtils.closeQuietly(out); } // Replace old archive with temp archive. if (!tmpFile.renameTo(archiveFile)) { try { FileUtils.forceDelete(archiveFile); FileUtils.moveFile(tmpFile, archiveFile); } catch (IOException e) { String err = "Unable to overwrite " + archiveFile.getAbsolutePath() + ": "; if (!archiveFile.exists()) { err += " file does not exist."; } else if (!archiveFile.canWrite()) { err += " file is not writable."; } else if (!archiveFile.isFile()) { err += " not a normal file."; } err += " Data written to " + tmpFile.getAbsolutePath() + " instead."; throw (IOException) new IOException(err).initCause(e); } } // Delete all temporary files for (Entry e : entries.values()) { if (e != null && e.file != null) { e.file.delete(); } } closed = true; modified = false; entries.clear(); } /** {@inheritDoc} */ public boolean contains(String path) throws IOException { r.lock(); try { openIfClosed(); return entries.containsKey(path); } finally { r.unlock(); } } /** {@inheritDoc} */ public long getSize(String path) throws IOException { r.lock(); try { openIfClosed(); final Entry e = entries.get(path); if (e == null) { throw new FileNotFoundException(path + " not in archive"); } return e.file == null ? e.ze.getSize() : e.file.length(); } finally { r.unlock(); } } /** {@inheritDoc} */ public long getMTime(String path) throws IOException { r.lock(); try { openIfClosed(); final Entry e = entries.get(path); if (e == null) { throw new FileNotFoundException(path + " not in archive"); } return e.file == null ? e.ze.getTime() : e.file.lastModified(); } finally { r.unlock(); } } /** {@inheritDoc} */ public List<String> getFiles() throws IOException { r.lock(); try { openIfClosed(); return new ArrayList<String>(entries.keySet()); } finally { r.unlock(); } } /** {@inheritDoc} */ public List<String> getFiles(String root) throws IOException { if (root.length() == 0) { return getFiles(); } r.lock(); try { openIfClosed(); // FIXME: directories need not have entries in the ZipFile! // if (!entries.containsKey(root)) // throw new FileNotFoundException(root + " not in archive"); root += '/'; final ArrayList<String> names = new ArrayList<String>(); for (String n : entries.keySet()) { if (n.startsWith(root)) { names.add(n); } } return names; } finally { r.unlock(); } } /** Rebuilds the {@link ZipEntries} from our underlying {@link ZipFile}. */ private synchronized void readEntries() throws IOException { entries.clear(); if (archiveFile.exists() && archiveFile.length() > 0) { zipFile = new ZipFile(archiveFile); for (ZipEntry e : iterate(zipFile.entries())) { entries.put(e.getName(), new Entry(e, null)); } } } /** Opens the archive if it is closed. */ private synchronized void openIfClosed() throws IOException { if (closed) { readEntries(); modified = false; closed = false; } } /** An {@link InputStream} which releases the read lock on close. */ private class ZipArchiveInputStream extends FilterInputStream { public ZipArchiveInputStream(InputStream in) { super(in); if (in == null) { throw new NullPointerException("in == null"); } } private boolean closed = false; @Override public void close() throws IOException { if (closed) { return; } try { super.close(); } finally { r.unlock(); closed = true; } } } /** * An {@link OutputStream} which calculates a checksum, counts bytes * written, and releases the write lock on close. */ private class ZipArchiveOutputStream extends CheckedOutputStream { private ZipEntry entry; private long count = 0; public ZipArchiveOutputStream(OutputStream out, Checksum cksum, ZipEntry e) { super(out, cksum); if (out == null) { throw new NullPointerException("out == null"); } if (cksum == null) { throw new NullPointerException("cksum == null"); } if (e == null) { throw new NullPointerException("e == null"); } entry = e; } @Override public void write(byte[] bytes, int off, int len) throws IOException { super.write(bytes, off, len); count += len; } @Override public void write(int b) throws IOException { super.write(b); ++count; } @Override public void flush() throws IOException { super.flush(); entry.setSize(count); entry.setCrc(getChecksum().getValue()); } private boolean closed = false; @Override public void close() throws IOException { if (closed) { return; } try { super.close(); } finally { w.unlock(); closed = true; } } } public static void main(String[] args) throws IOException { final ZipArchive archive = new ZipArchive("test.zip"); // write test archive.add("NOTES", "NOTES"); archive.add("README.txt", "README.txt"); archive.flush(); // read test InputStream in = null; try { in = archive.getInputStream("NOTES"); IOUtils.copy(in, System.out); in.close(); } finally { IOUtils.closeQuietly(in); } archive.close(); } }