package org.limewire.bittorrent; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import org.limewire.logging.Log; import org.limewire.logging.LogFactory; import org.limewire.security.SHA1; import org.limewire.service.ErrorService; import org.limewire.util.BEncoder; import org.limewire.util.CommonUtils; import org.limewire.util.GenericsUtils; import org.limewire.util.StringUtils; import org.limewire.util.URIUtils; import org.limewire.util.GenericsUtils.ScanMode; import com.google.common.collect.ImmutableList; /** * Contains type safe representations of all understand information in in a * .torrent file. * <p> * This will throw a <code>ValueException</code> if the data is malformed or not * what we expect it to be. UTF-8 versions of Strings are preferred over ASCII * versions, wherever possible. */ public class BTDataImpl implements BTData { public static final String UTF_8_ENCODING = "UTF-8"; private static final Log LOG = LogFactory.getLog(BTDataImpl.class); private final List<URI> trackerUris; /** The webseed addresses */ private final URI[] webSeeds; /** * All the pieces as one big array. Non-final 'cause it's big & we want to * clear it. */ private/* final */byte[] pieces; /** The length of a single piece. */ private final Long pieceLength; /** The SHA1 of the info object. */ private byte[] infoHash; /** * The name of the torrent file (if one file) or parent folder (if multiple * files). */ private final String name; /** The length of the torrent if one file. null if multiple. */ private final Long length; /** * A list of subfiles of this torrent is multiple files. null if a single * file. */ private final List<BTData.BTFileData> files; /** A list of all subfolders this torrent uses. null if a single file. */ private final Set<String> folders; /** Whether the private flag is set */ private final boolean isPrivate; /** Constructs a new BTData out of the map of properties. */ // See http://wiki.theory.org/BitTorrentSpecification#Info_Dictionary // for more information public BTDataImpl(Map<?, ?> torrentFileMap) throws BTDataValueException { Object tmp; trackerUris = parseTrackerUris(torrentFileMap); webSeeds = parseWebSeeds(torrentFileMap); tmp = torrentFileMap.get("info"); if (tmp == null || !(tmp instanceof Map)) throw new BTDataValueException("info missing or invalid!"); Map infoMap = (Map) tmp; infoHash = calculateInfoHash(infoMap); tmp = infoMap.get("private"); if (tmp instanceof Long) { isPrivate = ((Long) tmp).intValue() == 1; } else isPrivate = false; tmp = infoMap.get("pieces"); if (tmp instanceof byte[]) pieces = (byte[]) tmp; else throw new BTDataValueException("info->piece missing!"); tmp = infoMap.get("piece length"); if (tmp instanceof Long) pieceLength = (Long) tmp; else throw new BTDataValueException("info->'piece length' missing!"); // get name, prefer utf8 tmp = infoMap.get("name.utf-8"); name = getPreferredString(infoMap, "name"); if (name == null || name.length() == 0) throw new BTDataValueException("no valid name!"); if (infoMap.containsKey("length") == infoMap.containsKey("files")) throw new BTDataValueException("info->length & info.files can't both exist or not exist!"); tmp = infoMap.get("length"); if (tmp instanceof Long) { length = (Long) tmp; if (length < 0) throw new BTDataValueException("invalid length value"); } else if (tmp != null) throw new BTDataValueException("info->length is non-null, but not a Long!"); else length = null; tmp = infoMap.get("files"); if (tmp instanceof List) { List<?> fileData = (List) tmp; if (fileData.isEmpty()) throw new BTDataValueException("empty file list"); files = new ArrayList<BTData.BTFileData>(fileData.size()); folders = new HashSet<String>(); for (Object o : fileData) { if (!(o instanceof Map)) throw new BTDataValueException("info->files[x] not a Map!"); Map<?, ?> fileMap = (Map) o; tmp = fileMap.get("length"); if (!(tmp instanceof Long)) throw new BTDataValueException("info->files[x].length not a Long!"); Long ln = (Long) tmp; if (ln < 0) throw new BTDataValueException("invalid length"); boolean doASCII = true; // Don't try ASCII if UTF-8 succeeds. try { parseFiles(fileMap, ln, files, folders, true); doASCII = false; } catch (BTDataValueException ignored) { } if (doASCII) parseFiles(fileMap, ln, files, folders, false); } } else if (tmp != null) { throw new BTDataValueException("info->files is non-null, but not a list!"); } else { files = null; folders = null; } } static List<URI> parseTrackerUris(Map<?, ?> torrentFileMap) { List<URI> trackerUris = new ArrayList<URI>(2); Object value = torrentFileMap.get("announce-list"); if (value instanceof List) { List<List> announceLists = GenericsUtils.scanForList(value, List.class, ScanMode.REMOVE); for (List uriList : announceLists) { List<byte[]> list = GenericsUtils.scanForList(uriList, byte[].class, ScanMode.REMOVE); for (byte[] data : list) { String uriString = StringUtils.getASCIIString(data); try { trackerUris.add(URIUtils.toURI(uriString)); } catch (URISyntaxException e) { LOG.debugf(e, "invalid uri: {0}", uriString); } } } } // only use announce field if announce list didn't yield valid uris if (trackerUris.isEmpty()) { value = torrentFileMap.get("announce"); if (value instanceof byte[]) { String uriString = StringUtils.getASCIIString((byte[]) value); try { trackerUris.add(URIUtils.toURI(uriString)); } catch (URISyntaxException e) { LOG.debugf(e, "invalid uri: {0}", uriString); } } } return trackerUris.isEmpty() ? Collections.<URI>emptyList() : ImmutableList.copyOf(trackerUris); } /** * Parses the webseed addresses from the torrent file. The web seed * addresses should be in a parameter "url-list". url-list can either be a * list or a single webseed address. */ @SuppressWarnings("unchecked") private URI[] parseWebSeeds(Map<?, ?> torrentFileMap) { List<URI> webSeeds = new ArrayList<URI>(); Object tmp = torrentFileMap.get("url-list"); if (tmp != null) { if (tmp instanceof List) { List<byte[]> uris = (List<byte[]>) tmp; if (uris.size() > 0) { for (byte[] uri : uris) { addURI(webSeeds, StringUtils.getASCIIString(uri)); } } } else if (tmp instanceof byte[]) { String uri = StringUtils.getASCIIString((byte[]) tmp); addURI(webSeeds, uri); } } return webSeeds.toArray(new URI[webSeeds.size()]); } private void addURI(List<URI> uris, String uriString) { try { URI uri = URIUtils.toURI(uriString); uris.add(uri); } catch (URISyntaxException e) { LOG.warn("Error parsing uri: " + uriString, e); } } /** Parses the List of Maps of file data. */ private void parseFiles(Map<?, ?> fileMap, Long ln, List<BTData.BTFileData> fileData, Set<String> folderData, boolean utf8) throws BTDataValueException { Object tmp = fileMap.get("path" + (utf8 ? ".utf-8" : "")); if (!(tmp instanceof List)) throw new BTDataValueException("info->files[x].path[.utf-8] not a List!"); Set<String> newFolders = new HashSet<String>(); String path = parseFileList((List) tmp, newFolders, true); if (path == null) throw new BTDataValueException("info->files[x].path[-utf-8] not valid!"); folderData.addAll(newFolders); fileData.add(new BTData.BTFileData(ln, path)); } /** * Parses a list of paths into a single string, adding the intermediate * folders into the Set of folders. The paths are parsed either as UTF or * ASCII. */ private String parseFileList(List<?> paths, Set<String> folders, boolean utf8) throws BTDataValueException { if (paths.isEmpty()) throw new BTDataValueException("empty paths list"); StringBuilder sb = new StringBuilder(); for (Iterator<?> i = paths.iterator(); i.hasNext();) { Object o = i.next(); if (!(o instanceof byte[])) throw new BTDataValueException("info->files[x]->path[.utf-8][x] not a byte[]!"); String current; if (utf8) current = StringUtils.getUTF8String((byte[]) o); else current = StringUtils.getASCIIString((byte[]) o); if (current.length() == 0) throw new BTDataValueException("empty path element"); // using unix path style so path can be appended to urls sb.append("/"); sb.append(CommonUtils.convertFileName(current)); // if another path, this is a subfolder, so add it to folders if (i.hasNext()) folders.add(sb.toString()); } return sb.toString(); } /** * Returns either the UTF-8 version (if it exists) or the ASCII version of a * String. */ private String getPreferredString(Map<?, ?> info, String key) { String str = null; Object data = info.get(key + ".utf-8"); if (data instanceof byte[]) { try { str = new String((byte[]) data, UTF_8_ENCODING); } catch (Throwable t) { } // could throw any error if input bytes are invalid } if (str == null) { data = info.get(key); if (data instanceof byte[]) str = StringUtils.getASCIIString((byte[]) data); } return str; } /** * Calculates the infoHash of the map. Because BT maps are stored as String * -> Object, and the keys are stored alphabetically, it is guaranteed that * any two maps with identical keys & values will have the same info hash * when decoded & recoded. * * @return the infoHash of the infoMap */ private byte[] calculateInfoHash(Map<?, ?> infoMap) { // create the info hash, we could create the info hash while reading it // but that would make the code a lot more complex. This works well too, // because the order of a list is not changed during the process of // decoding or encoding it and Maps are always sorted alphanumerically // when encoded. // So the data we encoded is always exactly the same as the data before // we decoded it. This is intended that way by the protocol. ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { BEncoder.getEncoder(baos, true, false, "UTF-8").encodeDict(infoMap); } catch (IOException ioe) { ErrorService.error(ioe); } MessageDigest md = new SHA1(); return md.digest(baos.toByteArray()); } @Override public List<URI> getTrackerUris() { return trackerUris; } @Override public List<BTData.BTFileData> getFiles() { return files; } @Override public Set<String> getFolders() { return folders; } @Override public boolean isPrivate() { return isPrivate; } @Override public byte[] getInfoHash() { return infoHash; } @Override public Long getLength() { return length; } @Override public String getName() { return name; } @Override public Long getPieceLength() { return pieceLength; } @Override public byte[] getPieces() { return pieces; } @Override public void clearPieces() { pieces = null; } @Override public URI[] getWebSeeds() { return webSeeds; } @Override public String toString() { return StringUtils.toStringBlacklist(this, pieces, pieceLength); } }