/** * @param schema a MIME compliant non-localizable identifier, that matches file categories (and * XSD schema names). * @param descriptionKey a media identifier that can be used to retreive a localizable descriptive * text. * @param extensions a list of all file extensions of this type. Must be all lowercase. If null, * this matches any file. */ public MediaType(String schema, String descriptionKey, String[] extensions) { if (schema == null) { throw new NullPointerException("schema must not be null"); } this.schema = schema; this.descriptionKey = descriptionKey; if (extensions == null) { this.exts = Collections.emptySet(); this.extsArray = new String[0]; } else { Set<String> set = new TreeSet<String>(Comparators.caseInsensitiveStringComparator()); set.addAll(Arrays.asList(extensions)); this.exts = set; this.extsArray = exts.toArray(new String[0]); } }
/** * A repository of temporary filenames. Gives out file names for temporary files, ensuring that two * duplicate files always get the same name. This enables smart resumes across hosts. Also keeps * track of the blocks downloaded, for smart downloading purposes. <b>Thread safe.</b> * * <p> */ @Singleton public class IncompleteFileManager { /** * The delimiter to use between the size and a real name of a temporary file. To make it easier to * break the temporary name into its constituent parts, this should not contain a number. */ static final String SEPARATOR = "-"; /** The prefix added to preview copies of incomplete files. */ public static final String PREVIEW_PREFIX = "Preview-"; /** * A mapping from incomplete files (File) to the blocks of the file stored on disk * (VerifyingFile). Needed for resumptive smart downloads. INVARIANT: all blocks disjoint, no two * intervals can be coalesced into one interval. Note that blocks are not sorted; there are * typically few blocks so performance isn't an issue. */ private final Map<File, VerifyingFile> blocks = new TreeMap<File, VerifyingFile>(Comparators.fileComparator()); /** * Bijection between sha1 hashes (URN) and incomplete files (File). This is used to ensure that * any two RemoteFileDesc with the same hash get the same incomplete file, regardless of name. The * inverse of this map is used to get the hash of an incomplete file for query-by-hash and * resuming. Note that the hash is that of the desired completed file, not that of the incomplete * file. * * <p>Entries are added to hashes before the temp file is actually created on disk. For this * reason, there can be files in the value set of hashes that are not in the key set of blocks. * These entries are not serialized to disk in the downloads.dat file. Similarly there may be * files in the key set of blocks that are not in the value set of hashes. This happens if we * received RemoteFileDesc's without hashes, or when loading old downloads.dat files without hash * info. * * <p>INVARIANT: the range (value set) of hashes contains no duplicates. INVARIANT: for all keys k * in hashes, k.isSHA1() */ private final Map<URN, File> hashes = new HashMap<URN, File>(); private final Provider<FileManager> fileManager; private final Provider<HashTreeCache> tigerTreeCache; private final VerifyingFileFactory verifyingFileFactory; @Inject public IncompleteFileManager( Provider<FileManager> fileManager, Provider<HashTreeCache> tigerTreeCache, VerifyingFileFactory verifyingFileFactory) { this.fileManager = fileManager; this.tigerTreeCache = tigerTreeCache; this.verifyingFileFactory = verifyingFileFactory; } /** * Removes entries in this for which there is no file on disk. * * @return true iff any entries were purged */ public synchronized boolean purge() { boolean ret = false; // Remove any blocks for which the file doesn't exist. for (Iterator<File> iter = blocks.keySet().iterator(); iter.hasNext(); ) { File file = iter.next(); if (!file.exists()) { ret = true; fileManager.get().getManagedFileList().remove(file); file.delete(); // always safe to call; return value ignored iter.remove(); } } return ret; } /** * Deletes incomplete files more than INCOMPLETE_PURGE_TIME days old from disk Then removes * entries in this for which there is no file on disk. * * @param activeFiles which files are currently being downloaded. * @return true iff any entries were purged */ public synchronized boolean initialPurge(Collection<File> activeFiles) { // Remove any files that are old. boolean ret = false; for (Iterator<File> iter = blocks.keySet().iterator(); iter.hasNext(); ) { File file = iter.next(); try { file = FileUtils.getCanonicalFile(file); } catch (IOException iox) { file = file.getAbsoluteFile(); } if (!file.exists() || (isOld(file) && !activeFiles.contains(file))) { ret = true; fileManager.get().getManagedFileList().remove(file); file.delete(); iter.remove(); } } for (Iterator<File> iter = hashes.values().iterator(); iter.hasNext(); ) { File file = iter.next(); if (!file.exists()) { iter.remove(); ret = true; } } return ret; } /** Returns true iff file is "too old". */ private static final boolean isOld(File file) { // Inlining this method allows some optimizations--not that they matter. long days = SharingSettings.INCOMPLETE_PURGE_TIME.getValue(); // Back up a couple days. // 24 hour/day * 60 min/hour * 60 sec/min * 1000 msec/sec long purgeTime = System.currentTimeMillis() - days * 24l * 60l * 60l * 1000l; return file.lastModified() < purgeTime; } /* * Returns true if both rfd "have the same content". Currently * rfd1~=rfd2 iff either of the following conditions hold: * * <ul> * <li>Both files have the same hash, i.e., * rfd1.getSHA1Urn().equals(rfd2.getSHA1Urn(). Note that this (almost) * always means that rfd1.getSize()==rfd2.getSize(), though rfd1 and * rfd2 may have different names. * <li>Both files have the same name and size and don't have conflicting * hashes, i.e., rfd1.getName().equals(rfd2.getName()) && * rfd1.getSize()==rfd2.getSize() && (rfd1.getSHA1Urn()==null || * rfd2.getSHA1Urn()==null || * rfd1.getSHA1Urn().equals(rfd2.getSHA1Urn())). * </ul> * Note that the second condition allows risky resumes, i.e., resumes when * one (or both) of the files doesn't have a hash. * * @see getFile */ static boolean same(RemoteFileDesc rfd1, RemoteFileDesc rfd2) { return same( rfd1.getFileName(), rfd1.getSize(), rfd1.getSHA1Urn(), rfd2.getFileName(), rfd2.getSize(), rfd2.getSHA1Urn()); } /** @see similar(RemoteFileDesc, RemoteFileDesc) */ static boolean same(String name1, long size1, URN hash1, String name2, long size2, URN hash2) { // Either they have the same hashes... if (hash1 != null && hash2 != null) return hash1.equals(hash2); // ..or same name and size and no conflicting hashes. else return size1 == size2 && name1.equals(name2); } /** * Canonicalization is not as important on windows, and is causing problems. Therefore, don't do * it. */ private static File canonicalize(File f) throws IOException { f = f.getAbsoluteFile(); if (OSUtils.isWindows()) return f; else return f.getCanonicalFile(); } /** * Same as getFile(String, urn, int), except taking the values from the RFD. getFile(rfd) == * getFile(rfd.getFileName(), rfd.getSHA1Urn(), rfd.getSize()); */ public synchronized File getFile(RemoteFileDesc rfd) throws IOException { return getFile(rfd.getFileName(), rfd.getSHA1Urn(), rfd.getSize()); } /** * Stub for calling getFile(String, URN, int, SharingSettings.INCOMPLETE_DIRECTORY.getValue()); */ public synchronized File getFile(String name, URN sha1, long size) throws IOException { return getFile(name, sha1, size, SharingSettings.INCOMPLETE_DIRECTORY.get()); } /** * Returns the fully-qualified temporary download file for the given file/location pair. If an * incomplete file already exists for this URN, that file is returned. Otherwise, the location of * the file is determined by the "incDir" variable. For example, getFile("test.txt", 1999) may * return "C:\Program Files\LimeWire\Incomplete\T-1999-Test.txt" if "incDir" is "C:\Program * Files\LimeWire\Incomplete". The disk is not modified, except for the file possibly being * created. * * <p>This method gives duplicate files the same temporary file, which is critical for resume and * swarmed downloads. That is, for all rfd_i and rfd_j * * <pre> * similar(rfd_i, rfd_j) <==> getFile(rfd_i).equals(getFile(rfd_j))<p> * </pre> * * It is imperative that the files are compared as in their canonical formats to preserve the * integrity of the filesystem. Otherwise, multiple downloads could be downloading to "FILE A", * and "file a", although only "file a" exists on disk and is being written to by both. * * @throws IOException if there was an IOError while determining the file's name. */ public synchronized File getFile(String name, URN sha1, long size, File incDir) throws IOException { boolean dirsMade = false; File baseFile = null; File canonFile = null; // make sure its created.. (the user might have deleted it) dirsMade = incDir.mkdirs(); String convertedName = CommonUtils.convertFileName(name); try { if (sha1 != null) { File file = hashes.get(sha1); if (file != null) { // File already allocated for hash return file; } else { // Allocate unique file for hash. By "unique" we mean not in // the value set of HASHES. Because we allow risky resumes, // there's no need to look at BLOCKS as well... for (int i = 1; ; i++) { file = new File(incDir, tempName(convertedName, size, i)); baseFile = file; file = canonicalize(file); canonFile = file; if (!hashes.values().contains(file)) break; } // ...and record the hash for later. hashes.put(sha1, file); // ...and make sure the file exists on disk, so that // future File.getCanonicalFile calls will match this // file. This was a problem on OSX, where // File("myfile") and File("MYFILE") aren't equal, // but File("myfile").getCanonicalFile() will only return // a File("MYFILE") if that already existed on disk. // This means that in order for the canonical-checking // within this class to work, the file must exist on disk. FileUtils.touch(file); return file; } } else { // No hash. File f = new File(incDir, tempName(convertedName, size, 0)); baseFile = f; f = canonicalize(f); canonFile = f; return f; } } catch (IOException ioe) { IOException ioe2 = new IOException( "dirsMade: " + dirsMade + "\ndirExist: " + incDir.exists() + "\nbaseFile: " + baseFile + "\ncannFile: " + canonFile); ioe2.initCause(ioe); throw ioe2; } } /** * Returns the file associated with the specified URN. If no file matches, returns null. * * @return the file associated with the URN, or null if none. */ public synchronized File getFileForUrn(URN urn) { if (urn == null) throw new NullPointerException("null urn"); return hashes.get(urn); } /** * Returns the unqualified file name for a file with the given name and size, with an optional * suffix to make it unique. * * @param count a suffix to attach before the file extension in parens before the file extension, * or 1 for none. */ private static String tempName(String filename, long size, int suffix) { if (suffix <= 1) { // a) No suffix return "T-" + size + "-" + filename; } int i = filename.lastIndexOf('.'); if (i < 0) { // b) Suffix, no extension return "T-" + size + "-" + filename + " (" + suffix + ")"; } else { // c) Suffix, file extension String noExtension = filename.substring(0, i); String extension = filename.substring(i); // e.g., ".txt" return "T-" + size + "-" + noExtension + " (" + suffix + ")" + extension; } } /** * Removes the block and hash information for the given incomplete file. Typically this is called * after incompleteFile has been deleted. * * @param incompleteFile a temporary file returned by getFile */ public synchronized void removeEntry(File incompleteFile) { // Remove downloaded blocks. blocks.remove(incompleteFile); // Remove any key k from hashes for which hashes[k]=incompleteFile. // There should be at most one value of k. for (Iterator<Map.Entry<URN, File>> iter = hashes.entrySet().iterator(); iter.hasNext(); ) { Map.Entry<URN, File> entry = iter.next(); if (incompleteFile.equals(entry.getValue())) iter.remove(); } // Remove the entry from FileManager fileManager.get().getManagedFileList().remove(incompleteFile); } /** Initializes entries with URNs, Files & Ranges. */ public synchronized void initEntry( File incompleteFile, List<Range> ranges, URN sha1, boolean publish) throws InvalidDataException { try { incompleteFile = canonicalize(incompleteFile); } catch (IOException iox) { throw new InvalidDataException(iox); } VerifyingFile verifyingFile; try { verifyingFile = verifyingFileFactory.createVerifyingFile(getCompletedSize(incompleteFile)); } catch (IllegalArgumentException iae) { throw new InvalidDataException(iae); } if (ranges != null) { for (Range range : ranges) { verifyingFile.addInterval(range); } } if (ranges == null || ranges.isEmpty()) { try { verifyingFile.setScanForExistingBlocks(true, incompleteFile.length()); } catch (IOException iox) { throw new InvalidDataException(iox); } } blocks.put(incompleteFile, verifyingFile); if (sha1 != null) hashes.put(sha1, incompleteFile); if (publish) registerIncompleteFile(incompleteFile); } /** * Associates the incompleteFile with the VerifyingFile vf. Notifies FileManager about a new * Incomplete File. */ public synchronized void addEntry(File incompleteFile, VerifyingFile vf, boolean publish) { // We must canonicalize the file. try { incompleteFile = canonicalize(incompleteFile); } catch (IOException ignored) { } blocks.put(incompleteFile, vf); if (publish) registerIncompleteFile(incompleteFile); } public synchronized void addTorrentEntry(URN urn) { String torrentDirPath = SharingSettings.INCOMPLETE_DIRECTORY.get().getAbsolutePath() + File.separator + Base32.encode(urn.getBytes()); File torrentDir = new File(torrentDirPath); hashes.put(urn, torrentDir); } public synchronized void removeTorrentEntry(URN urn) { hashes.remove(urn); } public synchronized VerifyingFile getEntry(File incompleteFile) { return blocks.get(incompleteFile); } public synchronized long getBlockSize(File incompleteFile) { VerifyingFile vf = blocks.get(incompleteFile); if (vf == null) return 0; else return vf.getBlockSize(); } /** Notifies file manager about all incomplete files. */ public synchronized void registerAllIncompleteFiles() { for (File file : blocks.keySet()) { if (file.exists() && !isOld(file)) registerIncompleteFile(file); } } /** Notifies file manager about a single incomplete file. */ private synchronized void registerIncompleteFile(File incompleteFile) { // Only register if it has a SHA1 -- otherwise we can't share. Set<URN> completeHashes = getAllCompletedHashes(incompleteFile); if (completeHashes.size() == 0) return; fileManager .get() .getIncompleteFileList() .addIncompleteFile( incompleteFile, completeHashes, getCompletedName(incompleteFile), getCompletedSize(incompleteFile), getEntry(incompleteFile)); } /** * Returns the name of the complete file associated with the given incomplete file, i.e., what * incompleteFile will be renamed to when the download completes (without path information). Slow; * runs in linear time with respect to the number of hashes in this. * * @param incompleteFile a file returned by getFile * @return the complete file name, without path * @exception IllegalArgumentException incompleteFile was not the return value from getFile */ public static String getCompletedName(File incompleteFile) throws IllegalArgumentException { String torrent = getCompletedTorrentName(incompleteFile); if (torrent != null) return torrent; // Given T-<size>-<name> return <name>. // i j // This is not as strict as it could be. TODO: what about (x) suffix? String name = incompleteFile.getName(); int i = name.indexOf(SEPARATOR); if (i < 0) throw new IllegalArgumentException("Missing separator: " + name); int j = name.indexOf(SEPARATOR, i + 1); if (j < 0) throw new IllegalArgumentException("Missing separator: " + name); if (j == name.length() - 1) throw new IllegalArgumentException("No name after last separator"); return name.substring(j + 1); } private static String getCompletedTorrentName(File incompleteDir) { if (!isTorrentFolder(incompleteDir)) return null; File[] list = incompleteDir.listFiles(); if (list[0].getName().startsWith(".dat")) return list[1].getName(); else return list[0].getName(); } public static boolean isTorrentFolder(File file) { if (!file.isDirectory() || file.getName().length() != 32) return false; File[] files = file.listFiles(); if (files.length != 2) return false; File datFile = files[0]; File otherFile = files[1]; if (!datFile.getName().startsWith(".dat")) { datFile = files[1]; otherFile = files[0]; } if (!datFile.getName().startsWith(".dat")) return false; return datFile.getName().equals(".dat" + otherFile.getName()); } /** * Returns the size of the complete file associated with the given incomplete file, i.e., the * number of bytes in the file when the download completes. * * @param incompleteFile a file returned by getFile * @return the complete file size * @exception IllegalArgumentException incompleteFile was not returned by getFile */ public static long getCompletedSize(File incompleteFile) throws IllegalArgumentException { // Given T-<size>-<name>, return <size>. // i j String name = incompleteFile.getName(); int i = name.indexOf(SEPARATOR); if (i < 0) throw new IllegalArgumentException("Missing separator: " + name); int j = name.indexOf(SEPARATOR, i + 1); if (j < 0) throw new IllegalArgumentException("Missing separator: " + name); try { return Long.parseLong(name.substring(i + 1, j)); } catch (NumberFormatException e) { throw new IllegalArgumentException("Bad number format: " + name); } } /** * Returns the hash of the complete file associated with the given incomplete file, i.e., the hash * of incompleteFile when the download is complete. * * @param incompleteFile a file returned by getFile * @return a SHA1 hash, or null if unknown */ public synchronized URN getCompletedHash(File incompleteFile) { // Return a key k s.t., hashes.get(k)==incompleteFile... for (Map.Entry<URN, File> entry : hashes.entrySet()) { if (incompleteFile.equals(entry.getValue()) && entry.getKey().isSHA1()) return entry.getKey(); } return null; // ...or null if no such k. } /** * Returns any known hashes of the complete file associated with the given incomplete file, i.e., * the hashes of incompleteFile when the download is complete. * * @param incompleteFile a file returned by getFile * @return a set of known hashes */ public synchronized Set<URN> getAllCompletedHashes(File incompleteFile) { Set<URN> urns = new UrnSet(); // Return a set S s.t. for each K in S, hashes.get(k)==incpleteFile for (Map.Entry<URN, File> entry : hashes.entrySet()) { if (incompleteFile.equals(entry.getValue())) { urns.add(entry.getKey()); URN ttroot = tigerTreeCache.get().getHashTreeRootForSha1(entry.getKey()); if (ttroot != null) urns.add(ttroot); } } return urns; } @Override public synchronized String toString() { StringBuilder buf = new StringBuilder(); buf.append("{"); boolean first = true; for (File file : blocks.keySet()) { if (!first) buf.append(", "); List<Range> intervals = blocks.get(file).getVerifiedBlocksAsList(); buf.append(file); buf.append(":"); buf.append(intervals.toString()); first = false; } buf.append("}"); return buf.toString(); } public synchronized String dumpHashes() { return hashes.toString(); } public Collection<File> getUnregisteredIncompleteFilesInDirectory(File value) { if (value == null) { return Collections.emptyList(); } File[] files = value.listFiles( new FileFilter() { @Override public boolean accept(File incompleteFile) { if (!incompleteFile.isFile()) { return false; } String name = incompleteFile.getName(); int i = name.indexOf(SEPARATOR); if (i < 0 || i == name.length() - 1) { return false; } int j = name.indexOf(SEPARATOR, i + 1); if (j < 0 || j == name.length() - 1) { return false; } try { Long.parseLong(name.substring(i + 1, j)); } catch (NumberFormatException e) { return false; } synchronized (IncompleteFileManager.this) { return !blocks.containsKey(FileUtils.canonicalize(incompleteFile)); } } }); if (files == null) { return Collections.emptyList(); } else { return Arrays.asList(files); } } }