001    /*
002     * Jpkg - Java library and tools for operating system package creation.
003     *
004     * Copyright (c) 2007 Three Rings Design, Inc.
005     * All rights reserved.
006     *
007     * Redistribution and use in source and binary forms, with or without
008     * modification, are permitted provided that the following conditions
009     * are met:
010     * 1. Redistributions of source code must retain the above copyright
011     *    notice, this list of conditions and the following disclaimer.
012     * 2. Redistributions in binary form must reproduce the above copyright
013     *    notice, this list of conditions and the following disclaimer in the
014     *    documentation and/or other materials provided with the distribution.
015     * 3. Neither the name of the copyright owner nor the names of contributors
016     *    may be used to endorse or promote products derived from this software
017     *    without specific prior written permission.
018     *
019     * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
020     * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
021     * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
022     * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
023     * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
024     * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
025     * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
026     * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
027     * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
028     * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
029     * POSSIBILITY OF SUCH DAMAGE.
030     */
031    package com.threerings.jpkg.ar;
032    
033    import java.io.File;
034    import java.io.FileInputStream;
035    import java.io.FileOutputStream;
036    import java.io.IOException;
037    import java.io.InputStream;
038    import java.io.OutputStream;
039    import java.io.UnsupportedEncodingException;
040    import java.util.Arrays;
041    import java.util.Formatter;
042    
043    import org.apache.commons.io.IOUtils;
044    
045    /**
046     * Creates and works with Unix ar(1) archives.
047     * @see <a href="http://en.wikipedia.org/wiki/Ar_%28Unix%29">The Wikipedia ar (Unix) entry</a>
048     */
049    public class Archive
050    {
051        /** The character encoding all text added to the archive will be translated into. */
052        public static final String CHAR_ENCODING = "ASCII";
053    
054        /** The magic header which starts every ar archive. */
055        public static final byte[] AR_MAGIC;
056    
057        /** The byte used to pad data. */
058        public static final byte[] PADDING;
059    
060        /** The length of the header that appears before every file in the archive. */
061        public static final int FILE_HEADER_LENGTH = 60;
062    
063        /** Initialize the AR_MAGIC header and PADDING byte arrays. */
064        static {
065            try {
066                AR_MAGIC = "!<arch>\012".getBytes(CHAR_ENCODING);
067                PADDING = "\012".getBytes(CHAR_ENCODING);
068    
069            } catch (final UnsupportedEncodingException uee) {
070                throw new RuntimeException("Failed to initialize static byte arrays.", uee);
071            }
072        }
073    
074        /**
075         * Construct a new archive at the supplied {@link File} path. If the path already exists and is
076         * an ar(1) archive, open the archive for appending.
077         * @throws InvalidMagicException If the file being appended to is an invalid ar(1) archive.
078         * @throws IOException If any exception occurs during file i/o.
079         */
080        public Archive (File path)
081            throws InvalidMagicException, IOException
082        {
083            _path = path;
084    
085            // if the file already exists, and has content, verify it has the correct ar header.
086            if (_path.exists() && _path.length() > 0) {
087                InputStream input = null;
088                try {
089                    input = new FileInputStream(_path);
090                    final byte[] header = new byte[AR_MAGIC.length];
091                    input.read(header, 0, AR_MAGIC.length);
092                    if (!Arrays.equals(header, AR_MAGIC)) {
093                        throw new InvalidMagicException("Archive header is invalid: " + Arrays.toString(header));
094                    }
095    
096                } finally {
097                    IOUtils.closeQuietly(input);
098                }
099                _output = new FileOutputStream(_path, true);
100    
101            // otherwise create the file if necessary and write out the header.
102            } else {
103                if (!_path.exists()) {
104                    if (!_path.createNewFile()) {
105                        throw new IOException("Unable to create archive file at " + _path.getAbsolutePath());
106                    }
107                }
108    
109                _output = new FileOutputStream(_path);
110                _output.write(AR_MAGIC);
111            }
112        }
113    
114        /**
115         * Append the contents of the supplied {@link ArchiveEntry} to this archive.
116         * @param entry The {@link ArchiveEntry} to add to the archive.
117         * @throws PathnameTooLongException If the path name is too long for an ar(1) archive.
118         * @throws PathnameInvalidException If the path name is invalid, e.g. contains a space.
119         * @throws DataTooLargeException If the data contained in the entry is too large for an ar(1) archive.
120         * @throws IOException If any exception occurs during data i/o.
121         */
122        public void appendEntry (ArchiveEntry entry)
123            throws PathnameInvalidException, PathnameTooLongException, DataTooLargeException, IOException
124        {
125            // ar(1) only supports storing the file size as an integer. throw an exception if the
126            // data is too large.
127            if (entry.getSize() > Integer.MAX_VALUE) {
128                throw new DataTooLargeException("Data being added to the archive is too large. " +
129                    "path=[" + entry.getPath() + "], size=[" + entry.getSize() + "].");
130            }
131    
132            // add the entry header to the archive
133            addEntryHeader(entry);
134    
135            // append the entry data to the archive
136            final byte[] buffer = new byte[1024];
137            int len;
138            final InputStream input = entry.getInputStream();
139            try {
140                while ((len = input.read(buffer)) > 0) {
141                    _output.write(buffer, 0, len);
142                }
143    
144            } finally {
145                IOUtils.closeQuietly(input);
146            }
147    
148            // pad the data section if necessary
149            if (entry.getSize() % 2 != 0) {
150                _output.write(PADDING);
151            }
152        }
153    
154        /**
155         * Add a file header to the archive for the given ArchiveEntry.
156         */
157        private void addEntryHeader (ArchiveEntry entry)
158            throws IOException, PathnameTooLongException, PathnameInvalidException
159        {
160            if (entry.getPath().getBytes(CHAR_ENCODING).length > 16) {
161                throw new PathnameTooLongException("The supplied path name is too long: " + entry.getPath());
162            }
163    
164            if (entry.getPath().contains(" ")) {
165                throw new PathnameInvalidException("The path name cannot contain spaces: " + entry.getPath());
166            }
167    
168            // set the file mtime to now
169            final int mtime = (int)(System.currentTimeMillis() / 1000L);
170    
171            final StringBuffer buffer = new StringBuffer();
172            final Formatter formatter = new Formatter(buffer);
173    
174            /*
175             * Common file header format:
176             * All data is stored in an ASCII representation
177             * Fields 0-15:  Name        ASCII   (null-terminated)
178             * Fields 16-27: Mod date    Integer (seconds since epoch)
179             * Fields 28-33: Owner UID   Integer (gid)
180             * Fields 34-39: Owner GID   Integer (uid)
181             * Fields 40-47: File mode   Integer (octal)
182             * Fields 48-57: File size   Integer (bytes)
183             * Fields 58-59: File magic  Constant (\140\012)
184             */
185            formatter.format("%-16s%-12s%-6s%-6s%-8o%-10s%s%s",
186                entry.getPath(), mtime, entry.getUserId(), entry.getGroupId(), entry.getMode(), entry.getSize(), '\140', '\012');
187    
188            _output.write(buffer.toString().getBytes(CHAR_ENCODING));
189        }
190    
191        /**
192         * Make sure the {@link OutputStream} gets closed.
193         */
194        @Override
195        protected void finalize ()
196            throws Throwable
197        {
198            try {
199                _output.close();
200    
201            } finally {
202                super.finalize();
203            }
204        }
205    
206        /** The path to the archive being operated upon. */
207        private final File _path;
208    
209        /** The output stream used to append data and files to this archive. */
210        private final OutputStream _output;
211    }