All Downloads are FREE. Search and download functionalities are using the official Maven repository.

de.schlichtherle.io.ArchiveFileSystem Maven / Gradle / Ivy

Go to download

TrueZIP is a Java based Virtual File System (VFS) to enable transparent, multi-threaded read/write access to archive files (ZIP, TAR etc.) as if they were directories. Archive files may be arbitrarily nested and the nesting level is only limited by heap and file system size.

The newest version!
/*
 * Copyright (C) 2005-2010 Schlichtherle IT Services
 *
 * Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package de.schlichtherle.io;

import de.schlichtherle.io.archive.spi.*;

import java.io.*;
import java.util.*;

import javax.swing.Icon;

/**
 * This class implements a virtual file system of archive entries for use
 * by the archive controller provided to the constructor.
 * 

* WARNING:This class is not thread safe! * All calls to non-static methods must be synchronized on the * respective {@code ArchiveController} object! * * @author Christian Schlichtherle * @version $Id$ * @since TrueZIP 6.0 (refactored from the former {@code ZipFileSystem}) */ final class ArchiveFileSystem implements Entry { /** * Denotes the entry name of the virtual root directory as a valid * directory entry name. *

* This constant cannot be used for identity comparison! * * @since TrueZIP 6.5 */ private static final String ROOT_DIRECTORY_NAME = SEPARATOR; /** The controller that this filesystem belongs to. */ private final ArchiveFileSystemController controller; /** The read only status of this file system. */ private final boolean readOnly; /** * The map of ArchiveEntries in this file system. * If this is a read-only file system, this is actually an unmodifiable * map. * This field should be considered final! *

* Note that the ArchiveEntries in this map are shared with the * {@link InputArchive} object provided to this class' constructor. */ private Map master; /** The archive entry for the virtual root of this file system. */ private final ArchiveEntry root; /** The number of times this file system has been modified (touched). */ private long touched; /** For use by {@link #split} only! */ private final String[] split = new String[2]; /** * Creates a new archive file system and ensures its integrity. * The root directory is created with its last modification time set to * the system's current time. * The file system is modifiable and marked as touched! * * @param controller The controller which will use this file system. * This constructor will finally call * {@link ArchiveFileSystemController#touch} once it has fully * initialized this instance. */ ArchiveFileSystem(final ArchiveFileSystemController controller) throws IOException { this.controller = controller; touched = 1; master = new LinkedHashMap(64); // Setup root. root = createArchiveEntry(ROOT_DIRECTORY_NAME); root.setTime(System.currentTimeMillis()); master.put(ROOT_DIRECTORY_NAME, root); readOnly = false; controller.touch(); } /** * Mounts the archive file system from {@code archive} and ensures its * integrity. * First, a root directory with the given last modification time is * created - it's never loaded from the archive! * Then the entries from the archive are loaded into the file system and * its integrity is checked: * Any missing parent directories are created using the system's current * time as their last modification time - existing directories will never * be replaced. *

* Note that the entries in this file system are shared with * {@code archive}. * * @param controller The controller which will use this file system. * This constructor will solely use the controller as a factory * to create missing archive entries using * {@link ArchiveFileSystemController#createArchiveEntry}. * @param archive The archive to mount the file system from. * @param rootTime The last modification time of the root of the mounted * file system in milliseconds since the epoch. * @param readOnly If and only if {@code true}, any subsequent * modifying operation will result in a * {@link ArchiveReadOnlyException}. */ ArchiveFileSystem( final ArchiveFileSystemController controller, final InputArchive archive, final long rootTime, final boolean readOnly) { this.controller = controller; final int iniCap = (int) (archive.getNumArchiveEntries() / 0.75f) + 1; master = new LinkedHashMap(iniCap); // Setup root. root = createArchiveEntry(ROOT_DIRECTORY_NAME); root.setTime(rootTime); // do NOT yet touch the file system! master.put(ROOT_DIRECTORY_NAME, root); Enumeration entries = archive.getArchiveEntries(); while (entries.hasMoreElements()) { final ArchiveEntry entry = (ArchiveEntry) entries.nextElement(); final String entryName = getEntryName(entry); // Map entry if it doesn't address the virtual root directory. if (!ROOT_DIRECTORY_NAME.equals(entryName) && !("." + SEPARATOR).equals(entryName)) { entry.setMetaData(new ArchiveEntryMetaData(entry)); master.put(entryName, entry); } } // Now perform a file system check to fix missing parent directories. // This needs to be done separately! //entries = Collections.enumeration(master.values()); // concurrent modification! entries = archive.getArchiveEntries(); while (entries.hasMoreElements()) fixParents((ArchiveEntry) entries.nextElement()); // Reset master map to be unmodifiable if this is a readonly file system this.readOnly = readOnly; if (readOnly) master = Collections.unmodifiableMap(master); assert touched == 0; // don't call !isTouched() - preconditions not met yet! } private String getEntryName(ArchiveEntry entry) { // Fix issue #42 - see https://truezip.dev.java.net/issues/show_bug.cgi?id=42 return entry.getName().replace('\\', SEPARATOR_CHAR); } /** * Checks whether the given entry entryName is a legal entry name. * A legal entry name does not denote the virtual root directory, the dot * directory ({@code "."}) or the dot-dot directory * ({@code ".."}) or any of their descendants. */ private static boolean isLegalEntryName(final String entryName) { final int l = entryName.length(); if (l <= 0) return false; // never fix empty pathnames switch (entryName.charAt(0)) { case SEPARATOR_CHAR: return false; // never fix root or absolute pathnames case '.': if (l >= 2) { switch (entryName.charAt(1)) { case '.': if (l >= 3) { if (entryName.charAt(2) == SEPARATOR_CHAR) { assert entryName.startsWith(".." + SEPARATOR); return false; } // Fall through. } else { assert "..".equals(entryName); return false; } break; case SEPARATOR_CHAR: assert entryName.startsWith("." + SEPARATOR); return false; default: // Fall through. } } else { assert ".".equals(entryName); return false; } break; default: // Fall through. } return true; } /** * Called from a constructor to fix the parent directories of * {@code entry}, ensuring that all parent directories of the entry * exist and that they contain the respective child. * If a parent directory does not exist, it is created using an * unkown time as the last modification time - this is defined to be a * ghost directory. * If a parent directory does exist, the respective child is added * (possibly yet again) and the process is continued. */ private void fixParents(final ArchiveEntry entry) { final String entryName = getEntryName(entry); if (isLegalEntryName(entryName) && SEPARATOR_CHAR != entryName.charAt(0)) fixParents(entryName); } private void fixParents(final String entryName) { // When recursing into this method, it may be called with the root // directory as its parameter, so we may NOT skip the following test. if (ROOT_DIRECTORY_NAME.equals(entryName)) return; // never fix root or empty or absolute pathnames final String split[] = split(entryName); final String parentName = split[0]; final String baseName = split[1]; ArchiveEntry parent = (ArchiveEntry) master.get(parentName); if (null == parent) { parent = createArchiveEntry(parentName); master.put(parentName, parent); } fixParents(parentName); parent.getMetaData().children.add(baseName); } /** * Splits the given entry name in a parent entry name and a base name. * * @param entryName The name of the entry which's parent entry name and * base name are to be returned. * @return The {@link #split} array, which will hold at least two strings: *

    *
  • Index 0 holds the parent entry name. * If {@code entryName} is empty or equals * {@code SEPARATOR}, this is {@code null}. * Otherwise, this contains the parent name of the entry and * always ends with an {@code SEPARATOR}. *
  • Index 1 holds the base name. * If {@code entryName} is empty or equals * {@code SEPARATOR}, this is an empty string. * Otherwise, this contains the base name of the entry and * never contains an {@code SEPARATOR}. *
* @throws NullPointerException If {@code entryName} is * {@code null}. */ private String[] split(final String entryName) { //return Paths.split(entryName, SEPARATOR_CHAR, split); return split(entryName, split); } // This method is package private only to enable unit tests! static String[] split(final String entryName, final String[] result) { assert entryName != null; assert result != null; assert result.length >= 2; // Calculate index of last character, ignoring trailing entry separator. int end = entryName.length(); if (0 <= --end) if (entryName.charAt(end) == SEPARATOR_CHAR) end--; // Now look for the separator. int base = entryName.lastIndexOf(SEPARATOR_CHAR, end); end++; // convert end index to interval boundary // Finally split according to our findings. if (base != -1) { // found slash? base++; result[0] = entryName.substring(0, base); // include separator, may produce only separator! result[1] = entryName.substring(base, end); // between separator and trailing separator } else { // no slash if (end > 0) { // At least one character exists, excluding a trailing separator? result[0] = ROOT_DIRECTORY_NAME; } else { result[0] = null; // no parent } result[1] = entryName.substring(0, end); // between prefix and trailing separator } return result; } /** * Indicates whether this file system is read only or not. * The default is {@code false}. */ boolean isReadOnly() { return readOnly; } /** * Indicates whether this file system has been modified since * its time of creation or the last call to {@code resetTouched()}. */ boolean isTouched() { assert controller.getFileSystem() == this; return touched != 0; } /** * Ensures that the controller's data structures required to output * entries are properly initialized and marks this virtual archive * file system as touched. * * @throws ArchiveReadOnlyExceptionn If this virtual archive file system * is read only. * @throws IOException If setting up the required data structures in the * controller fails for some reason. */ private void touch() throws IOException { if (isReadOnly()) throw new ArchiveReadOnlyException(); // Order is important here because of exceptions! if (touched == 0) controller.touch(); touched++; } /** * Returns an enumeration of all {@code ArchiveEntry} instances * in this file system. */ Enumeration getArchiveEntries() { assert controller.getFileSystem() == this; return Collections.enumeration(master.values()); } /** * Returns the virtual root directory of this file system. * This archive entry always exists. * It's name may depend on the archive type. * It's last modification time is guaranteed to be non-negative, so it's * not a ghost directory! */ ArchiveEntry getRoot() { assert controller.getFileSystem() == this; return root; } /** * Returns {@code true} iff the given entry name refers to the * virtual root directory within this controller. */ static boolean isRoot(String entryName) { return ROOT_NAME.equals(entryName); // possibly assigned by File.init(...) } /** * Looks up the specified entry in the file system and returns it or * {@code null} if not existent. */ ArchiveEntry get(String entryName) { assert entryName != null; assert controller.getFileSystem() == this; return (ArchiveEntry) master.get(entryName); } /** * Equivalent to {@link #link(String, boolean, ArchiveEntry) * link(entryName, createParents, null)}. */ Delta link(final String entryName, final boolean createParents) throws ArchiveFileSystemException { return link(entryName, createParents, null); } /** * Begins a "create and link entry" transaction to ensure that either a * new entry for the given {@code entryName} will be created or an * existing entry is replaced within this virtual archive file system. *

* This is the first step of a two-step process to create an archive entry * and link it into this virtual archive file system. * To commit the transaction, call {@link Delta#commit} on the returned object * after you have successfully conducted the operations which compose the * transaction. *

* Upon a {@code commit} operation, the last modification time of * the newly created and linked entries will be set to the system's * current time at the moment the transaction has begun and the file * system will be marked as touched at the moment the transaction has * been committed. *

* Note that there is no rollback operation: After this method returns, * nothing in the virtual file system has changed yet and all information * required to commit the transaction is contained in the returned object. * Hence, if the operations which compose the transaction fails, the * returned object may be safely collected by the garbage collector, * * @param entryName The relative path name of the entry to create or replace. * @param createParents If {@code true}, any non-existing parent * directory will be created in this file system with its last * modification time set to the system's current time. * @param template If not {@code null}, then the newly created or * replaced entry shall inherit as much properties from this * instance as possible (with the exception of the name). * This is typically used for archive copy operations and requires * some support by the archive driver. * @return A transaction object. You must call its * {@link Delta#commit} method in order to commit * link the newly created entry into this virtual archive file * system. * @throws ArchiveReadOnlyExceptionn If this virtual archive file system * is read only. * @throws ArchiveFileSystemException If one of the following is true: *

    *
  • {@code entryName} contains characters which are not * supported by the archive file. *
  • The entry name indicates a directory (trailing {@code /}) * and its entry does already exist within this file system. *
  • The entry is a file or directory and does already exist as * the respective other type within this file system. *
  • The parent directory does not exist and * {@code createParents} is {@code false}. *
  • One of the entry's parents denotes a file. *
*/ Delta link( final String entryName, final boolean createParents, final ArchiveEntry template) throws ArchiveFileSystemException { assert isRoot(entryName) || entryName.charAt(0) != SEPARATOR_CHAR; assert controller.getFileSystem() == this; if (isRoot(entryName)) throw new ArchiveFileSystemException(entryName, "virtual root directory cannot get replaced"); return new LinkDelta(entryName, createParents, template); } /** * A simple transaction for creating (and hence probably replacing) and * linking an entry in this virtual archive file system. * * @see #link */ private final class LinkDelta extends AbstractDelta { final Element[] elements; private LinkDelta( final String entryName, final boolean createParents, final ArchiveEntry template) throws ArchiveFileSystemException { if (isReadOnly()) throw new ArchiveReadOnlyException(); try { elements = createElements(entryName, createParents, template, 1); } catch (CharConversionException cce) { final ArchiveFileSystemException afse = new ArchiveFileSystemException(cce.toString()); afse.initCause(cce); throw afse; } } private Element[] createElements( final String entryName, final boolean createParents, final ArchiveEntry template, final int level) throws ArchiveFileSystemException, CharConversionException { final String split[] = split(entryName); final String parentName = split[0]; // could be separator only to indicate root final String baseName = split[1]; final Element[] elements; // Lookup parent entry, creating it where necessary and allowed. final ArchiveEntry parent = (ArchiveEntry) master.get(parentName); final ArchiveEntry entry; if (parent != null) { final ArchiveEntry oldEntry = (ArchiveEntry) master.get(entryName); ensureMayBeReplaced(entryName, oldEntry); elements = new Element[level + 1]; elements[0] = new Element(parentName, parent); entry = createArchiveEntry(entryName, template); elements[1] = new Element(baseName, entry); } else if (createParents) { elements = createElements( parentName, createParents, null, level + 1); entry = createArchiveEntry(entryName, template); elements[elements.length - level] = new Element(baseName, entry); } else { throw new ArchiveFileSystemException(entryName, "missing parent directory"); } return elements; } private void ensureMayBeReplaced( final String entryName, final ArchiveEntry oldEntry) throws ArchiveFileSystemException { final int end = entryName.length() - 1; if (entryName.charAt(end) == SEPARATOR_CHAR) { // entryName indicates directory if (oldEntry != null) throw new ArchiveFileSystemException(entryName, "directories cannot get replaced"); if (master.get(entryName.substring(0, end)) != null) throw new ArchiveFileSystemException(entryName, "directories cannot replace files"); } else { // entryName indicates file if (master.get(entryName + SEPARATOR) != null) throw new ArchiveFileSystemException(entryName, "files cannot replace directories"); } } /** Links the entries into this virtual archive file system. */ public void commit() throws IOException { assert controller.getFileSystem() == ArchiveFileSystem.this; assert elements.length >= 2; touch(); final long time = System.currentTimeMillis(); final int l = elements.length; ArchiveEntry parent = elements[0].entry; for (int i = 1; i < l ; i++) { final Element element = elements[i]; final String baseName = element.baseName; final ArchiveEntry entry = element.entry; if (parent.getMetaData().children.add(baseName) && parent.getTime() != ArchiveEntry.UNKNOWN) // never touch ghosts! parent.setTime(time); master.put(entry.getName(), entry); parent = entry; } final ArchiveEntry entry = elements[l - 1].entry; if (entry.getTime() == ArchiveEntry.UNKNOWN) entry.setTime(time); } public ArchiveEntry getEntry() { assert controller.getFileSystem() == ArchiveFileSystem.this; return elements[elements.length - 1].entry; } } // class LinkDelta private static abstract class AbstractDelta implements Delta { /** A data class for use by subclasses. */ static class Element { final String baseName; final ArchiveEntry entry; // This constructor is provided for convenience only. Element(String baseName, ArchiveEntry entry) { this.baseName = baseName; // may be null! assert entry != null; this.entry = entry; } } } // class AbstractDelta /** * This interface encapsulates the methods required to begin and commit * a simplified transaction (a delta) on this virtual archive file system. *

* Note that there is no {@code begin} or {@code rollback} * method in this class. * Instead, {@code begin} is expected to be implemented by the * constructor of the implementation and must not modify the file system, * so that an explicit {@code rollback} is not required. */ interface Delta { /** * Returns the entry operated by this file system delta. */ ArchiveEntry getEntry(); /** * Commits the simplified transaction, possibly modifying the * enclosing virtual archive file system. * * @throws IOException If the commit operation fails for any I/O * related reason. */ void commit() throws IOException; } // interface Delta /** * Creates an archive entry which is going to be linked into this virtual * archive file system in the near future. * The returned entry has properly initialized meta data, but is * otherwise left as created by the archive driver. * * @param entryName The path name of the entry to create or replace. * This must be a relative path name. * @param blueprint If not {@code null}, then the newly created entry * shall inherit as much attributes from this object as possible * (with the exception of the name). * This is typically used for archive copy operations and requires * some support by the archive driver. * @return An {@link ArchiveEntry} created by the archive driver and * properly initialized with meta data. * @throws CharConversionException If {@code entryName} contains * characters which are not supported by the archive file. */ private ArchiveEntry createArchiveEntry( final String entryName, final ArchiveEntry blueprint) throws CharConversionException { final ArchiveEntry entry = controller.createArchiveEntry(entryName, blueprint); entry.setMetaData(new ArchiveEntryMetaData(entry)); return entry; } /** * Like {@link #createArchiveEntry}, but throws an * {@code AssertionError} instead of * {@code CharConversionException}. * * @throws AssertionError If a {@link CharConversionException} occurs. */ private ArchiveEntry createArchiveEntry(final String entryName) { try { return createArchiveEntry(entryName, null); } catch (CharConversionException ex) { throw new AssertionError(ex); } } /** * If this method returns, the entry identified by the given * {@code entryName} has been successfully deleted from the virtual * archive file system. * If the entry is a directory, it must be empty for successful deletion. * * @throws ArchiveReadOnlyExceptionn If the virtual archive file system is * read only. * @throws ArchiveIllegalOperationException If the operation failed for * any other reason. */ private void unlink(final String entryName) throws IOException { if (isRoot(entryName)) throw new ArchiveFileSystemException(entryName, "virtual root directory cannot get unlinked"); try { final ArchiveEntry entry = (ArchiveEntry) master.remove(entryName); if (entry == null) throw new ArchiveFileSystemException(entryName, "entry does not exist"); if (entry == root || entry.isDirectory() && !entry.getMetaData().children.isEmpty()) { master.put(entryName, entry); // Restore file system throw new ArchiveFileSystemException(entryName, "directory is not empty"); } final String split[] = split(entryName); final String parentName = split[0]; final ArchiveEntry parent = (ArchiveEntry) master.get(parentName); assert parent != null : "The parent directory of \"" + entryName + "\" is missing - archive file system is corrupted!"; final boolean ok = parent.getMetaData().children.remove(split[1]); assert ok : "The parent directory of \"" + entryName + "\" does not contain this entry - archive file system is corrupted!"; touch(); if (parent.getTime() != ArchiveEntry.UNKNOWN) // never touch ghosts! parent.setTime(System.currentTimeMillis()); } catch (UnsupportedOperationException unmodifiableMap) { throw new ArchiveReadOnlyException(); } } // // File system operations used by the ArchiveController class: // boolean exists(final String entryName) { return get(entryName) != null || get(entryName + SEPARATOR) != null; } boolean isFile(final String entryName) { /*ArchiveEntry entry = get(entryName); if (entry == null) entry = get(entryName + SEPARATOR); return entry != null && !entry.isDirectory();*/ return get(entryName) != null; } boolean isDirectory(final String entryName) { /*ArchiveEntry entry = get(entryName + SEPARATOR); if (entry == null) entry = get(entryName); return entry != null && entry.isDirectory();*/ return get(entryName + SEPARATOR) != null; } Icon getOpenIcon(final String entryName) { assert !isRoot(entryName); ArchiveEntry entry = get(entryName); if (entry == null) entry = get(entryName + SEPARATOR); return entry != null ? entry.getOpenIcon() : null; } Icon getClosedIcon(final String entryName) { assert !isRoot(entryName); ArchiveEntry entry = get(entryName); if (entry == null) entry = get(entryName + SEPARATOR); return entry != null ? entry.getClosedIcon() : null; } boolean canWrite(final String entryName) { return !isReadOnly() && exists(entryName); } boolean setReadOnly(final String entryName) { return isReadOnly() && exists(entryName); } long length(final String entryName) { final ArchiveEntry entry = get(entryName); if (entry == null || entry.isDirectory()) return 0; // TODO: Review: Can we avoid this special case? // It's probably ZipDriver specific! // This entry is a plain file in the file system. // If entry.getSize() returns ArchiveEntry.UNKNOWN, the length is yet unknown. // This may happen if e.g. a ZIP entry has only been partially // written, i.e. not yet closed by another thread, or if this is a // ghost directory. // As this is not specified in the contract of the File class, return // 0 in this case instead. final long length = entry.getSize(); return length != ArchiveEntry.UNKNOWN ? length : 0; } long lastModified(final String entryName) { ArchiveEntry entry = get(entryName); if (entry == null) entry = get(entryName + SEPARATOR); if (entry != null) { // Depending on the driver type, entry.getTime() could return // a negative value. E.g. this is the default value that the // ArchiveDriver uses for newly created entries in order to // indicate an unknown time. // As this is not specified in the contract of the File class, // 0 is returned in this case instead. final long time = entry.getTime(); return time >= 0 ? time : 0; } // This entry does not exist. return 0; } boolean setLastModified(final String entryName, final long time) throws IOException { if (time < 0) throw new IllegalArgumentException(entryName + " (negative entry modification time)"); if (isReadOnly()) return false; ArchiveEntry entry = get(entryName); if (entry == null) { entry = get(entryName + SEPARATOR); if (entry == null) { // This entry does not exist. return false; } } // Order is important here! touch(); entry.setTime(time); return true; } String[] list(final String entryName) { // Lookup the entry as a directory. final ArchiveEntry entry = get(entryName + SEPARATOR); if (entry != null) return entry.getMetaData().list(); else return null; // does not exist as a directory } String[] list( final String entryName, final FilenameFilter filenameFilter, final File dir) { // Lookup the entry as a directory. final ArchiveEntry entry = get(entryName + SEPARATOR); if (entry != null) if (filenameFilter != null) return entry.getMetaData().list(filenameFilter, dir); else return entry.getMetaData().list(); // most efficient else return null; // does not exist as directory } File[] listFiles( final String entryName, final FilenameFilter filenameFilter, final File dir, final FileFactory factory) { // deprecated warning is OK! // Lookup the entry as a directory. final ArchiveEntry entry = get(entryName + SEPARATOR); if (entry != null) return entry.getMetaData().listFiles(filenameFilter, dir, factory); else return null; // does not exist as a directory } File[] listFiles( final String entryName, final FileFilter fileFilter, final File dir, final FileFactory factory) { // deprecated warning is OK! // Lookup the entry as a directory. final ArchiveEntry entry = get(entryName + SEPARATOR); if (entry != null) return entry.getMetaData().listFiles(fileFilter, dir, factory); else return null; // does not exist as a directory } void mkdir(String entryName, boolean createParents) throws IOException { link(entryName + SEPARATOR, createParents).commit(); } void delete(final String entryName) throws IOException { assert isRoot(entryName) || entryName.charAt(0) != SEPARATOR_CHAR; if (get(entryName) != null) { unlink(entryName); return; } final String dirEntryName = entryName + SEPARATOR; if (get(dirEntryName) != null) { unlink(dirEntryName); return; } throw new ArchiveFileSystemException(entryName, "archive entry does not exist"); } // // Exceptions: // /** * This exception is thrown when a client application tries to perform an * illegal operation on an archive file system. *

* This exception is private by intention: Clients applications should not * even know about the existence of virtual archive file systems. */ static class ArchiveFileSystemException extends IOException { private static final long serialVersionUID = 7625038629582374837L; /** The entry's path name. */ private final String entryName; private ArchiveFileSystemException(String message) { super(message); this.entryName = null; } private ArchiveFileSystemException(String entryName, String message) { super(message); this.entryName = entryName; } public String getMessage() { // For performance reasons, this string is constructed on demand // only! return entryName != null ? entryName + " (" + super.getMessage() + ")" : super.getMessage(); } } /** * This exception is thrown when a client tries to modify a read only * virtual archive file system. */ static class ArchiveReadOnlyException extends ArchiveFileSystemException { private static final long serialVersionUID = 7625038627494543837L; private ArchiveReadOnlyException() { super("Archive file is read-only!"); } } }