diff --git a/BencodeNET/Torrents/Torrent.cs b/BencodeNET/Torrents/Torrent.cs index eead9c5afe..2f49b4f8f9 100644 --- a/BencodeNET/Torrents/Torrent.cs +++ b/BencodeNET/Torrents/Torrent.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using BencodeNET.Exceptions; using BencodeNET.Objects; +using BencodeNET.Torrents.Validation; namespace BencodeNET.Torrents { @@ -17,6 +18,11 @@ namespace BencodeNET.Torrents /// public class Torrent : BObject { + /// + /// Number of bytes a piece has + /// + private const int PIECE_NUMBER_OF_BYTES = 20; + /// /// /// @@ -173,20 +179,37 @@ public virtual string DisplayNameUtf8 public virtual long PieceSize { get; set; } // TODO: Split into list of 20-byte hashes and rename to something appropriate? + /// + /// A list of all 20-byte SHA1 hash values (one for each piece). + /// + public List Pieces + { + get + { + var pieces = new List(); + for (int i = 0; i < PiecesConcatenated.Length; i += PIECE_NUMBER_OF_BYTES) + { + var piece = new byte[PIECE_NUMBER_OF_BYTES]; + Array.Copy(PiecesConcatenated, i, piece, 0, PIECE_NUMBER_OF_BYTES); + pieces.Add(piece); + } + return pieces; + } + } /// /// A concatenation of all 20-byte SHA1 hash values (one for each piece). - /// Use to get/set this value as a hex string instead. + /// Use to get/set this value as a hex string instead. /// - public virtual byte[] Pieces { get; set; } = new byte[0]; + public virtual byte[] PiecesConcatenated { get; set; } = new byte[0]; /// - /// Gets or sets from/to a hex string (without dashes), e.g. 1C115D26444AEF2A5E936133DCF8789A552BBE9F[...]. + /// Gets or sets from/to a hex string (without dashes), e.g. 1C115D26444AEF2A5E936133DCF8789A552BBE9F[...]. /// The length of the string must be a multiple of 40. /// - public virtual string PiecesAsHexString + public virtual string PiecesConcatenatedAsHexString { - get => BitConverter.ToString(Pieces).Replace("-", ""); + get => BitConverter.ToString(PiecesConcatenated).Replace("-", ""); set { if (value?.Length % 40 != 0) @@ -195,14 +218,14 @@ public virtual string PiecesAsHexString if (Regex.IsMatch(value, "[^0-9A-F]")) throw new ArgumentException("Value must only contain hex characters (0-9 and A-F) and only uppercase."); - var bytes = new byte[value.Length/2]; + var bytes = new byte[value.Length / 2]; for (var i = 0; i < bytes.Length; i++) { - var str = $"{value[i*2]}{value[i*2+1]}"; + var str = $"{value[i * 2]}{value[i * 2 + 1]}"; bytes[i] = Convert.ToByte(str, 16); } - Pieces = bytes; + PiecesConcatenated = bytes; } } @@ -232,10 +255,23 @@ public virtual long TotalSize /// /// The total number of file pieces. /// - public virtual int NumberOfPieces => Pieces != null - ? (int) Math.Ceiling((double) Pieces.Length / 20) + public virtual int NumberOfPieces => PiecesConcatenated != null + ? (int)Math.Ceiling((double)PiecesConcatenated.Length / 20) : 0; + /// + /// Verify integrity of the torrent content versus existing data + /// + /// Either a folder path in multi mode or a file path in single mode + /// Validation options. Null means the dafault options + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "")] + public async virtual Task ValidateExistingDataAsync(string path, ValidationOptions options = null) + { + var validator = new Validator(this, options); + return await validator.ValidateExistingDataAsync(path); + } + /// /// Converts the torrent to a . /// @@ -284,10 +320,10 @@ protected virtual BDictionary CreateInfoDictionary(Encoding encoding) var info = new BDictionary(); if (PieceSize > 0) - info[TorrentInfoFields.PieceLength] = (BNumber) PieceSize; + info[TorrentInfoFields.PieceLength] = (BNumber)PieceSize; - if (Pieces?.Length > 0) - info[TorrentInfoFields.Pieces] = new BString(Pieces, encoding); + if (PiecesConcatenated?.Length > 0) + info[TorrentInfoFields.Pieces] = new BString(PiecesConcatenated, encoding); if (IsPrivate) info[TorrentInfoFields.Private] = (BNumber)1; diff --git a/BencodeNET/Torrents/TorrentParser.cs b/BencodeNET/Torrents/TorrentParser.cs index 2fde44558c..fde88df3e0 100644 --- a/BencodeNET/Torrents/TorrentParser.cs +++ b/BencodeNET/Torrents/TorrentParser.cs @@ -112,7 +112,7 @@ protected Torrent CreateTorrent(BDictionary data) { IsPrivate = info.Get(TorrentInfoFields.Private) == 1, PieceSize = info.Get(TorrentInfoFields.PieceLength), - Pieces = info.Get(TorrentInfoFields.Pieces)?.Value.ToArray() ?? new byte[0], + PiecesConcatenated = info.Get(TorrentInfoFields.Pieces)?.Value.ToArray() ?? new byte[0], Comment = data.Get(TorrentFields.Comment)?.ToString(encoding), CreatedBy = data.Get(TorrentFields.CreatedBy)?.ToString(encoding), diff --git a/BencodeNET/Torrents/Validation/ValidationData.cs b/BencodeNET/Torrents/Validation/ValidationData.cs new file mode 100644 index 0000000000..30a8edcd2d --- /dev/null +++ b/BencodeNET/Torrents/Validation/ValidationData.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BencodeNET.Torrents.Validation +{ + class ValidationData + { + public bool isValid; + public int piecesValidated; + public long remainder; + public byte[] buffer; + public bool validateRemainder; + + public ValidationData(long bufferSize, bool validateReminder) + { + piecesValidated = 0; + isValid = false; + remainder = 0; + buffer = new byte[bufferSize]; + this.validateRemainder = validateReminder; + } + } +} diff --git a/BencodeNET/Torrents/Validation/ValidationOptions.cs b/BencodeNET/Torrents/Validation/ValidationOptions.cs new file mode 100644 index 0000000000..1148920484 --- /dev/null +++ b/BencodeNET/Torrents/Validation/ValidationOptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BencodeNET.Torrents.Validation +{ + /// + /// Options for torrent file(s) validation + /// + public class ValidationOptions + { + /// + /// What percentage validated is considered as valid torrent existing data + /// + /// >=1 = 100%, 0.95 = 95%. Only valid with torrent in MultiFile mode. + public double Tolerance { get; set; } = 1; + + /// + /// + /// + public static readonly ValidationOptions DefaultOptions = new ValidationOptions(); + } +} diff --git a/BencodeNET/Torrents/Validation/Validator.cs b/BencodeNET/Torrents/Validation/Validator.cs new file mode 100644 index 0000000000..0c7492f470 --- /dev/null +++ b/BencodeNET/Torrents/Validation/Validator.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using System.Linq; + +namespace BencodeNET.Torrents.Validation +{ + class Validator + { + private readonly Torrent torrent; + private readonly ValidationOptions options; + private readonly HashAlgorithm sha1 = SHA1.Create(); + + // Shorthand helpers + private TorrentFileMode FileMode => torrent.FileMode; + private MultiFileInfoList Files => torrent.Files; + private long PieceSize => torrent.PieceSize; + private int NumberOfPieces => torrent.NumberOfPieces; + private List Pieces => torrent.Pieces; + + public Validator(Torrent torrent, ValidationOptions options) + { + this.options = options ?? ValidationOptions.DefaultOptions; + this.torrent = torrent; + + // Ensure options are appropriate toward the current torrent + if (this.torrent.FileMode == TorrentFileMode.Single || this.options.Tolerance > 1) + { + this.options.Tolerance = 1; + } + } + + /// + /// Verify integrity of the torrent content versus existing data + /// + /// either a folder path in multi mode or a file path in single mode + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "")] + public async virtual Task ValidateExistingDataAsync(string path) + { + var isDirectory = Directory.Exists(path); + var isFile = System.IO.File.Exists(path); + if (String.IsNullOrEmpty(path)) + { + return false; + } + + if (isDirectory && FileMode != TorrentFileMode.Multi) + { + throw new ArgumentException("The path represents a directory but the torrent is not set as a multi mode"); + } + else if (isFile && FileMode != TorrentFileMode.Single) + { + throw new ArgumentException("The path represents a file but the torrent is not set as a single mode"); + } + else if (!isFile && !isDirectory) + { + return false; + } + + var validation = new ValidationData(PieceSize, false); + if (isFile) + { + validation = await ValidateExistingFileAsync(new System.IO.FileInfo(path)); + } + else if (isDirectory) + { + validation.isValid = true; + var piecesOffset = 0; + for (int i = 0; i < Files.Count && piecesOffset < NumberOfPieces; i++) + { + var previousRemainder = validation.remainder; + validation.validateRemainder = (i + 1) == Files.Count; + var file = new FileInfo(Path.Combine(path, Files.DirectoryName, Files[i].FullPath)); + validation = await ValidateExistingFileAsync(file, piecesOffset, validation); + if (!validation.isValid && options.Tolerance == 1) + { + break; + } + + validation.remainder = (Files[i].FileSize + previousRemainder) % PieceSize; // Set again the remainder in case the file was not existing or partially good + piecesOffset += (int)((Files[i].FileSize + previousRemainder) / PieceSize); + } + } + + return ((double)validation.piecesValidated / (double)NumberOfPieces) >= options.Tolerance; + } + + /// + /// Validate integrity of an existing file + /// + /// file to validate + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "")] + private async Task ValidateExistingFileAsync(FileInfo file) + { + return await ValidateExistingFileAsync(file, 0, new ValidationData(PieceSize, true)); + } + + /// + /// Validate integrity of an existing file + /// + /// file to validate + /// next piece index to validate + /// current validation data + /// Based on https://raw.githubusercontent.com/eclipse/ecf/master/protocols/bundles/org.eclipse.ecf.protocol.bittorrent/src/org/eclipse/ecf/protocol/bittorrent/TorrentFile.java + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "")] + private async Task ValidateExistingFileAsync(FileInfo file, int piecesOffset, ValidationData validation) + { + if (!file.Exists) + { + validation.isValid = false; + return validation; + } + + int piecesIndex = piecesOffset, bytesRead = (int)validation.remainder; + using (var stream = file.OpenRead()) + { + while ((bytesRead += await stream.ReadAsync(validation.buffer, (int)validation.remainder, (int)(PieceSize - validation.remainder))) == PieceSize) + { + var isFileTooLarge = piecesIndex >= NumberOfPieces; + var isPieceNotMatching = !isFileTooLarge && !Pieces[piecesIndex].SequenceEqual(sha1.ComputeHash(validation.buffer)) && options.Tolerance == 1; + if (isFileTooLarge || isPieceNotMatching) + { + validation.isValid = false; + return validation; + } + + validation.piecesValidated++; + piecesIndex++; + bytesRead = 0; + validation.remainder = 0; + } + } + + validation.remainder = bytesRead; + if (!validation.validateRemainder || validation.remainder == 0) + { + validation.isValid = true; + return validation; + } + + byte[] lastBuffer = new byte[validation.remainder]; + Array.Copy(validation.buffer, lastBuffer, bytesRead); + + validation.isValid = Pieces[piecesIndex].SequenceEqual(sha1.ComputeHash(lastBuffer)); + validation.piecesValidated += (validation.isValid ? 1 : 0); + + return validation; + } + } +}