213 lines
7.4 KiB
Python
213 lines
7.4 KiB
Python
import hashlib
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
|
|
from archive_clients.bencode import BencodeError, decode_metainfo, encode
|
|
from archive_clients.resources import (
|
|
ResourceError,
|
|
build_content_tree,
|
|
normalize_resource,
|
|
)
|
|
from archive_control.v1 import resource_pb2
|
|
|
|
|
|
class ResourceTests(unittest.TestCase):
|
|
def test_pure_v2_identity_and_single_file_tree_normalization(self):
|
|
info = {
|
|
b"file tree": {
|
|
b"v2.bin": {
|
|
b"": {
|
|
b"length": 3,
|
|
b"pieces root": b"x" * 32,
|
|
},
|
|
},
|
|
},
|
|
b"meta version": 2,
|
|
b"name": b"v2.bin",
|
|
b"piece length": 16384,
|
|
}
|
|
metainfo_bytes = encode({b"info": info})
|
|
decoded = decode_metainfo(metainfo_bytes)
|
|
self.assertEqual(decoded.info_hash_v1_hex, "")
|
|
self.assertEqual(
|
|
decoded.info_hash_v2_hex,
|
|
hashlib.sha256(encode(info)).hexdigest(),
|
|
)
|
|
self.assertEqual(decoded.files[0].path, "v2.bin")
|
|
|
|
normalized = normalize_resource(
|
|
{
|
|
"hash": decoded.info_hash_v2_hex,
|
|
"name": "v2.bin",
|
|
"state": "stoppedUP",
|
|
"save_path": "/downloads",
|
|
},
|
|
[{
|
|
"index": 0,
|
|
"name": "v2.bin",
|
|
"size": 3,
|
|
"completed": 3,
|
|
"priority": 1,
|
|
}],
|
|
metainfo_bytes,
|
|
)
|
|
self.assertEqual(
|
|
normalized.summary.resource_id.info_hash_v2_hex,
|
|
decoded.info_hash_v2_hex,
|
|
)
|
|
self.assertFalse(normalized.summary.resource_id.info_hash_v1_hex)
|
|
|
|
def test_hybrid_identity_and_selection_normalization(self):
|
|
info = {
|
|
b"file tree": {
|
|
b"resource": {
|
|
b"a.txt": {b"": {b"length": 3}},
|
|
b"b.bin": {b"": {b"attr": b"p", b"length": 5}},
|
|
},
|
|
},
|
|
b"files": [
|
|
{b"length": 3, b"path": [b"a.txt"]},
|
|
{b"attr": b"p", b"length": 5, b"path": [b"b.bin"]},
|
|
],
|
|
b"meta version": 2,
|
|
b"name": b"resource",
|
|
b"piece length": 16384,
|
|
b"pieces": b"x" * 20,
|
|
}
|
|
metainfo_bytes = encode({b"announce": b"http://tracker", b"info": info})
|
|
decoded = decode_metainfo(metainfo_bytes)
|
|
encoded_info = encode(info)
|
|
self.assertEqual(
|
|
decoded.info_hash_v1_hex, hashlib.sha1(encoded_info).hexdigest()
|
|
)
|
|
self.assertEqual(
|
|
decoded.info_hash_v2_hex, hashlib.sha256(encoded_info).hexdigest()
|
|
)
|
|
self.assertEqual(decoded.files[1].path, "resource/b.bin")
|
|
self.assertTrue(decoded.files[1].padding)
|
|
|
|
observed = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
|
normalized = normalize_resource(
|
|
{
|
|
"hash": decoded.info_hash_v1_hex,
|
|
"name": "resource",
|
|
"state": "stoppedUP",
|
|
"save_path": "/downloads/nested",
|
|
},
|
|
[
|
|
{
|
|
"index": 0, "name": "resource/a.txt", "size": 3,
|
|
"progress": 1.0, "priority": 1,
|
|
},
|
|
{
|
|
"index": 1, "name": "resource/b.bin", "size": 5,
|
|
"progress": 0.5, "priority": 0,
|
|
},
|
|
],
|
|
metainfo_bytes,
|
|
observed,
|
|
)
|
|
summary = normalized.summary
|
|
self.assertEqual(normalized.save_path.as_posix(), "/downloads/nested")
|
|
self.assertEqual(
|
|
summary.runtime_state, resource_pb2.TORRENT_RUNTIME_STATE_STOPPED
|
|
)
|
|
self.assertEqual(summary.selected_files.ranges[0].first, 0)
|
|
self.assertEqual(summary.selected_complete_files.ranges[0].last, 0)
|
|
self.assertEqual(summary.selected_logical_bytes, 3)
|
|
self.assertTrue(summary.canonical_paths)
|
|
tree = build_content_tree(normalized.files, {0})
|
|
root = next(entry for entry in tree if entry.canonical_path == "resource")
|
|
self.assertEqual(root.available_file_count, 1)
|
|
self.assertEqual(root.available_logical_bytes, 3)
|
|
|
|
def test_qbittorrent_hidden_padding_files_are_normalized(self):
|
|
info = {
|
|
b"files": [
|
|
{b"length": 3, b"path": [b"first.bin"]},
|
|
{
|
|
b"length": 5,
|
|
b"path": [b"_____padding_file_5"],
|
|
},
|
|
{b"length": 7, b"path": [b"last.bin"]},
|
|
],
|
|
b"name": b"with-padding",
|
|
b"piece length": 16384,
|
|
b"pieces": b"x" * 20,
|
|
}
|
|
metainfo = encode({b"info": info})
|
|
torrent_hash = hashlib.sha1(encode(info)).hexdigest()
|
|
normalized = normalize_resource(
|
|
{
|
|
"hash": torrent_hash,
|
|
"name": "with-padding",
|
|
"state": "stalledUP",
|
|
"save_path": "/downloads",
|
|
},
|
|
[
|
|
{
|
|
"index": 0, "name": "with-padding/first.bin",
|
|
"size": 3, "completed": 3, "priority": 1,
|
|
},
|
|
{
|
|
"index": 1, "name": "with-padding/last.bin",
|
|
"size": 7, "completed": 7, "priority": 1,
|
|
},
|
|
],
|
|
metainfo,
|
|
)
|
|
self.assertEqual(
|
|
[item.canonical_path for item in normalized.files],
|
|
["with-padding/first.bin", "with-padding/last.bin"],
|
|
)
|
|
self.assertEqual(normalized.summary.total_file_count, 2)
|
|
self.assertEqual(normalized.summary.selected_complete_bytes, 10)
|
|
|
|
def test_noncanonical_and_unsafe_paths_are_distinct(self):
|
|
info = {
|
|
b"length": 3,
|
|
b"name": b"a.txt",
|
|
b"piece length": 16384,
|
|
b"pieces": b"x" * 20,
|
|
}
|
|
metainfo = encode({b"info": info})
|
|
torrent = {
|
|
"hash": hashlib.sha1(encode(info)).hexdigest(),
|
|
"name": "renamed", "state": "uploading", "save_path": "/downloads",
|
|
}
|
|
renamed = normalize_resource(torrent, [{
|
|
"index": 0, "name": "renamed.txt", "size": 3,
|
|
"progress": 1.0, "priority": 1,
|
|
}], metainfo)
|
|
self.assertFalse(renamed.summary.canonical_paths)
|
|
with self.assertRaisesRegex(ResourceError, "unsafe"):
|
|
normalize_resource(torrent, [{
|
|
"index": 0, "name": "../escape", "size": 3,
|
|
"progress": 1.0, "priority": 1,
|
|
}], metainfo)
|
|
|
|
def test_missing_or_out_of_shape_save_path_is_rejected(self):
|
|
info = {
|
|
b"length": 3, b"name": b"a.txt", b"piece length": 16384,
|
|
b"pieces": b"x" * 20,
|
|
}
|
|
metainfo = encode({b"info": info})
|
|
torrent = {
|
|
"hash": hashlib.sha1(encode(info)).hexdigest(),
|
|
"name": "a.txt", "state": "uploading", "save_path": "relative",
|
|
}
|
|
with self.assertRaisesRegex(ResourceError, "save path is unsafe"):
|
|
normalize_resource(torrent, [{
|
|
"index": 0, "name": "a.txt", "size": 3,
|
|
"completed": 3, "priority": 1,
|
|
}], metainfo)
|
|
|
|
|
|
def test_noncanonical_bencode_is_rejected(self):
|
|
with self.assertRaisesRegex(BencodeError, "unsorted"):
|
|
decode_metainfo(b"d4:infod1:b1:x1:a1:yee")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|