/* MetaInfo - Holds all information gotten from a torrent file. Copyright (C) 2003 Mark J. Wielaard This file is part of Snark. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. */ package org.klomp.snark; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import net.i2p.I2PAppContext; import net.i2p.crypto.SHA1; import net.i2p.data.DataHelper; import net.i2p.util.Log; import org.klomp.snark.bencode.BDecoder; import org.klomp.snark.bencode.BEValue; import org.klomp.snark.bencode.BEncoder; import org.klomp.snark.bencode.InvalidBEncodingException; /** * Note: this class is buggy, as it doesn't propogate custom meta fields into the bencoded * info data, and from there to the info_hash. At the moment, though, it seems to work with * torrents created by I2P-BT, I2PRufus and Azureus. * */ public class MetaInfo { private final Log _log = I2PAppContext.getGlobalContext().logManager().getLog(MetaInfo.class); private final String announce; private final byte[] info_hash; private final String name; private final String name_utf8; private final List<List<String>> files; private final List<List<String>> files_utf8; private final List<Long> lengths; private final int piece_length; private final byte[] piece_hashes; private final long length; private final boolean privateTorrent; private final List<List<String>> announce_list; private final String comment; private final String created_by; private final long creation_date; private Map<String, BEValue> infoMap; /** * Called by Storage when creating a new torrent from local data * * @param announce may be null * @param files null for single-file torrent * @param lengths null for single-file torrent * @param announce_list may be null * @param created_by may be null */ MetaInfo(String announce, String name, String name_utf8, List<List<String>> files, List<Long> lengths, int piece_length, byte[] piece_hashes, long length, boolean privateTorrent, List<List<String>> announce_list, String created_by) { this.announce = announce; this.name = name; this.name_utf8 = name_utf8; this.files = files == null ? null : Collections.unmodifiableList(files); this.files_utf8 = null; this.lengths = lengths == null ? null : Collections.unmodifiableList(lengths); this.piece_length = piece_length; this.piece_hashes = piece_hashes; this.length = length; this.privateTorrent = privateTorrent; this.announce_list = announce_list; this.comment = null; this.created_by = created_by; this.creation_date = I2PAppContext.getGlobalContext().clock().now(); // TODO if we add a parameter for other keys //if (other != null) { // otherInfo = new HashMap(2); // otherInfo.putAll(other); //} this.info_hash = calculateInfoHash(); //infoMap = null; } /** * Creates a new MetaInfo from the given InputStream. The * InputStream must start with a correctly bencoded dictonary * describing the torrent. * Caller must close the stream. */ public MetaInfo(InputStream in) throws IOException { this(new BDecoder(in)); } /** * Creates a new MetaInfo from the given BDecoder. The BDecoder * must have a complete dictionary describing the torrent. */ private MetaInfo(BDecoder be) throws IOException { // Note that evaluation order matters here... this(be.bdecodeMap().getMap()); byte[] origInfohash = be.get_special_map_digest(); // shouldn't ever happen if (!DataHelper.eq(origInfohash, info_hash)) throw new InvalidBEncodingException("Infohash mismatch, please report"); } /** * Creates a new MetaInfo from a Map of BEValues and the SHA1 over * the original bencoded info dictonary (this is a hack, we could * reconstruct the bencoded stream and recalculate the hash). Will * NOT throw a InvalidBEncodingException if the given map does not * contain a valid announce string. * WILL throw a InvalidBEncodingException if the given map does not * contain a valid info dictionary. */ public MetaInfo(Map<String, BEValue> m) throws InvalidBEncodingException { if (_log.shouldLog(Log.DEBUG)) _log.debug("Creating a metaInfo: " + m, new Exception("source")); BEValue val = m.get("announce"); // Disabled check, we can get info from a magnet now if (val == null) { //throw new InvalidBEncodingException("Missing announce string"); this.announce = null; } else { this.announce = val.getString(); } // BEP 12 val = m.get("announce-list"); if (val == null) { this.announce_list = null; } else { this.announce_list = new ArrayList<List<String>>(); List<BEValue> bl1 = val.getList(); for (BEValue bev : bl1) { List<BEValue> bl2 = bev.getList(); List<String> sl2 = new ArrayList<String>(); for (BEValue bev2 : bl2) { sl2.add(bev2.getString()); } this.announce_list.add(sl2); } } // misc. optional top-level stuff val = m.get("comment"); String st = null; if (val != null) { try { st = val.getString(); } catch (InvalidBEncodingException ibee) {} } this.comment = st; val = m.get("created by"); st = null; if (val != null) { try { st = val.getString(); } catch (InvalidBEncodingException ibee) {} } this.created_by = st; val = m.get("creation date"); long time = 0; if (val != null) { try { time = val.getLong() * 1000; } catch (InvalidBEncodingException ibee) {} } this.creation_date = time; val = m.get("info"); if (val == null) throw new InvalidBEncodingException("Missing info map"); Map<String, BEValue> info = val.getMap(); infoMap = Collections.unmodifiableMap(info); val = info.get("name"); if (val == null) throw new InvalidBEncodingException("Missing name string"); name = val.getString(); // We could silently replace the '/', but that messes up the info hash, so just throw instead. if (name.indexOf('/') >= 0) throw new InvalidBEncodingException("Invalid name containing '/' " + name); val = info.get("name.utf-8"); if (val != null) name_utf8 = val.getString(); else name_utf8 = null; // BEP 27 val = info.get("private"); if (val != null) { Object o = val.getValue(); // Is it supposed to be a number or a string? // i2psnark does it as a string. BEP 27 doesn't say. // Transmission does numbers. So does libtorrent. // We handle both as of 0.9.9. // We switch to storing as number as of 0.9.14. privateTorrent = "1".equals(o) || ((o instanceof Number) && ((Number) o).intValue() == 1); } else { privateTorrent = false; } val = info.get("piece length"); if (val == null) throw new InvalidBEncodingException("Missing piece length number"); piece_length = val.getInt(); val = info.get("pieces"); if (val == null) throw new InvalidBEncodingException("Missing piece bytes"); piece_hashes = val.getBytes(); val = info.get("length"); if (val != null) { // Single file case. length = val.getLong(); files = null; files_utf8 = null; lengths = null; } else { // Multi file case. val = info.get("files"); if (val == null) throw new InvalidBEncodingException ("Missing length number and/or files list"); List<BEValue> list = val.getList(); int size = list.size(); if (size == 0) throw new InvalidBEncodingException("zero size files list"); List<List<String>> m_files = new ArrayList<List<String>>(size); List<List<String>> m_files_utf8 = new ArrayList<List<String>>(size); List<Long> m_lengths = new ArrayList<Long>(size); long l = 0; for (int i = 0; i < list.size(); i++) { Map<String, BEValue> desc = list.get(i).getMap(); val = desc.get("length"); if (val == null) throw new InvalidBEncodingException("Missing length number"); long len = val.getLong(); if (len < 0) throw new InvalidBEncodingException("Negative file length"); m_lengths.add(Long.valueOf(len)); // check for overflowing the long long oldTotal = l; l += len; if (l < oldTotal) throw new InvalidBEncodingException("Huge total length"); val = desc.get("path"); if (val == null) throw new InvalidBEncodingException("Missing path list"); List<BEValue> path_list = val.getList(); int path_length = path_list.size(); if (path_length == 0) throw new InvalidBEncodingException("zero size file path list"); List<String> file = new ArrayList<String>(path_length); Iterator<BEValue> it = path_list.iterator(); while (it.hasNext()) { String s = it.next().getString(); // We could throw an IBEE, but just silently replace instead. if (s.indexOf('/') >= 0) s = s.replace("/", "_"); file.add(s); } // quick dup check - case sensitive, etc. - Storage does a better job for (int j = 0; j < i; j++) { if (file.equals(m_files.get(j))) throw new InvalidBEncodingException("Duplicate file path " + DataHelper.toString(file)); } m_files.add(Collections.unmodifiableList(file)); val = desc.get("path.utf-8"); if (val != null) { path_list = val.getList(); path_length = path_list.size(); if (path_length > 0) { file = new ArrayList<String>(path_length); it = path_list.iterator(); while (it.hasNext()) file.add(it.next().getString()); m_files_utf8.add(Collections.unmodifiableList(file)); } } } files = Collections.unmodifiableList(m_files); files_utf8 = Collections.unmodifiableList(m_files_utf8); lengths = Collections.unmodifiableList(m_lengths); length = l; } info_hash = calculateInfoHash(); } /** * Efficiently returns the name and the 20 byte SHA1 hash of the info dictionary in a torrent file * Caller must close stream. * * @param infoHashOut 20-byte out parameter * @since 0.8.5 */ public static String getNameAndInfoHash(InputStream in, byte[] infoHashOut) throws IOException { BDecoder bd = new BDecoder(in); Map<String, BEValue> m = bd.bdecodeMap().getMap(); BEValue ibev = m.get("info"); if (ibev == null) throw new InvalidBEncodingException("Missing info map"); Map<String, BEValue> i = ibev.getMap(); BEValue rvbev = i.get("name"); if (rvbev == null) throw new InvalidBEncodingException("Missing name"); byte[] h = bd.get_special_map_digest(); System.arraycopy(h, 0, infoHashOut, 0, 20); return rvbev.getString(); } /** * Returns the string representing the URL of the tracker for this torrent. * @return may be null! */ public String getAnnounce() { return announce; } /** * Returns a list of lists of urls. * * @since 0.9.5 */ public List<List<String>> getAnnounceList() { return announce_list; } /** * Returns the original 20 byte SHA1 hash over the bencoded info map. */ public byte[] getInfoHash() { // XXX - Should we return a clone, just to be sure? return info_hash; } /** * Returns the piece hashes. Only used by storage so package local. */ byte[] getPieceHashes() { return piece_hashes; } /** * Returns the requested name for the file or toplevel directory. * If it is a toplevel directory name getFiles() will return a * non-null List of file name hierarchy name. */ public String getName() { return name; } /** * Is it a private torrent? * @since 0.9 */ public boolean isPrivate() { return privateTorrent; } /** * Returns a list of lists of file name hierarchies or null if it is * a single name. It has the same size as the list returned by * getLengths(). */ public List<List<String>> getFiles() { return files; } /** * Returns a list of Longs indication the size of the individual * files, or null if it is a single file. It has the same size as * the list returned by getFiles(). */ public List<Long> getLengths() { return lengths; } /** * The comment string or null. * Not available for locally-created torrents. * @since 0.9.7 */ public String getComment() { return this.comment; } /** * The created-by string or null. * Not available for locally-created torrents. * @since 0.9.7 */ public String getCreatedBy() { return this.created_by; } /** * The creation date (ms) or zero. * As of 0.9.19, available for locally-created torrents. * @since 0.9.7 */ public long getCreationDate() { return this.creation_date; } /** * Returns the number of pieces. */ public int getPieces() { return piece_hashes.length/20; } /** * Return the length of a piece. All pieces are of equal length * except for the last one (<code>getPieces()-1</code>). * * @throws IndexOutOfBoundsException when piece is equal to or * greater then the number of pieces in the torrent. */ public int getPieceLength(int piece) { int pieces = getPieces(); if (piece >= 0 && piece < pieces -1) return piece_length; else if (piece == pieces -1) return (int)(length - ((long)piece * piece_length)); else throw new IndexOutOfBoundsException("no piece: " + piece); } /** * Checks that the given piece has the same SHA1 hash as the given * byte array. Returns random results or IndexOutOfBoundsExceptions * when the piece number is unknown. */ public boolean checkPiece(int piece, byte[] bs, int off, int length) { //if (true) return fast_checkPiece(piece, bs, off, length); //else // return orig_checkPiece(piece, bs, off, length); } /**** private boolean orig_checkPiece(int piece, byte[] bs, int off, int length) { // Check digest MessageDigest sha1; try { sha1 = MessageDigest.getInstance("SHA"); } catch (NoSuchAlgorithmException nsae) { throw new InternalError("No SHA digest available: " + nsae); } sha1.update(bs, off, length); byte[] hash = sha1.digest(); for (int i = 0; i < 20; i++) if (hash[i] != piece_hashes[20 * piece + i]) return false; return true; } ****/ private boolean fast_checkPiece(int piece, byte[] bs, int off, int length) { MessageDigest sha1 = SHA1.getInstance(); sha1.update(bs, off, length); byte[] hash = sha1.digest(); for (int i = 0; i < 20; i++) { if (hash[i] != piece_hashes[20 * piece + i]) return false; } return true; } /** * @return good * @since 0.9.1 */ boolean checkPiece(PartialPiece pp) { int piece = pp.getPiece(); byte[] hash; try { hash = pp.getHash(); } catch (IOException ioe) { // Could be caused by closing a peer connnection // we don't want the exception to propagate through // to Storage.putPiece() _log.warn("Error checking", ioe); return false; } for (int i = 0; i < 20; i++) { if (hash[i] != piece_hashes[20 * piece + i]) return false; } return true; } /** * Returns the total length of the torrent in bytes. */ public long getTotalLength() { return length; } @Override public String toString() { return "MetaInfo[info_hash='" + I2PSnarkUtil.toHex(info_hash) + "', announce='" + announce + "', name='" + name + "', files=" + files + ", #pieces='" + piece_hashes.length/20 + "', piece_length='" + piece_length + "', length='" + length + "']"; } /** * Creates a copy of this MetaInfo that shares everything except the * announce URL. * Drops any announce-list. * Preserves infohash and info map, including any non-standard fields. * @param announce may be null */ public MetaInfo reannounce(String announce) throws InvalidBEncodingException { Map<String, BEValue> m = new HashMap<String, BEValue>(); if (announce != null) m.put("announce", new BEValue(DataHelper.getUTF8(announce))); Map<String, BEValue> info = createInfoMap(); m.put("info", new BEValue(info)); return new MetaInfo(m); } /** * Called by servlet to save a new torrent file generated from local data */ public synchronized byte[] getTorrentData() { Map<String, Object> m = new HashMap<String, Object>(); if (announce != null) m.put("announce", announce); if (announce_list != null) m.put("announce-list", announce_list); // misc. optional top-level stuff if (comment != null) m.put("comment", comment); if (created_by != null) m.put("created by", created_by); if (creation_date != 0) m.put("creation date", creation_date / 1000); Map<String, BEValue> info = createInfoMap(); m.put("info", info); // don't save this locally, we should only do this once return BEncoder.bencode(m); } /** @since 0.8.4 */ public synchronized byte[] getInfoBytes() { if (infoMap == null) createInfoMap(); return BEncoder.bencode(infoMap); } /** @return an unmodifiable view of the Map */ private Map<String, BEValue> createInfoMap() { // If we loaded this metainfo from a file, we have the map, and we must use it // or else we will lose any non-standard keys and corrupt the infohash. if (infoMap != null) return Collections.unmodifiableMap(infoMap); // we should only get here if serving a magnet on a torrent we created if (_log.shouldLog(Log.WARN)) _log.warn("Creating new infomap", new Exception()); // otherwise we must create it Map<String, BEValue> info = new HashMap<String, BEValue>(); info.put("name", new BEValue(DataHelper.getUTF8(name))); if (name_utf8 != null) info.put("name.utf-8", new BEValue(DataHelper.getUTF8(name_utf8))); // BEP 27 if (privateTorrent) // switched to number in 0.9.14 //info.put("private", new BEValue(DataHelper.getUTF8("1"))); info.put("private", new BEValue(Integer.valueOf(1))); info.put("piece length", new BEValue(Integer.valueOf(piece_length))); info.put("pieces", new BEValue(piece_hashes)); if (files == null) info.put("length", new BEValue(Long.valueOf(length))); else { List<BEValue> l = new ArrayList<BEValue>(); for (int i = 0; i < files.size(); i++) { Map<String, BEValue> file = new HashMap<String, BEValue>(); List<String> fi = files.get(i); List<BEValue> befiles = new ArrayList<BEValue>(fi.size()); for (int j = 0; j < fi.size(); j++) { befiles.add(new BEValue(DataHelper.getUTF8(fi.get(j)))); } file.put("path", new BEValue(befiles)); if ( (files_utf8 != null) && (files_utf8.size() > i) ) { List<String> fiu = files_utf8.get(i); List<BEValue> beufiles = new ArrayList<BEValue>(fiu.size()); for (int j = 0; j < fiu.size(); j++) { beufiles.add(new BEValue(DataHelper.getUTF8(fiu.get(j)))); } file.put("path.utf-8", new BEValue(beufiles)); } file.put("length", new BEValue(lengths.get(i))); l.add(new BEValue(file)); } info.put("files", new BEValue(l)); } // TODO if we add the ability for other keys in the first constructor //if (otherInfo != null) // info.putAll(otherInfo); infoMap = info; return Collections.unmodifiableMap(infoMap); } private byte[] calculateInfoHash() { Map<String, BEValue> info = createInfoMap(); if (_log.shouldLog(Log.DEBUG)) { StringBuilder buf = new StringBuilder(128); buf.append("info: "); for (Map.Entry<String, BEValue> entry : info.entrySet()) { String key = entry.getKey(); Object val = entry.getValue(); buf.append(key).append('='); buf.append(val.toString()); } _log.debug(buf.toString()); } byte[] infoBytes = BEncoder.bencode(info); //_log.debug("info bencoded: [" + Base64.encode(infoBytes, true) + "]"); MessageDigest digest = SHA1.getInstance(); byte hash[] = digest.digest(infoBytes); if (_log.shouldLog(Log.DEBUG)) _log.debug("info hash: " + I2PSnarkUtil.toHex(hash)); return hash; } /** @since 0.8.5 */ public static void main(String[] args) { if (args.length <= 0) { System.err.println("Usage: MetaInfo files..."); return; } for (int i = 0; i < args.length; i++) { InputStream in = null; try { in = new FileInputStream(args[i]); MetaInfo meta = new MetaInfo(in); System.out.println(args[i] + " InfoHash: " + I2PSnarkUtil.toHex(meta.getInfoHash())); } catch (IOException ioe) { System.err.println("Error in file " + args[i] + ": " + ioe); } finally { try { if (in != null) in.close(); } catch (IOException ioe) {} } } } }