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;
032    
033    import java.io.File;
034    import java.io.FileInputStream;
035    import java.io.FileNotFoundException;
036    import java.io.FileOutputStream;
037    import java.io.IOException;
038    import java.io.InputStream;
039    import java.security.MessageDigest;
040    import java.security.NoSuchAlgorithmException;
041    import java.util.HashMap;
042    import java.util.Map;
043    import java.util.Map.Entry;
044    import java.util.zip.GZIPOutputStream;
045    
046    import org.apache.commons.codec.binary.Hex;
047    import org.apache.commons.io.FileUtils;
048    import org.apache.commons.io.IOUtils;
049    import org.apache.tools.tar.TarEntry;
050    import org.apache.tools.tar.TarOutputStream;
051    
052    import com.threerings.jpkg.ar.ArchiveEntry;
053    
054    /**
055     * A wrapper around TarOutputStream to handle adding files from a destroot into a tar file.
056     * Every regular file will have its md5 checksum recorded and the total amount of file data in
057     * kilobytes will be stored.
058     */
059    public class PackageTarFile
060        implements ArchiveEntry
061    {
062        /**
063         * Convenience constructor to create {@link PackageTarFile} with an empty {@link PermissionsMap}.
064         * @see PackageTarFile#PackageTarFile(File, PermissionsMap)
065         */
066        public PackageTarFile (File safeTemp)
067            throws IOException
068        {
069            this(safeTemp, new PermissionsMap());
070        }
071    
072        /**
073         * Initialize a PackageTar file.
074         * @param safeTemp A location with enough free space to hold the tar data.
075         * @param permissions A {@link PermissionsMap} will be used to manipulate entries before being
076         * added to the tar file.
077         * @throws IOException If the tar file cannot be initialized due to i/o errors.
078         */
079        public PackageTarFile (File safeTemp, PermissionsMap permissions)
080            throws IOException
081        {
082            _permissions = permissions;
083            _tar = File.createTempFile("jpkgtmp", ".tar.gz", safeTemp);
084    
085            _tarOut = new TarOutputStream(new GZIPOutputStream(new FileOutputStream(_tar)));
086            _tarOut.setLongFileMode(TarOutputStream.LONGFILE_GNU);
087        }
088    
089        /**
090         * Add the contents of the supplied directory to the tar file. The root of the directory path
091         * will be stripped from all entries being added to the tar file.
092         * e.g. If /root is supplied: /root/directory/file.txt -> directory/file.txt
093         */
094        public void addDirectory (File directory)
095            throws IOException
096        {
097            final DestrootWalker walker = new DestrootWalker(directory, this);
098            walker.walk();
099        }
100    
101        /**
102         * Add directories and files to the tar archive without stripping a leading path.
103         * @see PackageTarFile#addFile(File, String)
104         */
105        public void addFile (File file)
106            throws DuplicatePermissionsException, IOException
107        {
108            addFile(file, NO_STRIP_PATH);
109        }
110    
111        /**
112         * Add directories and files to the tar archive. All file paths are treated as absolute paths.
113         * @param file The {@link File} to add to the tar archive.
114         * @param stripPath The path to stripped from the start of any entry path before adding it to
115         * the tar file.
116         * @throws InvalidPathException If the supplied stripPath path cannot be normalized.
117         * @throws DuplicatePermissionsException If more than one permission in the defined
118         * {@link PermissionsMap} maps to the file being added.
119         * @throws IOException If any i/o exceptions occur when appending the file data.
120         */
121        public void addFile (File file, String stripPath)
122            throws DuplicatePermissionsException, IOException
123        {
124            // normalize the strip path and remove any leading /'s
125            final String normalizedStripPath = PathUtils.stripLeadingSeparators(PathUtils.normalize(stripPath));
126    
127            // use the file to initialize the TarEntry, and then override various properties.
128            final TarEntry entry = new TarEntry(file.getAbsoluteFile());
129    
130            // normalize the entry path
131            entry.setName(PathUtils.normalize(entry.getName()));
132    
133            // if the entry path includes the strip path, remove it.
134            final String entryPath = entry.getName();
135            if (entryPath.startsWith(normalizedStripPath)) {
136                final String stripped = entryPath.substring(normalizedStripPath.length());
137                // be extra sure that the modified path has no leading separators so that the entry
138                // does not expand into the root.
139                entry.setName(PathUtils.stripLeadingSeparators(stripped));
140            }
141    
142            // set standard permission modes
143            if (file.isDirectory()) {
144                // set the entry size to 0 if this is a directory
145                entry.setSize(0);
146                entry.setMode(UnixStandardPermissions.STANDARD_DIR_MODE);
147    
148            } else if (file.isFile()) {
149                entry.setMode(UnixStandardPermissions.STANDARD_FILE_MODE);
150            }
151    
152            // configure the permissions in the entry.
153            setEntryPermissions(entry, file);
154    
155            // verify that the directory entries have trailing /'s which may have been removed
156            // during normalization
157            if (file.isDirectory()) {
158                final StringBuffer currentPath = new StringBuffer(entry.getName());
159                if (currentPath.charAt(currentPath.length() - 1) != File.separatorChar) {
160                    currentPath.append(File.separatorChar);
161                }
162                entry.setName(currentPath.toString());
163            }
164    
165            // write out the tar entry header.
166            _tarOut.putNextEntry(entry);
167    
168            // insert the file data into the tar and calculate the md5 checksum for any regular file.
169            if (file.isFile()) {
170                handleRegularFile(file, entry);
171            }
172    
173            _tarOut.closeEntry();
174        }
175    
176        /**
177         * Closes the tar file. This must be called to create a valid tar file.
178         */
179        public void close ()
180            throws IOException
181        {
182            _tarOut.close();
183        }
184    
185        /**
186         * Deletes the tar file. Returns true if the file was deleted, false otherwise.
187         */
188        public boolean delete ()
189        {
190            return _tar.delete();
191        }
192    
193        /**
194         * Return the map of tar entry paths to md5 checksums for regular files added to this tar file.
195         */
196        public Map<String, String> getMd5s ()
197        {
198            return _md5s;
199        }
200    
201        /**
202         * Return the total amount of file data added to this tar file, in kilobytes.
203         * If the supplied data is less than a kilobyte, 1 is returned.
204         */
205        public long getTotalDataSize ()
206        {
207            return _totalSize;
208        }
209    
210        // from ArchiveEntry
211        public InputStream getInputStream ()
212            throws IOException
213        {
214            return new FileInputStream(_tar);
215        }
216    
217        // from ArchiveEntry
218        public long getSize ()
219        {
220            return _tar.length();
221        }
222    
223        // from ArchiveEntry
224        public String getPath ()
225        {
226            return DEB_AR_DATA_FILE;
227        }
228    
229        // from ArchiveEntry
230        public int getUserId ()
231        {
232            return UnixStandardPermissions.ROOT_USER.getId();
233        }
234    
235        // from ArchiveEntry
236        public int getGroupId ()
237        {
238            return UnixStandardPermissions.ROOT_GROUP.getId();
239        }
240    
241        // from ArchiveEntry
242        public int getMode ()
243        {
244            return UnixStandardPermissions.STANDARD_FILE_MODE;
245        }
246    
247        /**
248         * Set the permissions in the TarEntry, applying any matches from the PermissionsMap.
249         * @throws DuplicatePermissionsException
250         */
251        private void setEntryPermissions (TarEntry entry, File file)
252            throws DuplicatePermissionsException
253        {
254            // default permissions to root
255            entry.setNames(UnixStandardPermissions.ROOT_USER.getName(),
256                UnixStandardPermissions.ROOT_GROUP.getName());
257            entry.setIds(UnixStandardPermissions.ROOT_USER.getId(),
258                UnixStandardPermissions.ROOT_GROUP.getId());
259    
260            // apply any permissions map to this entry to modify permissions. the first permission
261            // encountered will be applied. if any additional permissions are encountered that also
262            // match, a DuplicatePermissionsException will be thrown.
263            // NOTE: this should eventually be built into a tree that maps the filesystem and allows
264            // for nested recursive permissions to override earlier recursive permissions in a
265            // reasonable manner.
266            // TODO: Use Google collections to filter the permissions list down to the one applicable
267            // permission, or throw the exception if more than one is found.
268            boolean permissionFound = false;
269            for (final Entry<String, PathPermissions> permEntry : _permissions.getPermissions()) {
270                // since the entries in the tar file have any leading / stripped, the permission paths
271                // must also have the / stripped.
272                final String permPath = PathUtils.stripLeadingSeparators(permEntry.getKey());
273    
274                final String entryPath = entry.getName();
275                final PathPermissions permissions = permEntry.getValue();
276    
277                // if the permission path does not match, continue to the next permission
278                if (!permissionMatches(permPath, entryPath, permissions.isRecursive())) {
279                    continue;
280                }
281    
282                // if we have already applied a permission to this path, throw an exception.
283                if (permissionFound) {
284                    throw new DuplicatePermissionsException(
285                        "A permission already mapped to this file. Refusing to apply another. path=[" + file.getAbsolutePath() + "].");
286                }
287    
288                // apply the permission to the entry.
289                entry.setNames(permissions.getUser(), permissions.getGroup());
290                entry.setIds(permissions.getUid(), permissions.getGid());
291                entry.setMode(permissions.getMode());
292                permissionFound = true;
293            }
294        }
295    
296        /**
297         * Handles adding a regular file {@link File} object to the tar file. This includes
298         * calculating and recording the md5 checksum of the file data.
299         */
300        private void handleRegularFile (File file, TarEntry entry)
301            throws FileNotFoundException, IOException
302        {
303            try {
304                final MessageDigest md = MessageDigest.getInstance("MD5");
305                InputStream input = null;
306                try {
307                    input = new FileInputStream(file);
308                    final byte[] buf = new byte[1024];
309                    int len;
310                    while ((len = input.read(buf)) > 0) {
311                        _tarOut.write(buf, 0, len);
312                        md.update(buf, 0, len);
313                    }
314    
315                } finally {
316                    IOUtils.closeQuietly(input);
317                }
318    
319                _md5s.put(entry.getName(), new String(Hex.encodeHex(md.digest())));
320    
321            } catch (final NoSuchAlgorithmException nsa) {
322                throw new RuntimeException("md5 algorthm not found.", nsa);
323            }
324    
325            // record the kilobyte size of this file in the total file data count
326            _totalSize += bytesToKilobytes(file.length());
327        }
328    
329        /**
330         * Convert bytes into kilobytes. If the supplied bytes are less than a kilobyte, 1 is returned.
331         */
332        private long bytesToKilobytes (long bytes)
333        {
334            if (bytes <= FileUtils.ONE_KB) {
335                return 1;
336            }
337            return bytes / FileUtils.ONE_KB;
338        }
339    
340        /**
341         * Determine if the supplied permission path matches the supplied entry path.
342         */
343        private boolean permissionMatches (String permPath, String entryPath, boolean recursive)
344        {
345            // don't match if this permission path is not part of the entry.
346            if (!entryPath.startsWith(permPath)) {
347                return false;
348            }
349    
350            // match if the permission matches the path exactly.
351            if ((permPath.equals(entryPath))) {
352                return true;
353            }
354    
355            // or match if the permission is recursive, and the entry is inside of the recursive path.
356            if (recursive && entryPath.charAt(permPath.length()) == File.separatorChar) {
357                return true;
358            }
359    
360            // otherwise the permission did not match.
361            return false;
362        }
363    
364        /** The name of the data file in the Debian package. */
365        private static final String DEB_AR_DATA_FILE = "data.tar.gz";
366    
367        /** Used to indicate that the file being added should have nothing stripped from its path. */
368        private static final String NO_STRIP_PATH = "";
369    
370        /** The PermissionsMap to be applied to this tar file. */
371        private final PermissionsMap _permissions;
372    
373        /** An md5 map for every regular file entry in the tar file. */
374        private final Map<String, String> _md5s = new HashMap<String, String>();
375    
376        /** The amount of file data added to the tar file, stored in kilobytes. */
377        private long _totalSize;
378    
379        /** The file location of the tar file. */
380        private final File _tar;
381    
382        /** Used to write the tar file to the file system. */
383        private final TarOutputStream _tarOut;
384    }