Skip to content

Feature/existing data validation #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions BencodeNET/Torrents/Torrent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Threading.Tasks;
using BencodeNET.Exceptions;
using BencodeNET.Objects;
using BencodeNET.Torrents.Validation;

namespace BencodeNET.Torrents
{
Expand All @@ -17,6 +18,11 @@ namespace BencodeNET.Torrents
/// </summary>
public class Torrent : BObject
{
/// <summary>
/// Number of bytes a piece has
/// </summary>
private const int PIECE_NUMBER_OF_BYTES = 20;

/// <summary>
///
/// </summary>
Expand Down Expand Up @@ -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?
/// <summary>
/// A list of all 20-byte SHA1 hash values (one for each piece).
/// </summary>
public List<byte[]> Pieces
{
get
{
var pieces = new List<byte[]>();
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;
}
}

/// <summary>
/// A concatenation of all 20-byte SHA1 hash values (one for each piece).
/// Use <see cref="PiecesAsHexString"/> to get/set this value as a hex string instead.
/// Use <see cref="PiecesConcatenatedAsHexString"/> to get/set this value as a hex string instead.
/// </summary>
public virtual byte[] Pieces { get; set; } = new byte[0];
public virtual byte[] PiecesConcatenated { get; set; } = new byte[0];

/// <summary>
/// Gets or sets <see cref="Pieces"/> from/to a hex string (without dashes), e.g. 1C115D26444AEF2A5E936133DCF8789A552BBE9F[...].
/// Gets or sets <see cref="PiecesConcatenated"/> from/to a hex string (without dashes), e.g. 1C115D26444AEF2A5E936133DCF8789A552BBE9F[...].
/// The length of the string must be a multiple of 40.
/// </summary>
public virtual string PiecesAsHexString
public virtual string PiecesConcatenatedAsHexString
{
get => BitConverter.ToString(Pieces).Replace("-", "");
get => BitConverter.ToString(PiecesConcatenated).Replace("-", "");
set
{
if (value?.Length % 40 != 0)
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -232,10 +255,23 @@ public virtual long TotalSize
/// <summary>
/// The total number of file pieces.
/// </summary>
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;

/// <summary>
/// Verify integrity of the torrent content versus existing data
/// </summary>
/// <param name="path">Either a folder path in multi mode or a file path in single mode</param>
/// <param name="options">Validation options. Null means the dafault options</param>
/// <returns></returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "<Pending>")]
public async virtual Task<bool> ValidateExistingDataAsync(string path, ValidationOptions options = null)
{
var validator = new Validator(this, options);
return await validator.ValidateExistingDataAsync(path);
}

/// <summary>
/// Converts the torrent to a <see cref="BDictionary"/>.
/// </summary>
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion BencodeNET/Torrents/TorrentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ protected Torrent CreateTorrent(BDictionary data)
{
IsPrivate = info.Get<BNumber>(TorrentInfoFields.Private) == 1,
PieceSize = info.Get<BNumber>(TorrentInfoFields.PieceLength),
Pieces = info.Get<BString>(TorrentInfoFields.Pieces)?.Value.ToArray() ?? new byte[0],
PiecesConcatenated = info.Get<BString>(TorrentInfoFields.Pieces)?.Value.ToArray() ?? new byte[0],

Comment = data.Get<BString>(TorrentFields.Comment)?.ToString(encoding),
CreatedBy = data.Get<BString>(TorrentFields.CreatedBy)?.ToString(encoding),
Expand Down
24 changes: 24 additions & 0 deletions BencodeNET/Torrents/Validation/ValidationData.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
23 changes: 23 additions & 0 deletions BencodeNET/Torrents/Validation/ValidationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace BencodeNET.Torrents.Validation
{
/// <summary>
/// Options for torrent file(s) validation
/// </summary>
public class ValidationOptions
{
/// <summary>
/// What percentage validated is considered as valid torrent existing data
/// </summary>
/// <remarks>>=1 = 100%, 0.95 = 95%. Only valid with torrent in MultiFile mode.</remarks>
public double Tolerance { get; set; } = 1;

/// <summary>
///
/// </summary>
public static readonly ValidationOptions DefaultOptions = new ValidationOptions();
}
}
155 changes: 155 additions & 0 deletions BencodeNET/Torrents/Validation/Validator.cs
Original file line number Diff line number Diff line change
@@ -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<byte[]> 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;
}
}

/// <summary>
/// Verify integrity of the torrent content versus existing data
/// </summary>
/// <param name="path">either a folder path in multi mode or a file path in single mode</param>
/// <returns></returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "<Pending>")]
public async virtual Task<bool> 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;
}

/// <summary>
/// Validate integrity of an existing file
/// </summary>
/// <param name="file">file to validate</param>
/// <returns></returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "<Pending>")]
private async Task<ValidationData> ValidateExistingFileAsync(FileInfo file)
{
return await ValidateExistingFileAsync(file, 0, new ValidationData(PieceSize, true));
}

/// <summary>
/// Validate integrity of an existing file
/// </summary>
/// <param name="file">file to validate</param>
/// <param name="piecesOffset">next piece index to validate</param>
/// <param name="validation">current validation data</param>
/// <remarks>Based on https://raw.githubusercontent.com/eclipse/ecf/master/protocols/bundles/org.eclipse.ecf.protocol.bittorrent/src/org/eclipse/ecf/protocol/bittorrent/TorrentFile.java</remarks>
/// <returns></returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD111:Use ConfigureAwait(bool)", Justification = "<Pending>")]
private async Task<ValidationData> 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;
}
}
}