helppeer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- helppeer-0.1.0/PKG-INFO +94 -0
- helppeer-0.1.0/README.md +64 -0
- helppeer-0.1.0/pyproject.toml +48 -0
- helppeer-0.1.0/setup.cfg +4 -0
- helppeer-0.1.0/src/helppeer/__init__.py +8 -0
- helppeer-0.1.0/src/helppeer/__main__.py +2 -0
- helppeer-0.1.0/src/helppeer/cli.py +68 -0
- helppeer-0.1.0/src/helppeer/client.py +278 -0
- helppeer-0.1.0/src/helppeer/config.py +45 -0
- helppeer-0.1.0/src/helppeer/crypto.py +59 -0
- helppeer-0.1.0/src/helppeer/erasure.py +130 -0
- helppeer-0.1.0/src/helppeer/manifest.py +139 -0
- helppeer-0.1.0/src/helppeer/validator.py +66 -0
- helppeer-0.1.0/src/helppeer.egg-info/PKG-INFO +94 -0
- helppeer-0.1.0/src/helppeer.egg-info/SOURCES.txt +18 -0
- helppeer-0.1.0/src/helppeer.egg-info/dependency_links.txt +1 -0
- helppeer-0.1.0/src/helppeer.egg-info/entry_points.txt +2 -0
- helppeer-0.1.0/src/helppeer.egg-info/requires.txt +8 -0
- helppeer-0.1.0/src/helppeer.egg-info/top_level.txt +1 -0
- helppeer-0.1.0/tests/test_sdk.py +160 -0
helppeer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: helppeer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous ephemeral file transfer for AI model weights
|
|
5
|
+
Author: JenD86
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/JenD86/help-peer
|
|
8
|
+
Project-URL: Repository, https://github.com/JenD86/help-peer
|
|
9
|
+
Project-URL: Issues, https://github.com/JenD86/help-peer/issues
|
|
10
|
+
Keywords: file-transfer,ai,model-weights,safetensors,p2p,decentralized,ephemeral
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: cryptography>=41.0
|
|
24
|
+
Requires-Dist: blake3>=0.3
|
|
25
|
+
Requires-Dist: requests>=2.28
|
|
26
|
+
Requires-Dist: reedsolo>=1.7
|
|
27
|
+
Requires-Dist: hkdf>=0.0.3
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Help Peer Python SDK
|
|
32
|
+
|
|
33
|
+
A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install helppeer
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import helppeer
|
|
45
|
+
|
|
46
|
+
# Send a model directory
|
|
47
|
+
code = helppeer.send("./my-model", name="Llama-3-70B")
|
|
48
|
+
print(f"Transfer code: {code}")
|
|
49
|
+
# Share this code with the recipient. Data lives for 24 hours.
|
|
50
|
+
|
|
51
|
+
# Receive a transfer
|
|
52
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import helppeer
|
|
59
|
+
|
|
60
|
+
# Use custom relay and storage nodes
|
|
61
|
+
helppeer.configure(
|
|
62
|
+
relay="https://relay.helppeer.dev",
|
|
63
|
+
storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Or use environment variables:
|
|
67
|
+
# HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Agent Integration
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import helppeer
|
|
74
|
+
import json
|
|
75
|
+
|
|
76
|
+
# Send and get structured result
|
|
77
|
+
result = helppeer.send("./model-weights", name="my-model", return_details=True)
|
|
78
|
+
# result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
|
|
79
|
+
|
|
80
|
+
# Receive with progress callback
|
|
81
|
+
def on_progress(file_path, segment, total_segments):
|
|
82
|
+
print(f" {file_path}: {segment}/{total_segments} segments")
|
|
83
|
+
|
|
84
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## CLI
|
|
88
|
+
|
|
89
|
+
The SDK also installs a `helppeer` CLI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
helppeer send ./my-model --name "Llama-3-70B"
|
|
93
|
+
helppeer receive 38-vortex-xenon --output ./received
|
|
94
|
+
```
|
helppeer-0.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Help Peer Python SDK
|
|
2
|
+
|
|
3
|
+
A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install helppeer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import helppeer
|
|
15
|
+
|
|
16
|
+
# Send a model directory
|
|
17
|
+
code = helppeer.send("./my-model", name="Llama-3-70B")
|
|
18
|
+
print(f"Transfer code: {code}")
|
|
19
|
+
# Share this code with the recipient. Data lives for 24 hours.
|
|
20
|
+
|
|
21
|
+
# Receive a transfer
|
|
22
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import helppeer
|
|
29
|
+
|
|
30
|
+
# Use custom relay and storage nodes
|
|
31
|
+
helppeer.configure(
|
|
32
|
+
relay="https://relay.helppeer.dev",
|
|
33
|
+
storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Or use environment variables:
|
|
37
|
+
# HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Agent Integration
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import helppeer
|
|
44
|
+
import json
|
|
45
|
+
|
|
46
|
+
# Send and get structured result
|
|
47
|
+
result = helppeer.send("./model-weights", name="my-model", return_details=True)
|
|
48
|
+
# result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
|
|
49
|
+
|
|
50
|
+
# Receive with progress callback
|
|
51
|
+
def on_progress(file_path, segment, total_segments):
|
|
52
|
+
print(f" {file_path}: {segment}/{total_segments} segments")
|
|
53
|
+
|
|
54
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
The SDK also installs a `helppeer` CLI:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
helppeer send ./my-model --name "Llama-3-70B"
|
|
63
|
+
helppeer receive 38-vortex-xenon --output ./received
|
|
64
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "helppeer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Asynchronous ephemeral file transfer for AI model weights"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "JenD86"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["file-transfer", "ai", "model-weights", "safetensors", "p2p", "decentralized", "ephemeral"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"cryptography>=41.0",
|
|
30
|
+
"blake3>=0.3",
|
|
31
|
+
"requests>=2.28",
|
|
32
|
+
"reedsolo>=1.7",
|
|
33
|
+
"hkdf>=0.0.3",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = ["pytest>=7.0"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
helppeer = "helppeer.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/JenD86/help-peer"
|
|
44
|
+
Repository = "https://github.com/JenD86/help-peer"
|
|
45
|
+
Issues = "https://github.com/JenD86/help-peer/issues"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
helppeer-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Help Peer CLI entry point."""
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
from . import send, receive, configure
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="helppeer",
|
|
10
|
+
description="Asynchronous ephemeral file transfer for AI model weights",
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument("--relay", default="http://127.0.0.1:7000", help="Relay server URL")
|
|
13
|
+
parser.add_argument("--nodes", default="http://127.0.0.1:7001",
|
|
14
|
+
help="Storage node URLs (comma-separated)")
|
|
15
|
+
|
|
16
|
+
sub = parser.add_subcommands(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
send_cmd = sub.add_parser("send", help="Send a file or directory")
|
|
19
|
+
send_cmd.add_argument("path", help="Path to send")
|
|
20
|
+
send_cmd.add_argument("--name", default="untitled-transfer", help="Transfer name")
|
|
21
|
+
|
|
22
|
+
recv_cmd = sub.add_parser("receive", help="Receive a transfer")
|
|
23
|
+
recv_cmd.add_argument("code", help="Transfer code (e.g., 38-vortex-xenon)")
|
|
24
|
+
recv_cmd.add_argument("--output", default="./received", help="Output directory")
|
|
25
|
+
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
# Configure from CLI args
|
|
29
|
+
nodes = [n.strip() for n in args.nodes.split(",") if n.strip()]
|
|
30
|
+
configure(relay=args.relay, storage_nodes=nodes)
|
|
31
|
+
|
|
32
|
+
if args.command == "send":
|
|
33
|
+
print(f"Help Peer — Sending: {args.path}")
|
|
34
|
+
print(f"Transfer name: {args.name}")
|
|
35
|
+
print(f"Relay: {args.relay}")
|
|
36
|
+
print(f"Storage nodes: {len(nodes)} (total)")
|
|
37
|
+
print()
|
|
38
|
+
|
|
39
|
+
code = send(args.path, name=args.name)
|
|
40
|
+
print()
|
|
41
|
+
print("✓ Upload complete!")
|
|
42
|
+
print()
|
|
43
|
+
print(f" Transfer code: {code}")
|
|
44
|
+
print()
|
|
45
|
+
print(" Share this code with the recipient.")
|
|
46
|
+
print(" The data will be available for 24 hours.")
|
|
47
|
+
|
|
48
|
+
elif args.command == "receive":
|
|
49
|
+
print(f"Help Peer — Receiving with code: {args.code}")
|
|
50
|
+
print(f"Output directory: {args.output}")
|
|
51
|
+
print(f"Relay: {args.relay}")
|
|
52
|
+
print()
|
|
53
|
+
|
|
54
|
+
result = receive(args.code, output_dir=args.output)
|
|
55
|
+
print()
|
|
56
|
+
print("✓ Download complete!")
|
|
57
|
+
print()
|
|
58
|
+
print(f" Transfer: {result['transfer_name']}")
|
|
59
|
+
print(f" Files: {result['files']}")
|
|
60
|
+
print(f" Total size: {result['total_bytes']} bytes")
|
|
61
|
+
print()
|
|
62
|
+
print(" File hashes (BLAKE3):")
|
|
63
|
+
for path, h in result["file_hashes"].items():
|
|
64
|
+
print(f" {path} → {h}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Help Peer client — send and receive file transfers."""
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import requests
|
|
5
|
+
from typing import Optional, Callable, List, Dict, Any
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
|
|
8
|
+
from .config import get_config
|
|
9
|
+
from . import crypto
|
|
10
|
+
from . import erasure
|
|
11
|
+
from .manifest import (
|
|
12
|
+
Manifest, ManifestFile, ManifestSegment, ManifestShard,
|
|
13
|
+
build_manifest, read_file_segment,
|
|
14
|
+
)
|
|
15
|
+
from .validator import ValidatorRegistry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def send(
|
|
19
|
+
path: str,
|
|
20
|
+
name: str = "untitled-transfer",
|
|
21
|
+
*,
|
|
22
|
+
return_details: bool = False,
|
|
23
|
+
) -> str | Dict[str, Any]:
|
|
24
|
+
"""Send a file or directory. Returns the transfer code.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to the file or directory to send.
|
|
28
|
+
name: Human-readable name for the transfer.
|
|
29
|
+
return_details: If True, return a dict with full transfer details.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Transfer code string (e.g., "38-vortex-xenon"), or a dict if return_details=True.
|
|
33
|
+
"""
|
|
34
|
+
config = get_config()
|
|
35
|
+
code = _generate_code()
|
|
36
|
+
k_data, k_index = crypto.derive_keys(code)
|
|
37
|
+
r_hash = crypto.relay_hash(k_index)
|
|
38
|
+
|
|
39
|
+
if os.path.isfile(path):
|
|
40
|
+
# Single file: wrap in a temporary directory structure
|
|
41
|
+
manifest = _send_single_file(path, name, k_data, config)
|
|
42
|
+
else:
|
|
43
|
+
manifest = _send_directory(path, name, k_data, config)
|
|
44
|
+
|
|
45
|
+
# Encrypt and upload manifest
|
|
46
|
+
manifest_json = manifest.to_json()
|
|
47
|
+
encrypted_manifest = crypto.encrypt_segment(k_data, manifest_json)
|
|
48
|
+
_upload_manifest(config.relay_url, r_hash, encrypted_manifest)
|
|
49
|
+
|
|
50
|
+
if return_details:
|
|
51
|
+
return {
|
|
52
|
+
"code": code,
|
|
53
|
+
"transfer_name": name,
|
|
54
|
+
"files": len(manifest.files),
|
|
55
|
+
"total_bytes": manifest.total_bytes,
|
|
56
|
+
}
|
|
57
|
+
return code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def receive(
|
|
61
|
+
code: str,
|
|
62
|
+
output_dir: str = "./received",
|
|
63
|
+
*,
|
|
64
|
+
progress: Optional[Callable[[str, int, int], None]] = None,
|
|
65
|
+
) -> Dict[str, Any]:
|
|
66
|
+
"""Receive a transfer using a code.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
code: The transfer code (e.g., "38-vortex-xenon").
|
|
70
|
+
output_dir: Directory to save received files.
|
|
71
|
+
progress: Optional callback(file_path, segment_index, total_segments).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with transfer details: transfer_name, files, total_bytes, file_hashes.
|
|
75
|
+
"""
|
|
76
|
+
config = get_config()
|
|
77
|
+
k_data, k_index = crypto.derive_keys(code)
|
|
78
|
+
r_hash = crypto.relay_hash(k_index)
|
|
79
|
+
|
|
80
|
+
# Download encrypted manifest from relay
|
|
81
|
+
encrypted_manifest = _download_manifest(config.relay_url, r_hash)
|
|
82
|
+
manifest_json = crypto.decrypt_segment(k_data, encrypted_manifest)
|
|
83
|
+
manifest = Manifest.from_json(manifest_json)
|
|
84
|
+
|
|
85
|
+
# Pre-allocate files
|
|
86
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
87
|
+
for f in manifest.files:
|
|
88
|
+
file_path = os.path.join(output_dir, f.path)
|
|
89
|
+
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
|
90
|
+
with open(file_path, "wb") as fh:
|
|
91
|
+
fh.truncate(f.size)
|
|
92
|
+
|
|
93
|
+
# Download and reconstruct each file
|
|
94
|
+
registry = ValidatorRegistry()
|
|
95
|
+
file_hashes = {}
|
|
96
|
+
|
|
97
|
+
for f in manifest.files:
|
|
98
|
+
file_path = os.path.join(output_dir, f.path)
|
|
99
|
+
total_segments = len(f.segments)
|
|
100
|
+
|
|
101
|
+
for seg_idx, segment in enumerate(f.segments):
|
|
102
|
+
# Download shards
|
|
103
|
+
shard_data = _download_shards(segment)
|
|
104
|
+
|
|
105
|
+
# Reconstruct encrypted segment
|
|
106
|
+
encrypted = erasure.decode_segment(shard_data, segment.encrypted_size)
|
|
107
|
+
|
|
108
|
+
# Decrypt
|
|
109
|
+
plaintext = crypto.decrypt_segment(k_data, encrypted)
|
|
110
|
+
|
|
111
|
+
# Validate first segment
|
|
112
|
+
if seg_idx == 0:
|
|
113
|
+
validator = registry.validator_for(f.path)
|
|
114
|
+
validator.validate_first_segment(plaintext)
|
|
115
|
+
|
|
116
|
+
# Write to file at offset
|
|
117
|
+
offset = seg_idx * config.segment_size
|
|
118
|
+
with open(file_path, "r+b") as fh:
|
|
119
|
+
fh.seek(offset)
|
|
120
|
+
fh.write(plaintext)
|
|
121
|
+
|
|
122
|
+
if progress:
|
|
123
|
+
progress(f.path, seg_idx + 1, total_segments)
|
|
124
|
+
|
|
125
|
+
# Compute file hash
|
|
126
|
+
file_hashes[f.path] = _compute_file_hash(file_path)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"transfer_name": manifest.transfer_name,
|
|
130
|
+
"files": len(manifest.files),
|
|
131
|
+
"total_bytes": manifest.total_bytes,
|
|
132
|
+
"file_hashes": file_hashes,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _send_directory(dir_path: str, name: str, k_data: bytes, config) -> Manifest:
|
|
137
|
+
"""Send a directory."""
|
|
138
|
+
manifest = build_manifest(dir_path, name, config.segment_size)
|
|
139
|
+
manifest.erasure_data_shards = config.data_shards
|
|
140
|
+
manifest.erasure_parity_shards = config.parity_shards
|
|
141
|
+
|
|
142
|
+
for f in manifest.files:
|
|
143
|
+
file_path = os.path.join(dir_path, f.path)
|
|
144
|
+
for seg in f.segments:
|
|
145
|
+
seg_idx = int(seg.id.split("_")[1])
|
|
146
|
+
plaintext = read_file_segment(file_path, seg_idx, config.segment_size)
|
|
147
|
+
encrypted = crypto.encrypt_segment(k_data, plaintext)
|
|
148
|
+
shards = erasure.encode_segment(encrypted)
|
|
149
|
+
|
|
150
|
+
shard_infos = []
|
|
151
|
+
for shard_idx, shard in enumerate(shards):
|
|
152
|
+
h = crypto.content_hash(shard)
|
|
153
|
+
node = config.storage_nodes[shard_idx % len(config.storage_nodes)]
|
|
154
|
+
_upload_shard(node, h, shard)
|
|
155
|
+
shard_infos.append(ManifestShard(
|
|
156
|
+
index=shard_idx, hash=h, node=node,
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
seg.encrypted_size = len(encrypted)
|
|
160
|
+
seg.shards = shard_infos
|
|
161
|
+
|
|
162
|
+
return manifest
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _send_single_file(file_path: str, name: str, k_data: bytes, config) -> Manifest:
|
|
166
|
+
"""Send a single file by treating it as a one-file directory."""
|
|
167
|
+
dir_path = os.path.dirname(file_path) or "."
|
|
168
|
+
filename = os.path.basename(file_path)
|
|
169
|
+
manifest = build_manifest(dir_path, name, config.segment_size)
|
|
170
|
+
manifest.erasure_data_shards = config.data_shards
|
|
171
|
+
manifest.erasure_parity_shards = config.parity_shards
|
|
172
|
+
|
|
173
|
+
# Filter to only the requested file
|
|
174
|
+
manifest.files = [f for f in manifest.files if f.path == filename]
|
|
175
|
+
manifest.total_bytes = sum(f.size for f in manifest.files)
|
|
176
|
+
|
|
177
|
+
for f in manifest.files:
|
|
178
|
+
full_path = os.path.join(dir_path, f.path)
|
|
179
|
+
for seg in f.segments:
|
|
180
|
+
seg_idx = int(seg.id.split("_")[1])
|
|
181
|
+
plaintext = read_file_segment(full_path, seg_idx, config.segment_size)
|
|
182
|
+
encrypted = crypto.encrypt_segment(k_data, plaintext)
|
|
183
|
+
shards = erasure.encode_segment(encrypted)
|
|
184
|
+
|
|
185
|
+
shard_infos = []
|
|
186
|
+
for shard_idx, shard in enumerate(shards):
|
|
187
|
+
h = crypto.content_hash(shard)
|
|
188
|
+
node = config.storage_nodes[shard_idx % len(config.storage_nodes)]
|
|
189
|
+
_upload_shard(node, h, shard)
|
|
190
|
+
shard_infos.append(ManifestShard(
|
|
191
|
+
index=shard_idx, hash=h, node=node,
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
seg.encrypted_size = len(encrypted)
|
|
195
|
+
seg.shards = shard_infos
|
|
196
|
+
|
|
197
|
+
return manifest
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _upload_shard(node_url: str, hash_hex: str, data: bytes) -> None:
|
|
201
|
+
"""Upload a shard to a storage node."""
|
|
202
|
+
url = f"{node_url}/shard/{hash_hex}"
|
|
203
|
+
resp = requests.put(url, data=data, headers={"Content-Type": "application/octet-stream"})
|
|
204
|
+
if not resp.status_code in (200, 201):
|
|
205
|
+
raise RuntimeError(f"shard upload to {node_url} returned {resp.status_code}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _upload_manifest(relay_url: str, r_hash: str, data: bytes) -> None:
|
|
209
|
+
"""Upload encrypted manifest to relay."""
|
|
210
|
+
url = f"{relay_url}/manifest/{r_hash}"
|
|
211
|
+
resp = requests.put(url, data=data, headers={"Content-Type": "application/octet-stream"})
|
|
212
|
+
if not resp.status_code in (200, 201):
|
|
213
|
+
raise RuntimeError(f"manifest upload returned {resp.status_code}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _download_manifest(relay_url: str, r_hash: str) -> bytes:
|
|
217
|
+
"""Download encrypted manifest from relay (one-time retrieval)."""
|
|
218
|
+
url = f"{relay_url}/manifest/{r_hash}"
|
|
219
|
+
resp = requests.get(url)
|
|
220
|
+
if resp.status_code != 200:
|
|
221
|
+
raise RuntimeError(f"manifest download returned {resp.status_code}")
|
|
222
|
+
return resp.content
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _download_shards(segment: ManifestSegment) -> List[Optional[bytes]]:
|
|
226
|
+
"""Download all shards for a segment concurrently."""
|
|
227
|
+
config = get_config()
|
|
228
|
+
total_shards = config.data_shards + config.parity_shards
|
|
229
|
+
shards: List[Optional[bytes]] = [None] * total_shards
|
|
230
|
+
|
|
231
|
+
def fetch_shard(shard: ManifestShard) -> tuple[int, Optional[bytes]]:
|
|
232
|
+
url = f"{shard.node}/shard/{shard.hash}"
|
|
233
|
+
try:
|
|
234
|
+
resp = requests.get(url)
|
|
235
|
+
if resp.status_code == 200:
|
|
236
|
+
return shard.index, resp.content
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
return shard.index, None
|
|
240
|
+
|
|
241
|
+
with ThreadPoolExecutor(max_workers=12) as executor:
|
|
242
|
+
futures = [executor.submit(fetch_shard, s) for s in segment.shards]
|
|
243
|
+
for future in as_completed(futures):
|
|
244
|
+
idx, data = future.result()
|
|
245
|
+
if data is not None:
|
|
246
|
+
shards[idx] = data
|
|
247
|
+
|
|
248
|
+
return shards
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _compute_file_hash(filepath: str) -> str:
|
|
252
|
+
"""Compute BLAKE3 hash of a file."""
|
|
253
|
+
import blake3
|
|
254
|
+
h = blake3.blake3()
|
|
255
|
+
with open(filepath, "rb") as f:
|
|
256
|
+
while True:
|
|
257
|
+
chunk = f.read(1024 * 1024)
|
|
258
|
+
if not chunk:
|
|
259
|
+
break
|
|
260
|
+
h.update(chunk)
|
|
261
|
+
return h.hexdigest()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_WORDS = [
|
|
265
|
+
"orbit", "velvet", "guitar", "battery", "candle", "dragon", "ember",
|
|
266
|
+
"falcon", "galaxy", "harbor", "iris", "jungle", "kettle", "lantern",
|
|
267
|
+
"meadow", "nebula", "ocean", "phoenix", "quartz", "raven", "silver",
|
|
268
|
+
"thunder", "umbra", "vortex", "willow", "xenon", "yonder", "zephyr",
|
|
269
|
+
"amber", "breeze",
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _generate_code() -> str:
|
|
274
|
+
"""Generate a human-readable transfer code: number-word-word."""
|
|
275
|
+
num = random.randint(1, 99)
|
|
276
|
+
w1 = random.choice(_WORDS)
|
|
277
|
+
w2 = random.choice(_WORDS)
|
|
278
|
+
return f"{num}-{w1}-{w2}"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Configuration for Help Peer SDK."""
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
"""SDK configuration. Modify via helppeer.configure()."""
|
|
10
|
+
relay_url: str = os.environ.get("HELPEER_RELAY_URL", "http://127.0.0.1:7000")
|
|
11
|
+
storage_nodes: List[str] = field(default_factory=lambda: [
|
|
12
|
+
n.strip() for n in os.environ.get(
|
|
13
|
+
"HELPEER_STORAGE_NODES", "http://127.0.0.1:7001"
|
|
14
|
+
).split(",") if n.strip()
|
|
15
|
+
])
|
|
16
|
+
segment_size: int = 64 * 1024 * 1024 # 64 MB
|
|
17
|
+
data_shards: int = 8
|
|
18
|
+
parity_shards: int = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_default_config = Config()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config() -> Config:
|
|
25
|
+
return _default_config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure(
|
|
29
|
+
relay: str = None,
|
|
30
|
+
storage_nodes: List[str] = None,
|
|
31
|
+
segment_size: int = None,
|
|
32
|
+
data_shards: int = None,
|
|
33
|
+
parity_shards: int = None,
|
|
34
|
+
):
|
|
35
|
+
"""Configure the SDK. Only provided values are updated."""
|
|
36
|
+
if relay is not None:
|
|
37
|
+
_default_config.relay_url = relay
|
|
38
|
+
if storage_nodes is not None:
|
|
39
|
+
_default_config.storage_nodes = storage_nodes
|
|
40
|
+
if segment_size is not None:
|
|
41
|
+
_default_config.segment_size = segment_size
|
|
42
|
+
if data_shards is not None:
|
|
43
|
+
_default_config.data_shards = data_shards
|
|
44
|
+
if parity_shards is not None:
|
|
45
|
+
_default_config.parity_shards = parity_shards
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Cryptographic operations for Help Peer."""
|
|
2
|
+
import os
|
|
3
|
+
import struct
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import hkdf
|
|
7
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
8
|
+
|
|
9
|
+
NONCE_SIZE = 12
|
|
10
|
+
KEY_SIZE = 32
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_keys(code: str) -> tuple[bytes, bytes]:
|
|
14
|
+
"""Derive K_data and K_index from a transfer code using HKDF-SHA256."""
|
|
15
|
+
ikm = code.encode("utf-8")
|
|
16
|
+
k_data = hkdf.Hkdf(None, ikm, hashlib.sha256).expand(b"help-peer-data-key", KEY_SIZE)
|
|
17
|
+
k_index = hkdf.Hkdf(None, ikm, hashlib.sha256).expand(b"help-peer-index-key", KEY_SIZE)
|
|
18
|
+
return k_data, k_index
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def relay_hash(k_index: bytes) -> str:
|
|
22
|
+
"""Compute the relay hash: hex(BLAKE3(K_index))."""
|
|
23
|
+
import blake3
|
|
24
|
+
return blake3.blake3(k_index).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encrypt_segment(k_data: bytes, plaintext: bytes) -> bytes:
|
|
28
|
+
"""Encrypt a segment with AES-256-GCM. Returns nonce || ciphertext."""
|
|
29
|
+
nonce = os.urandom(NONCE_SIZE)
|
|
30
|
+
aesgcm = AESGCM(k_data)
|
|
31
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
|
32
|
+
return nonce + ciphertext
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decrypt_segment(k_data: bytes, encrypted: bytes) -> bytes:
|
|
36
|
+
"""Decrypt a segment (nonce || ciphertext) with AES-256-GCM."""
|
|
37
|
+
if len(encrypted) < NONCE_SIZE:
|
|
38
|
+
raise ValueError("encrypted data too short")
|
|
39
|
+
nonce = encrypted[:NONCE_SIZE]
|
|
40
|
+
ciphertext = encrypted[NONCE_SIZE:]
|
|
41
|
+
aesgcm = AESGCM(k_data)
|
|
42
|
+
return aesgcm.decrypt(nonce, ciphertext, None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def content_hash(data: bytes) -> str:
|
|
46
|
+
"""Compute BLAKE3 hash of data, return hex string."""
|
|
47
|
+
import blake3
|
|
48
|
+
return blake3.blake3(data).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def shard_hmac(k_data: bytes, shard: bytes) -> bytes:
|
|
52
|
+
"""Compute HMAC-SHA256 of shard data."""
|
|
53
|
+
return hmac.new(k_data, shard, hashlib.sha256).digest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify_shard_hmac(k_data: bytes, shard: bytes, expected: bytes) -> bool:
|
|
57
|
+
"""Verify shard HMAC."""
|
|
58
|
+
actual = shard_hmac(k_data, shard)
|
|
59
|
+
return hmac.compare_digest(actual, expected)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Reed-Solomon erasure coding for Help Peer."""
|
|
2
|
+
from reedsolo import RSCodec
|
|
3
|
+
|
|
4
|
+
from .config import get_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def encode_segment(data: bytes) -> list[bytes]:
|
|
8
|
+
"""Split an encrypted segment into (8 data + 4 parity) shards."""
|
|
9
|
+
config = get_config()
|
|
10
|
+
return _encode_interleaved(data, config.data_shards, config.parity_shards)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _encode_interleaved(data: bytes, data_shards: int, parity_shards: int) -> list[bytes]:
|
|
14
|
+
"""Encode data using interleaved Reed-Solomon: split into data_shards pieces,
|
|
15
|
+
compute parity_shards parity pieces, return data_shards + parity_shards shards."""
|
|
16
|
+
shard_size = (len(data) + data_shards - 1) // data_shards
|
|
17
|
+
# Pad data to fill all data shards
|
|
18
|
+
padded = data + b"\x00" * (shard_size * data_shards - len(data))
|
|
19
|
+
|
|
20
|
+
# Split into data shards
|
|
21
|
+
data_shards_list = [
|
|
22
|
+
padded[i * shard_size:(i + 1) * shard_size]
|
|
23
|
+
for i in range(data_shards)
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# For each byte position, compute parity across data_shards bytes
|
|
27
|
+
rs = RSCodec(parity_shards, nsize=data_shards + parity_shards)
|
|
28
|
+
|
|
29
|
+
parity_shards_list = [bytearray(shard_size) for _ in range(parity_shards)]
|
|
30
|
+
|
|
31
|
+
for pos in range(shard_size):
|
|
32
|
+
# Gather one byte from each data shard
|
|
33
|
+
data_bytes = bytes(data_shards_list[i][pos] for i in range(data_shards))
|
|
34
|
+
# Encode: produces data_bytes + parity_bytes
|
|
35
|
+
encoded = rs.encode(data_bytes)
|
|
36
|
+
# Extract parity bytes
|
|
37
|
+
for j in range(parity_shards):
|
|
38
|
+
parity_shards_list[j][pos] = encoded[data_shards + j]
|
|
39
|
+
|
|
40
|
+
return data_shards_list + [bytes(p) for p in parity_shards_list]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode_segment(shards: list[bytes | None], original_len: int) -> bytes:
|
|
44
|
+
"""Reconstruct the original segment from at least data_shards of total_shards shards.
|
|
45
|
+
Missing shards should be None."""
|
|
46
|
+
config = get_config()
|
|
47
|
+
data_shards = config.data_shards
|
|
48
|
+
parity_shards = config.parity_shards
|
|
49
|
+
total_shards = data_shards + parity_shards
|
|
50
|
+
|
|
51
|
+
if len(shards) != total_shards:
|
|
52
|
+
raise ValueError(f"expected {total_shards} shards, got {len(shards)}")
|
|
53
|
+
|
|
54
|
+
# Find shard size from available shards
|
|
55
|
+
shard_size = None
|
|
56
|
+
for s in shards:
|
|
57
|
+
if s is not None:
|
|
58
|
+
shard_size = len(s)
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
if shard_size is None:
|
|
62
|
+
raise ValueError("no shards available")
|
|
63
|
+
|
|
64
|
+
# Count available shards
|
|
65
|
+
available = sum(1 for s in shards if s is not None)
|
|
66
|
+
if available < data_shards:
|
|
67
|
+
raise ValueError(f"insufficient shards: have {available}, need {data_shards}")
|
|
68
|
+
|
|
69
|
+
rs = RSCodec(parity_shards, nsize=data_shards + parity_shards)
|
|
70
|
+
|
|
71
|
+
# For each byte position, reconstruct missing bytes
|
|
72
|
+
data_shards_list = [None] * data_shards
|
|
73
|
+
parity_shards_list = [None] * parity_shards
|
|
74
|
+
|
|
75
|
+
for i in range(total_shards):
|
|
76
|
+
if shards[i] is not None:
|
|
77
|
+
if i < data_shards:
|
|
78
|
+
data_shards_list[i] = shards[i]
|
|
79
|
+
else:
|
|
80
|
+
parity_shards_list[i - data_shards] = shards[i]
|
|
81
|
+
|
|
82
|
+
result_shards = [bytearray(shard_size) for _ in range(data_shards)]
|
|
83
|
+
|
|
84
|
+
for pos in range(shard_size):
|
|
85
|
+
# Build the received codeword for this byte position
|
|
86
|
+
received = [None] * total_shards
|
|
87
|
+
for i in range(data_shards):
|
|
88
|
+
if data_shards_list[i] is not None:
|
|
89
|
+
received[i] = data_shards_list[i][pos]
|
|
90
|
+
for j in range(parity_shards):
|
|
91
|
+
if parity_shards_list[j] is not None:
|
|
92
|
+
received[data_shards + j] = parity_shards_list[j][pos]
|
|
93
|
+
|
|
94
|
+
# Convert to list with 0 for missing (erasure positions)
|
|
95
|
+
codeword = []
|
|
96
|
+
erasure_pos = []
|
|
97
|
+
for i in range(total_shards):
|
|
98
|
+
if received[i] is not None:
|
|
99
|
+
codeword.append(received[i])
|
|
100
|
+
else:
|
|
101
|
+
codeword.append(0)
|
|
102
|
+
erasure_pos.append(i)
|
|
103
|
+
|
|
104
|
+
if erasure_pos:
|
|
105
|
+
# Decode with erasures
|
|
106
|
+
try:
|
|
107
|
+
decoded = rs.decode(bytearray(codeword), erase_pos=erasure_pos)
|
|
108
|
+
# reedsolo returns (msg, full_corrected_codeword) when there are erasures
|
|
109
|
+
if isinstance(decoded, tuple):
|
|
110
|
+
full_codeword = decoded[1]
|
|
111
|
+
else:
|
|
112
|
+
full_codeword = decoded
|
|
113
|
+
# Extract corrected data bytes
|
|
114
|
+
for i in range(data_shards):
|
|
115
|
+
result_shards[i][pos] = full_codeword[i]
|
|
116
|
+
except Exception:
|
|
117
|
+
# If decode fails, use available data bytes directly
|
|
118
|
+
for i in range(data_shards):
|
|
119
|
+
if data_shards_list[i] is not None:
|
|
120
|
+
result_shards[i][pos] = data_shards_list[i][pos]
|
|
121
|
+
else:
|
|
122
|
+
result_shards[i][pos] = 0
|
|
123
|
+
else:
|
|
124
|
+
# No erasures, just copy data bytes
|
|
125
|
+
for i in range(data_shards):
|
|
126
|
+
result_shards[i][pos] = codeword[i]
|
|
127
|
+
|
|
128
|
+
# Concatenate data shards and trim to original length
|
|
129
|
+
result = b"".join(bytes(s) for s in result_shards)
|
|
130
|
+
return result[:original_len]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Manifest building and parsing for Help Peer."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field, asdict
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ManifestShard:
|
|
10
|
+
index: int
|
|
11
|
+
hash: str
|
|
12
|
+
node: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ManifestSegment:
|
|
17
|
+
id: str
|
|
18
|
+
original_size: int
|
|
19
|
+
encrypted_size: int
|
|
20
|
+
shards: List[ManifestShard] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ManifestFile:
|
|
25
|
+
path: str
|
|
26
|
+
size: int
|
|
27
|
+
segments: List[ManifestSegment] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Manifest:
|
|
32
|
+
version: int = 1
|
|
33
|
+
transfer_name: str = ""
|
|
34
|
+
total_bytes: int = 0
|
|
35
|
+
segment_size: int = 67108864
|
|
36
|
+
erasure_data_shards: int = 8
|
|
37
|
+
erasure_parity_shards: int = 4
|
|
38
|
+
files: List[ManifestFile] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> bytes:
|
|
41
|
+
return json.dumps({
|
|
42
|
+
"version": self.version,
|
|
43
|
+
"transfer_name": self.transfer_name,
|
|
44
|
+
"total_bytes": self.total_bytes,
|
|
45
|
+
"segment_size": self.segment_size,
|
|
46
|
+
"erasure_data_shards": self.erasure_data_shards,
|
|
47
|
+
"erasure_parity_shards": self.erasure_parity_shards,
|
|
48
|
+
"files": [
|
|
49
|
+
{
|
|
50
|
+
"path": f.path,
|
|
51
|
+
"size": f.size,
|
|
52
|
+
"segments": [
|
|
53
|
+
{
|
|
54
|
+
"id": s.id,
|
|
55
|
+
"original_size": s.original_size,
|
|
56
|
+
"encrypted_size": s.encrypted_size,
|
|
57
|
+
"shards": [
|
|
58
|
+
{"index": sh.index, "hash": sh.hash, "node": sh.node}
|
|
59
|
+
for sh in s.shards
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
for s in f.segments
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
for f in self.files
|
|
66
|
+
],
|
|
67
|
+
}).encode("utf-8")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_json(cls, data: bytes) -> "Manifest":
|
|
71
|
+
d = json.loads(data)
|
|
72
|
+
manifest = cls(
|
|
73
|
+
version=d["version"],
|
|
74
|
+
transfer_name=d["transfer_name"],
|
|
75
|
+
total_bytes=d["total_bytes"],
|
|
76
|
+
segment_size=d["segment_size"],
|
|
77
|
+
erasure_data_shards=d["erasure_data_shards"],
|
|
78
|
+
erasure_parity_shards=d["erasure_parity_shards"],
|
|
79
|
+
)
|
|
80
|
+
for f in d["files"]:
|
|
81
|
+
mf = ManifestFile(path=f["path"], size=f["size"])
|
|
82
|
+
for s in f["segments"]:
|
|
83
|
+
ms = ManifestSegment(
|
|
84
|
+
id=s["id"],
|
|
85
|
+
original_size=s["original_size"],
|
|
86
|
+
encrypted_size=s["encrypted_size"],
|
|
87
|
+
)
|
|
88
|
+
for sh in s["shards"]:
|
|
89
|
+
ms.shards.append(ManifestShard(
|
|
90
|
+
index=sh["index"], hash=sh["hash"], node=sh["node"]
|
|
91
|
+
))
|
|
92
|
+
mf.segments.append(ms)
|
|
93
|
+
manifest.files.append(mf)
|
|
94
|
+
return manifest
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_manifest(dir_path: str, transfer_name: str, segment_size: int) -> Manifest:
|
|
98
|
+
"""Build a manifest by crawling a directory."""
|
|
99
|
+
manifest = Manifest(
|
|
100
|
+
transfer_name=transfer_name,
|
|
101
|
+
segment_size=segment_size,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for root, dirs, files in os.walk(dir_path):
|
|
105
|
+
dirs.sort()
|
|
106
|
+
for filename in sorted(files):
|
|
107
|
+
filepath = os.path.join(root, filename)
|
|
108
|
+
rel_path = os.path.relpath(filepath, dir_path)
|
|
109
|
+
|
|
110
|
+
size = os.path.getsize(filepath)
|
|
111
|
+
manifest.total_bytes += size
|
|
112
|
+
|
|
113
|
+
num_segments = 0
|
|
114
|
+
if size > 0:
|
|
115
|
+
num_segments = (size + segment_size - 1) // segment_size
|
|
116
|
+
|
|
117
|
+
segments = []
|
|
118
|
+
for i in range(num_segments):
|
|
119
|
+
seg_size = min(segment_size, size - i * segment_size)
|
|
120
|
+
segments.append(ManifestSegment(
|
|
121
|
+
id=f"seg_{i:06d}",
|
|
122
|
+
original_size=seg_size,
|
|
123
|
+
encrypted_size=0,
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
manifest.files.append(ManifestFile(
|
|
127
|
+
path=rel_path,
|
|
128
|
+
size=size,
|
|
129
|
+
segments=segments,
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
return manifest
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_file_segment(filepath: str, segment_index: int, segment_size: int) -> bytes:
|
|
136
|
+
"""Read a specific segment from a file."""
|
|
137
|
+
with open(filepath, "rb") as f:
|
|
138
|
+
f.seek(segment_index * segment_size)
|
|
139
|
+
return f.read(segment_size)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Pluggable file validators for Help Peer."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ValidationError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileValidator(Protocol):
|
|
11
|
+
def file_extensions(self) -> list[str]: ...
|
|
12
|
+
def validate_first_segment(self, data: bytes) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SafetensorsValidator:
|
|
16
|
+
"""Validates safetensors files by parsing the JSON header."""
|
|
17
|
+
|
|
18
|
+
def file_extensions(self) -> list[str]:
|
|
19
|
+
return ["safetensors"]
|
|
20
|
+
|
|
21
|
+
def validate_first_segment(self, data: bytes) -> None:
|
|
22
|
+
if len(data) < 8:
|
|
23
|
+
raise ValidationError("file too small to be a valid safetensors file")
|
|
24
|
+
|
|
25
|
+
header_len = int.from_bytes(data[:8], "little")
|
|
26
|
+
|
|
27
|
+
if header_len > 100_000_000:
|
|
28
|
+
raise ValidationError(f"safetensors header length {header_len} is unreasonably large")
|
|
29
|
+
|
|
30
|
+
if len(data) < 8 + header_len:
|
|
31
|
+
raise ValidationError("safetensors header extends beyond first segment")
|
|
32
|
+
|
|
33
|
+
header_json = data[8:8 + header_len]
|
|
34
|
+
try:
|
|
35
|
+
parsed = json.loads(header_json)
|
|
36
|
+
except json.JSONDecodeError as e:
|
|
37
|
+
raise ValidationError(f"safetensors header is not valid JSON: {e}")
|
|
38
|
+
|
|
39
|
+
if not isinstance(parsed, dict):
|
|
40
|
+
raise ValidationError("safetensors header is not a JSON object")
|
|
41
|
+
|
|
42
|
+
if len(parsed) == 0:
|
|
43
|
+
raise ValidationError("safetensors header is empty")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GenericValidator:
|
|
47
|
+
"""Passthrough validator that accepts all data."""
|
|
48
|
+
|
|
49
|
+
def file_extensions(self) -> list[str]:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
def validate_first_segment(self, data: bytes) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ValidatorRegistry:
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.validators: list[FileValidator] = [SafetensorsValidator()]
|
|
59
|
+
self.fallback: FileValidator = GenericValidator()
|
|
60
|
+
|
|
61
|
+
def validator_for(self, path: str) -> FileValidator:
|
|
62
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
63
|
+
for v in self.validators:
|
|
64
|
+
if ext in v.file_extensions():
|
|
65
|
+
return v
|
|
66
|
+
return self.fallback
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: helppeer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous ephemeral file transfer for AI model weights
|
|
5
|
+
Author: JenD86
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/JenD86/help-peer
|
|
8
|
+
Project-URL: Repository, https://github.com/JenD86/help-peer
|
|
9
|
+
Project-URL: Issues, https://github.com/JenD86/help-peer/issues
|
|
10
|
+
Keywords: file-transfer,ai,model-weights,safetensors,p2p,decentralized,ephemeral
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: cryptography>=41.0
|
|
24
|
+
Requires-Dist: blake3>=0.3
|
|
25
|
+
Requires-Dist: requests>=2.28
|
|
26
|
+
Requires-Dist: reedsolo>=1.7
|
|
27
|
+
Requires-Dist: hkdf>=0.0.3
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Help Peer Python SDK
|
|
32
|
+
|
|
33
|
+
A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install helppeer
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import helppeer
|
|
45
|
+
|
|
46
|
+
# Send a model directory
|
|
47
|
+
code = helppeer.send("./my-model", name="Llama-3-70B")
|
|
48
|
+
print(f"Transfer code: {code}")
|
|
49
|
+
# Share this code with the recipient. Data lives for 24 hours.
|
|
50
|
+
|
|
51
|
+
# Receive a transfer
|
|
52
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import helppeer
|
|
59
|
+
|
|
60
|
+
# Use custom relay and storage nodes
|
|
61
|
+
helppeer.configure(
|
|
62
|
+
relay="https://relay.helppeer.dev",
|
|
63
|
+
storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Or use environment variables:
|
|
67
|
+
# HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Agent Integration
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import helppeer
|
|
74
|
+
import json
|
|
75
|
+
|
|
76
|
+
# Send and get structured result
|
|
77
|
+
result = helppeer.send("./model-weights", name="my-model", return_details=True)
|
|
78
|
+
# result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
|
|
79
|
+
|
|
80
|
+
# Receive with progress callback
|
|
81
|
+
def on_progress(file_path, segment, total_segments):
|
|
82
|
+
print(f" {file_path}: {segment}/{total_segments} segments")
|
|
83
|
+
|
|
84
|
+
helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## CLI
|
|
88
|
+
|
|
89
|
+
The SDK also installs a `helppeer` CLI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
helppeer send ./my-model --name "Llama-3-70B"
|
|
93
|
+
helppeer receive 38-vortex-xenon --output ./received
|
|
94
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/helppeer/__init__.py
|
|
4
|
+
src/helppeer/__main__.py
|
|
5
|
+
src/helppeer/cli.py
|
|
6
|
+
src/helppeer/client.py
|
|
7
|
+
src/helppeer/config.py
|
|
8
|
+
src/helppeer/crypto.py
|
|
9
|
+
src/helppeer/erasure.py
|
|
10
|
+
src/helppeer/manifest.py
|
|
11
|
+
src/helppeer/validator.py
|
|
12
|
+
src/helppeer.egg-info/PKG-INFO
|
|
13
|
+
src/helppeer.egg-info/SOURCES.txt
|
|
14
|
+
src/helppeer.egg-info/dependency_links.txt
|
|
15
|
+
src/helppeer.egg-info/entry_points.txt
|
|
16
|
+
src/helppeer.egg-info/requires.txt
|
|
17
|
+
src/helppeer.egg-info/top_level.txt
|
|
18
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
helppeer
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tests for Help Peer SDK."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
import tempfile
|
|
6
|
+
import shutil
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from helppeer import crypto, erasure, manifest, validator
|
|
10
|
+
from helppeer.config import configure, get_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestCrypto:
|
|
14
|
+
def test_encrypt_decrypt_roundtrip(self):
|
|
15
|
+
k_data = b"\x00" * 32
|
|
16
|
+
plaintext = b"Hello, Help Peer!"
|
|
17
|
+
encrypted = crypto.encrypt_segment(k_data, plaintext)
|
|
18
|
+
decrypted = crypto.decrypt_segment(k_data, encrypted)
|
|
19
|
+
assert plaintext == decrypted
|
|
20
|
+
|
|
21
|
+
def test_key_derivation_consistency(self):
|
|
22
|
+
k1_data, k1_index = crypto.derive_keys("7-orbit-velvet")
|
|
23
|
+
k2_data, k2_index = crypto.derive_keys("7-orbit-velvet")
|
|
24
|
+
assert k1_data == k2_data
|
|
25
|
+
assert k1_index == k2_index
|
|
26
|
+
|
|
27
|
+
k3_data, _ = crypto.derive_keys("8-orbit-velvet")
|
|
28
|
+
assert k1_data != k3_data
|
|
29
|
+
|
|
30
|
+
def test_relay_hash_consistency(self):
|
|
31
|
+
_, k_index = crypto.derive_keys("7-orbit-velvet")
|
|
32
|
+
h1 = crypto.relay_hash(k_index)
|
|
33
|
+
h2 = crypto.relay_hash(k_index)
|
|
34
|
+
assert h1 == h2
|
|
35
|
+
assert len(h1) == 64 # 32 bytes hex
|
|
36
|
+
|
|
37
|
+
def test_hmac_verification(self):
|
|
38
|
+
k_data = b"\x00" * 32
|
|
39
|
+
shard = b"some shard data"
|
|
40
|
+
h = crypto.shard_hmac(k_data, shard)
|
|
41
|
+
assert crypto.verify_shard_hmac(k_data, shard, h)
|
|
42
|
+
assert not crypto.verify_shard_hmac(k_data, b"tampered", h)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestErasure:
|
|
46
|
+
def test_encode_decode_roundtrip(self):
|
|
47
|
+
data = b"Hello, Help Peer! This is a test segment for erasure coding."
|
|
48
|
+
shards = erasure.encode_segment(data)
|
|
49
|
+
assert len(shards) == 12
|
|
50
|
+
|
|
51
|
+
shard_opts = list(shards)
|
|
52
|
+
reconstructed = erasure.decode_segment(shard_opts, len(data))
|
|
53
|
+
assert data == reconstructed
|
|
54
|
+
|
|
55
|
+
def test_decode_with_missing_parity(self):
|
|
56
|
+
data = b"Test data for erasure coding with missing shards!"
|
|
57
|
+
shards = erasure.encode_segment(data)
|
|
58
|
+
|
|
59
|
+
# Drop 4 parity shards
|
|
60
|
+
shard_opts = list(shards)
|
|
61
|
+
for i in range(8, 12):
|
|
62
|
+
shard_opts[i] = None
|
|
63
|
+
reconstructed = erasure.decode_segment(shard_opts, len(data))
|
|
64
|
+
assert data == reconstructed
|
|
65
|
+
|
|
66
|
+
def test_decode_with_missing_data_shards(self):
|
|
67
|
+
data = b"Test data for erasure coding with some data shards missing!"
|
|
68
|
+
shards = erasure.encode_segment(data)
|
|
69
|
+
|
|
70
|
+
# Drop 2 data + 2 parity
|
|
71
|
+
shard_opts = list(shards)
|
|
72
|
+
shard_opts[1] = None
|
|
73
|
+
shard_opts[3] = None
|
|
74
|
+
shard_opts[9] = None
|
|
75
|
+
shard_opts[11] = None
|
|
76
|
+
reconstructed = erasure.decode_segment(shard_opts, len(data))
|
|
77
|
+
assert data == reconstructed
|
|
78
|
+
|
|
79
|
+
def test_decode_insufficient_shards(self):
|
|
80
|
+
data = b"Too many missing shards!"
|
|
81
|
+
shards = erasure.encode_segment(data)
|
|
82
|
+
|
|
83
|
+
shard_opts = list(shards)
|
|
84
|
+
for i in range(5):
|
|
85
|
+
shard_opts[i] = None
|
|
86
|
+
|
|
87
|
+
with pytest.raises(ValueError, match="insufficient"):
|
|
88
|
+
erasure.decode_segment(shard_opts, len(data))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestManifest:
|
|
92
|
+
def test_build_from_dir(self, tmp_path):
|
|
93
|
+
(tmp_path / "config.json").write_text("{}")
|
|
94
|
+
(tmp_path / "model.safetensors").write_text("weights")
|
|
95
|
+
|
|
96
|
+
m = manifest.build_manifest(str(tmp_path), "test-transfer", 67108864)
|
|
97
|
+
assert m.transfer_name == "test-transfer"
|
|
98
|
+
assert m.version == 1
|
|
99
|
+
assert len(m.files) == 2
|
|
100
|
+
assert m.total_bytes > 0
|
|
101
|
+
|
|
102
|
+
def test_json_roundtrip(self):
|
|
103
|
+
m = manifest.Manifest(
|
|
104
|
+
transfer_name="test",
|
|
105
|
+
total_bytes=100,
|
|
106
|
+
segment_size=67108864,
|
|
107
|
+
files=[
|
|
108
|
+
manifest.ManifestFile(
|
|
109
|
+
path="test.txt",
|
|
110
|
+
size=100,
|
|
111
|
+
segments=[
|
|
112
|
+
manifest.ManifestSegment(
|
|
113
|
+
id="seg_000000",
|
|
114
|
+
original_size=100,
|
|
115
|
+
encrypted_size=0,
|
|
116
|
+
)
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
json_bytes = m.to_json()
|
|
122
|
+
restored = manifest.Manifest.from_json(json_bytes)
|
|
123
|
+
assert restored.transfer_name == "test"
|
|
124
|
+
assert len(restored.files) == 1
|
|
125
|
+
assert restored.files[0].path == "test.txt"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestValidator:
|
|
129
|
+
def test_safetensors_valid(self):
|
|
130
|
+
header = json.dumps({
|
|
131
|
+
"__metadata__": {"format": "pt"},
|
|
132
|
+
"weight1": {"dtype": "F32", "shape": [10, 10], "data_offsets": [0, 400]},
|
|
133
|
+
}).encode()
|
|
134
|
+
data = struct.pack("<Q", len(header)) + header
|
|
135
|
+
v = validator.SafetensorsValidator()
|
|
136
|
+
v.validate_first_segment(data) # should not raise
|
|
137
|
+
|
|
138
|
+
def test_safetensors_invalid_json(self):
|
|
139
|
+
data = struct.pack("<Q", 5) + b"NOT{JSON"
|
|
140
|
+
v = validator.SafetensorsValidator()
|
|
141
|
+
with pytest.raises(validator.ValidationError):
|
|
142
|
+
v.validate_first_segment(data)
|
|
143
|
+
|
|
144
|
+
def test_safetensors_too_small(self):
|
|
145
|
+
v = validator.SafetensorsValidator()
|
|
146
|
+
with pytest.raises(validator.ValidationError):
|
|
147
|
+
v.validate_first_segment(b"tiny")
|
|
148
|
+
|
|
149
|
+
def test_generic_accepts_all(self):
|
|
150
|
+
v = validator.GenericValidator()
|
|
151
|
+
v.validate_first_segment(b"anything")
|
|
152
|
+
v.validate_first_segment(b"")
|
|
153
|
+
|
|
154
|
+
def test_registry_lookup(self):
|
|
155
|
+
reg = validator.ValidatorRegistry()
|
|
156
|
+
v = reg.validator_for("model.safetensors")
|
|
157
|
+
assert "safetensors" in v.file_extensions()
|
|
158
|
+
|
|
159
|
+
v = reg.validator_for("config.json")
|
|
160
|
+
assert len(v.file_extensions()) == 0 # fallback
|