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 }