Compare commits
9
Commits
6d19702d26
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a905462c0 | ||
|
|
5415eec81f | ||
|
|
f1107d129c | ||
|
|
99f9ef6228 | ||
|
|
71b2c572c3 | ||
|
|
7d3c6ef635 | ||
|
|
443fd3c6e6 | ||
|
|
d39a59c73f | ||
|
|
5e16f0e51a |
@@ -0,0 +1,6 @@
|
|||||||
|
# FIX CRLF always when developer has not set
|
||||||
|
# Linux/Mac: git config --global core.autocrlf input
|
||||||
|
# Windows: git config --global core.autocrlf true
|
||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
|
* eol=lf
|
||||||
@@ -3,3 +3,6 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/settings.local.json
|
||||||
|
target/
|
||||||
|
|||||||
Generated
+2142
File diff suppressed because it is too large
Load Diff
+54
@@ -0,0 +1,54 @@
|
|||||||
|
[package]
|
||||||
|
name = "metamesh-plugin-fast-full-hash"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "High-performance file hashing plugin for MetaMesh"
|
||||||
|
authors = ["MetaMesh"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# HTTP server
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
|
||||||
|
# Hashing
|
||||||
|
sha2 = "0.10"
|
||||||
|
sha1 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
crc32fast = "1.4"
|
||||||
|
sha3 = "0.10"
|
||||||
|
|
||||||
|
# CID/Multihash
|
||||||
|
cid = "0.11"
|
||||||
|
multihash = "0.19"
|
||||||
|
unsigned-varint = "0.8"
|
||||||
|
|
||||||
|
# BitTorrent v2 bencoding
|
||||||
|
bendy = "0.3"
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
|
|
||||||
|
# Async streaming
|
||||||
|
futures-util = "0.3"
|
||||||
|
bytes = "1.5"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
+33
-12
@@ -1,19 +1,40 @@
|
|||||||
# MetaMesh Plugin: full-hash
|
# MetaMesh Plugin: full-hash (Rust)
|
||||||
# Computes SHA-256 hash of the entire file
|
# High-performance file hashing with multi-algorithm support
|
||||||
|
|
||||||
FROM node:20-slim AS builder
|
FROM rust:1.89-slim-bookworm AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json tsconfig.json ./
|
|
||||||
RUN npm install
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy manifests and build dependencies first (for caching).
|
||||||
|
# Cargo.lock is copied so the build uses the committed, reproducible
|
||||||
|
# dependency set instead of re-resolving to the latest crates (which can
|
||||||
|
# pull in versions requiring a newer Rust edition than this toolchain).
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release --locked && rm -rf src
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN npm run build
|
RUN touch src/main.rs && cargo build --release --locked
|
||||||
|
|
||||||
FROM node:20-slim
|
FROM debian:bookworm-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install --omit=dev
|
# Install runtime dependencies (curl for healthcheck, ca-certificates for HTTPS)
|
||||||
COPY --from=builder /app/dist ./dist
|
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/metamesh-plugin-fast-full-hash /app/plugin
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:8080/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
ENV CACHE_PATH=/cache
|
||||||
|
|
||||||
|
# WebDAV URL for file access (set by container manager)
|
||||||
|
# Example: http://meta-sort-dev/webdav
|
||||||
|
ENV WEBDAV_URL=
|
||||||
|
|
||||||
|
CMD ["/app/plugin"]
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
# BitTorrent v2 Hashes (BEP 52)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generate BitTorrent v2 compatible hashes for magnet links and peer verification. Enables creating magnet links for files without a full .torrent file.
|
||||||
|
|
||||||
|
## Two Output Hashes
|
||||||
|
|
||||||
|
### 1. Pieces Root (`cid_bt_pieces_root`)
|
||||||
|
|
||||||
|
- **Multicodec**: `0xb702` (**official** `bittorrent-pieces-root`)
|
||||||
|
- **CID codec**: `0x55` (raw)
|
||||||
|
- **Description**: Merkle tree root of SHA-256 hashes of 16KB file blocks
|
||||||
|
- **Use case**: File verification, piece-level integrity checking
|
||||||
|
|
||||||
|
### 2. Info Hash (`cid_bt_info_hash`)
|
||||||
|
|
||||||
|
- **Multicodec**: `0x12` (standard SHA-256)
|
||||||
|
- **CID codec**: `0x55` (raw)
|
||||||
|
- **Description**: SHA-256 of bencoded info dictionary
|
||||||
|
- **Use case**: Magnet links, torrent identification
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read file in 16KB blocks
|
||||||
|
2. SHA-256 hash each block (pad last block with zeros)
|
||||||
|
3. Build merkle tree from leaf hashes:
|
||||||
|
- Pad to next power of 2 with zero hashes
|
||||||
|
- Each parent = SHA-256(left_child || right_child)
|
||||||
|
→ Result: pieces_root (32 bytes)
|
||||||
|
|
||||||
|
4. Create info dictionary:
|
||||||
|
{
|
||||||
|
"file tree": { filename: { "": { "length": N, "pieces root": <pieces_root> } } },
|
||||||
|
"meta version": 2,
|
||||||
|
"name": filename,
|
||||||
|
"piece length": 16384
|
||||||
|
}
|
||||||
|
|
||||||
|
5. Bencode the dictionary
|
||||||
|
6. SHA-256(bencoded_info)
|
||||||
|
→ Result: info_hash (32 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merkle Tree Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
[Root]
|
||||||
|
/ \
|
||||||
|
[Node] [Node]
|
||||||
|
/ \ / \
|
||||||
|
[Leaf0] [Leaf1] [Leaf2] [Zero]
|
||||||
|
|
||||||
|
Leaf = SHA-256(16KB block)
|
||||||
|
Node = SHA-256(left || right)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Magnet Link Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
magnet:?xt=urn:btmh:1220<info_hash_hex>&dn=<filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `btmh` = BitTorrent Multihash
|
||||||
|
- `1220` = `0x12` (SHA-256 code) + `0x20` (32 bytes = 0x20 in hex)
|
||||||
|
- `info_hash_hex` = 64 hex characters
|
||||||
|
- `dn` = display name (URL-encoded filename)
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
File: ubuntu-24.04-desktop-amd64.iso
|
||||||
|
Info Hash: a1b2c3d4...
|
||||||
|
Magnet: magnet:?xt=urn:btmh:1220a1b2c3d4...&dn=ubuntu-24.04-desktop-amd64.iso
|
||||||
|
```
|
||||||
|
|
||||||
|
## BEP 52 Compliance
|
||||||
|
|
||||||
|
This implementation follows [BEP 52](https://www.bittorrent.org/beps/bep_0052.html):
|
||||||
|
|
||||||
|
| Requirement | Implementation |
|
||||||
|
|-------------|----------------|
|
||||||
|
| Block size | 16 KiB (16,384 bytes) |
|
||||||
|
| Piece size | 16 KiB (minimum, power of 2) |
|
||||||
|
| Hash algorithm | SHA-256 |
|
||||||
|
| Merkle tree | Binary, power-of-2 padded |
|
||||||
|
| Info dict | v2 format with `file tree` |
|
||||||
|
| Meta version | 2 (v2-only torrent) |
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
| Property | Pieces Root | Info Hash |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| Hash size | 32 bytes | 32 bytes |
|
||||||
|
| Algorithm | Merkle SHA-256 | SHA-256 |
|
||||||
|
| Multicodec | 0xb702 (official) | 0x12 (SHA-256) |
|
||||||
|
| Time complexity | O(n) | O(n) |
|
||||||
|
| Space complexity | O(log n) | O(1) |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
1. **Magnet link creation** - Share files via magnet links without .torrent files
|
||||||
|
2. **Peer verification** - Verify file integrity in BitTorrent v2 swarms
|
||||||
|
3. **Deduplication** - Identify identical files across BitTorrent network
|
||||||
|
4. **Hybrid torrents** - Create v1+v2 hybrid torrents
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Pieces root depends on filename (included in info dict)
|
||||||
|
- Single-file torrents only (no multi-file directory support)
|
||||||
|
- Requires full file read for both hashes
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [BEP 52: The BitTorrent Protocol Specification v2](https://www.bittorrent.org/beps/bep_0052.html)
|
||||||
|
- [Multicodec Table](https://github.com/multiformats/multicodec/blob/master/table.csv) - `bittorrent-pieces-root` = 0xb702
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# MidHash256
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
O(1) file identification hash - constant time regardless of file size. Designed for quick duplicate detection and large file identification before expensive full-hash operations.
|
||||||
|
|
||||||
|
## Multicodec
|
||||||
|
|
||||||
|
- **Code**: `0x1000` (custom, not in official multicodec registry)
|
||||||
|
- **CID codec**: `0x55` (raw)
|
||||||
|
- **Output key**: `cid_midhash256`
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read file size (S)
|
||||||
|
2. If S <= 1MB:
|
||||||
|
sample = entire file content
|
||||||
|
3. If S > 1MB:
|
||||||
|
seek to (S - 1MB) / 2
|
||||||
|
sample = read 1MB from middle
|
||||||
|
4. Hash = SHA-256(S as u64 big-endian || sample)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pseudocode
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn midhash256(path: &Path) -> [u8; 32] {
|
||||||
|
let size = file_size(path);
|
||||||
|
let mut hasher = SHA256::new();
|
||||||
|
|
||||||
|
// Always include size as 8-byte big-endian prefix
|
||||||
|
hasher.update(size.to_be_bytes());
|
||||||
|
|
||||||
|
if size <= 1_048_576 {
|
||||||
|
// Small file: hash entire content
|
||||||
|
hasher.update(read_all(path));
|
||||||
|
} else {
|
||||||
|
// Large file: hash middle 1MB
|
||||||
|
let middle_start = (size - 1_048_576) / 2;
|
||||||
|
hasher.update(read_at(path, middle_start, 1_048_576));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finalize()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Time complexity | O(1) for files > 1MB |
|
||||||
|
| Space complexity | O(1) - fixed 1MB buffer |
|
||||||
|
| Hash size | 32 bytes (SHA-256) |
|
||||||
|
| Collision resistance | Same as SHA-256 for middle content |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
1. **Quick duplicate detection** - Identify potential duplicates before computing full hashes
|
||||||
|
2. **Large file identification** - Identify terabyte files in ~20ms regardless of size
|
||||||
|
3. **Pre-filtering** - Filter candidates before expensive operations (full hash, content analysis)
|
||||||
|
4. **Change detection** - Quick check if middle content changed (useful for video files)
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Files differing only at edges (first/last 512KB for 2MB+ files) will have same midhash
|
||||||
|
- Not suitable as a standalone deduplication hash - use as pre-filter only
|
||||||
|
- Custom multicodec code may conflict with future official allocations
|
||||||
|
|
||||||
|
## CID Example
|
||||||
|
|
||||||
|
```
|
||||||
|
File: movie.mkv (4.7 GB)
|
||||||
|
MidHash256: 3a7b...c2d1 (32 bytes)
|
||||||
|
CID: bafkreibh4xvl5... (base32lower encoded CIDv1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
The hash is computed separately from the standard single-pass algorithms since it requires seeking to the middle of the file. For files <= 1MB, it reads the entire file like other algorithms.
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@metazla/plugin-full-hash",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "MetaMesh plugin: Computes SHA-256 hash of the entire file",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "tsx watch src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"fastify": "^5.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"tsx": "^4.0.0",
|
|
||||||
"typescript": "^5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::api::process::ProcessState;
|
||||||
|
use crate::config::PluginConfig;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ConfigureRequest {
|
||||||
|
pub config: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ConfigureResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn configure(
|
||||||
|
State(state): State<Arc<ProcessState>>,
|
||||||
|
Json(req): Json<ConfigureRequest>,
|
||||||
|
) -> Json<ConfigureResponse> {
|
||||||
|
let mut cfg = state.config.write().await;
|
||||||
|
|
||||||
|
// Handle enabledHashes config
|
||||||
|
if let Some(hashes) = req.config.get("enabledHashes") {
|
||||||
|
let enabled = PluginConfig::parse_enabled_hashes(hashes);
|
||||||
|
if enabled.is_empty() {
|
||||||
|
return Json(ConfigureResponse {
|
||||||
|
status: "error",
|
||||||
|
error: Some("No valid hash algorithms specified".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cfg.enabled_hashes = enabled;
|
||||||
|
tracing::info!("Updated enabled hashes: {:?}", cfg.enabled_hashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle buffer size config
|
||||||
|
if let Some(size) = req.config.get("bufferSize") {
|
||||||
|
match size.parse::<usize>() {
|
||||||
|
Ok(s) if s >= 4096 => {
|
||||||
|
cfg.buffer_size = s;
|
||||||
|
tracing::info!("Updated buffer size: {}", s);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
return Json(ConfigureResponse {
|
||||||
|
status: "error",
|
||||||
|
error: Some("Buffer size must be at least 4096 bytes".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Json(ConfigureResponse {
|
||||||
|
status: "error",
|
||||||
|
error: Some("Invalid buffer size".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ConfigureResponse {
|
||||||
|
status: "ok",
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub ready: bool,
|
||||||
|
pub version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health() -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "healthy",
|
||||||
|
ready: true,
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ManifestResponse {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub author: &'static str,
|
||||||
|
pub dependencies: Vec<String>,
|
||||||
|
pub priority: u32,
|
||||||
|
pub color: &'static str,
|
||||||
|
#[serde(rename = "defaultQueue")]
|
||||||
|
pub default_queue: &'static str,
|
||||||
|
pub timeout: u64,
|
||||||
|
pub schema: Value,
|
||||||
|
pub config: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn manifest() -> Json<ManifestResponse> {
|
||||||
|
Json(ManifestResponse {
|
||||||
|
id: "full-hash",
|
||||||
|
name: "Full File Hash",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
description: "High-performance parallel hashing with optimized I/O",
|
||||||
|
author: "MetaMesh",
|
||||||
|
dependencies: vec![],
|
||||||
|
priority: 100,
|
||||||
|
color: "#FF5722",
|
||||||
|
default_queue: "background",
|
||||||
|
timeout: 300000,
|
||||||
|
schema: json!({
|
||||||
|
"cid_sha2-256": {
|
||||||
|
"label": "SHA-256 Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "SHA-256 hash in CID format (multicodec 0x12)"
|
||||||
|
},
|
||||||
|
"cid_sha1": {
|
||||||
|
"label": "SHA-1 Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "SHA-1 hash in CID format (multicodec 0x11)"
|
||||||
|
},
|
||||||
|
"cid_md5": {
|
||||||
|
"label": "MD5 Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "MD5 hash in CID format (multicodec 0xd5)"
|
||||||
|
},
|
||||||
|
"cid_crc32": {
|
||||||
|
"label": "CRC32 Checksum",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "CRC32 checksum in CID format (multicodec 0x0132)"
|
||||||
|
},
|
||||||
|
"cid_sha3-256": {
|
||||||
|
"label": "SHA3-256 Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "SHA3-256 hash in CID format (multicodec 0x16)"
|
||||||
|
},
|
||||||
|
"cid_sha3-384": {
|
||||||
|
"label": "SHA3-384 Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "SHA3-384 hash in CID format (multicodec 0x15)"
|
||||||
|
},
|
||||||
|
"cid_midhash256": {
|
||||||
|
"label": "MidHash256",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "O(1) file identification hash - SHA-256 of size + middle 1MB (custom multicodec 0x1000)"
|
||||||
|
},
|
||||||
|
"cid_bt_pieces_root": {
|
||||||
|
"label": "BitTorrent v2 Pieces Root",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "Merkle tree root of 16KB blocks (official multicodec 0xb702)"
|
||||||
|
},
|
||||||
|
"cid_bt_info_hash": {
|
||||||
|
"label": "BitTorrent v2 Info Hash",
|
||||||
|
"type": "cid",
|
||||||
|
"readonly": true,
|
||||||
|
"hint": "BitTorrent v2 info hash: SHA-256 of bencoded info dict (btih-v2 multicodec 0x10B7)"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
config: json!({
|
||||||
|
"enabledHashes": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Enabled hash algorithms (comma-separated)",
|
||||||
|
// These keys select WHICH digests to compute — they are an
|
||||||
|
// internal algorithm-selection vocabulary, NOT record field
|
||||||
|
// names. Results are stored on the record as a bare-CID
|
||||||
|
// key-set (cids/<cid> = "true"); the algorithm is recovered
|
||||||
|
// from each CID's multicodec, never a field name. The
|
||||||
|
// pieces-root is computed but not stored as a file-identity
|
||||||
|
// member (METADATA_KEYS.md §14.13).
|
||||||
|
"default": "cid_sha2-256,cid_sha1,cid_md5,cid_crc32,cid_sha3-256,cid_sha3-384,cid_midhash256,cid_bt_pieces_root,cid_bt_info_hash",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod configure;
|
||||||
|
mod health;
|
||||||
|
mod manifest;
|
||||||
|
mod process;
|
||||||
|
|
||||||
|
pub use configure::configure;
|
||||||
|
pub use health::health;
|
||||||
|
pub use manifest::manifest;
|
||||||
|
pub use process::{process, ProcessState};
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::cache::CacheManager;
|
||||||
|
use crate::client::MetaCoreClient;
|
||||||
|
use crate::config::{HashAlgorithm, SharedConfig};
|
||||||
|
use crate::hasher::{hash_file, hash_stream, multicodec_of};
|
||||||
|
use crate::webdav::{WebDavClient, WebDavReader};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProcessRequest {
|
||||||
|
pub task_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub callback_url: String,
|
||||||
|
pub meta_core_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub existing_meta: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ProcessResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CallbackPayload {
|
||||||
|
pub task_id: String,
|
||||||
|
pub status: &'static str,
|
||||||
|
pub duration: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProcessState {
|
||||||
|
pub config: SharedConfig,
|
||||||
|
pub cache: Arc<CacheManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process(
|
||||||
|
State(state): State<Arc<ProcessState>>,
|
||||||
|
Json(req): Json<ProcessRequest>,
|
||||||
|
) -> Json<ProcessResponse> {
|
||||||
|
// Validate required fields
|
||||||
|
if req.task_id.is_empty() || req.file_path.is_empty() || req.callback_url.is_empty() {
|
||||||
|
return Json(ProcessResponse {
|
||||||
|
status: "rejected",
|
||||||
|
error: Some("Missing required fields".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn async processing task
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
process_file(state_clone, req).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Json(ProcessResponse {
|
||||||
|
status: "accepted",
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_file(state: Arc<ProcessState>, req: ProcessRequest) {
|
||||||
|
let start = Instant::now();
|
||||||
|
let task_id = req.task_id.clone();
|
||||||
|
let callback_url = req.callback_url.clone();
|
||||||
|
|
||||||
|
let result = do_process_file(state, req).await;
|
||||||
|
|
||||||
|
let duration = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let payload = match result {
|
||||||
|
Ok(ProcessResult::Completed) => CallbackPayload {
|
||||||
|
task_id,
|
||||||
|
status: "completed",
|
||||||
|
duration,
|
||||||
|
error: None,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
Ok(ProcessResult::Skipped(reason)) => CallbackPayload {
|
||||||
|
task_id,
|
||||||
|
status: "skipped",
|
||||||
|
duration,
|
||||||
|
error: None,
|
||||||
|
reason: Some(reason),
|
||||||
|
},
|
||||||
|
Err(e) => CallbackPayload {
|
||||||
|
task_id,
|
||||||
|
status: "failed",
|
||||||
|
duration,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send callback
|
||||||
|
if let Err(e) = send_callback(&callback_url, &payload).await {
|
||||||
|
tracing::error!("Failed to send callback: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProcessResult {
|
||||||
|
Completed,
|
||||||
|
Skipped(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File source - either local filesystem or WebDAV stream
|
||||||
|
enum FileSource {
|
||||||
|
Local {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
size: u64,
|
||||||
|
mtime: i64,
|
||||||
|
},
|
||||||
|
WebDav {
|
||||||
|
reader: WebDavReader,
|
||||||
|
size: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_process_file(
|
||||||
|
state: Arc<ProcessState>,
|
||||||
|
req: ProcessRequest,
|
||||||
|
) -> anyhow::Result<ProcessResult> {
|
||||||
|
let path = Path::new(&req.file_path);
|
||||||
|
|
||||||
|
// Extract filename from path
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Try to get file - either from local filesystem or WebDAV
|
||||||
|
let source = if path.exists() {
|
||||||
|
// Local file available
|
||||||
|
let metadata = std::fs::metadata(path)?;
|
||||||
|
let size = metadata.len();
|
||||||
|
let mtime = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
tracing::debug!("Using local file: {}", req.file_path);
|
||||||
|
FileSource::Local {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
size,
|
||||||
|
mtime,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try WebDAV - stream instead of loading into memory
|
||||||
|
let webdav_url = std::env::var("WEBDAV_URL")
|
||||||
|
.map_err(|_| anyhow::anyhow!("File not found locally and WEBDAV_URL not set: {}", req.file_path))?;
|
||||||
|
|
||||||
|
tracing::info!("Streaming via WebDAV: {}", req.file_path);
|
||||||
|
let client = WebDavClient::new(&webdav_url);
|
||||||
|
let reader = client.get_file(&req.file_path).await?;
|
||||||
|
let size = reader.size();
|
||||||
|
|
||||||
|
tracing::info!("WebDAV stream ready, size: {:?}", size);
|
||||||
|
FileSource::WebDav {
|
||||||
|
reader,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file size and mtime for cache lookup
|
||||||
|
let (size, mtime) = match &source {
|
||||||
|
FileSource::Local { size, mtime, .. } => (*size, *mtime),
|
||||||
|
FileSource::WebDav { size, .. } => (size.unwrap_or(0), 0), // No mtime for WebDAV
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
let config = state.config.read().await.clone();
|
||||||
|
|
||||||
|
// Which digests does the record already carry? Sibling CIDs live as a
|
||||||
|
// bare-CID key-set (cids/<cid>); the algorithm is no longer in a field
|
||||||
|
// name, so we recover it from each member's multihash code. An algorithm
|
||||||
|
// is "already computed" iff its multicodec is present in that set.
|
||||||
|
let present_codecs: std::collections::HashSet<u64> = req
|
||||||
|
.existing_meta
|
||||||
|
.keys()
|
||||||
|
.filter_map(|k| k.strip_prefix("cids/"))
|
||||||
|
.filter_map(multicodec_of)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let missing_algos: Vec<HashAlgorithm> = config
|
||||||
|
.enabled_hashes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|a| !present_codecs.contains(&a.multicodec()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if missing_algos.is_empty() {
|
||||||
|
return Ok(ProcessResult::Skipped(
|
||||||
|
"All hashes already computed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache for remaining hashes. The internal results/cache maps stay
|
||||||
|
// keyed by the algorithm key (cid_sha2-256, …) — only the meta-core write
|
||||||
|
// below is transformed into the cids/<cid> key-set.
|
||||||
|
let cached = state.cache.get(&filename, size, mtime).await;
|
||||||
|
let mut results: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
|
let to_compute: Vec<HashAlgorithm> = missing_algos
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter_map(|algo| {
|
||||||
|
let key = algo.key();
|
||||||
|
if let Some(ref cached_hashes) = cached {
|
||||||
|
if let Some(hash) = cached_hashes.get(key) {
|
||||||
|
results.insert(key.to_string(), hash.clone());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(algo)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// If all hashes were in cache, we're done
|
||||||
|
if to_compute.is_empty() {
|
||||||
|
tracing::info!("All hashes from cache for {}", filename);
|
||||||
|
} else {
|
||||||
|
// Compute missing hashes
|
||||||
|
tracing::info!(
|
||||||
|
"Computing {} hashes for {} ({} bytes)",
|
||||||
|
to_compute.len(),
|
||||||
|
filename,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
|
||||||
|
let enabled_set = to_compute.iter().copied().collect();
|
||||||
|
let computed = match source {
|
||||||
|
FileSource::Local { path, .. } => {
|
||||||
|
// Use file-based hashing for local files
|
||||||
|
let buffer_size = config.buffer_size;
|
||||||
|
tokio::task::spawn_blocking(move || hash_file(&path, &enabled_set, buffer_size))
|
||||||
|
.await??
|
||||||
|
}
|
||||||
|
FileSource::WebDav { reader, size: file_size } => {
|
||||||
|
// Use streaming hashing for WebDAV - process chunks as they arrive
|
||||||
|
let stream = reader.bytes_stream();
|
||||||
|
let filename_clone = filename.clone();
|
||||||
|
hash_stream(stream, &filename_clone, file_size, &enabled_set).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge computed hashes into results
|
||||||
|
for (key, value) in &computed {
|
||||||
|
results.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with new hashes
|
||||||
|
let mut all_hashes = cached.unwrap_or_default();
|
||||||
|
all_hashes.extend(computed);
|
||||||
|
state
|
||||||
|
.cache
|
||||||
|
.insert(filename.clone(), size, mtime, all_hashes)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// CRITICAL: Save cache to disk BEFORE reporting done
|
||||||
|
// This ensures the cache file is written and synced before the callback
|
||||||
|
state.cache.save().await?;
|
||||||
|
|
||||||
|
tracing::info!("Cache saved for {} ({} hashes)", filename, results.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store results to meta-core as the bare-CID key-set: one
|
||||||
|
// cids/<cid> = "true" entry per computed digest. The CID is
|
||||||
|
// self-describing (its algorithm is the multicodec), so there is no
|
||||||
|
// per-algorithm field name. The pieces-root is excluded — it's a
|
||||||
|
// torrent-internal merkle root, not a file-identity CID (§14.13).
|
||||||
|
let pieces_root_key = HashAlgorithm::BtPiecesRoot.key();
|
||||||
|
let cids_payload: HashMap<String, String> = results
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| key.as_str() != pieces_root_key)
|
||||||
|
.map(|(_, cid_value)| (format!("cids/{}", cid_value), "true".to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !cids_payload.is_empty() {
|
||||||
|
match MetaCoreClient::new(&req.meta_core_url) {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(e) = client.merge_metadata(&req.cid, &cids_payload).await {
|
||||||
|
tracing::warn!("Failed to store metadata to meta-core: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to create meta-core client: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProcessResult::Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_callback(url: &str, payload: &CallbackPayload) -> anyhow::Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client.post(url).json(payload).send().await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("Callback failed with status {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Vendored
+333
@@ -0,0 +1,333 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::config::HashAlgorithm;
|
||||||
|
|
||||||
|
/// Cache entry for a single file
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CacheEntry {
|
||||||
|
pub filename: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub mtime: i64,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-algorithm CSV index manager
|
||||||
|
///
|
||||||
|
/// Each algorithm has its own CSV file: index-{algorithm}.csv
|
||||||
|
/// Format: path,size,mtime,hash
|
||||||
|
///
|
||||||
|
/// Uses append-only writes for efficiency (like meta-hash does)
|
||||||
|
struct AlgorithmIndex {
|
||||||
|
file_path: PathBuf,
|
||||||
|
/// In-memory cache: key = "filename|size|mtime", value = hash
|
||||||
|
entries: HashMap<String, String>,
|
||||||
|
/// Track which entries are new and need to be appended
|
||||||
|
pending_writes: Vec<CacheEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlgorithmIndex {
|
||||||
|
fn new(cache_dir: &Path, algorithm: &str) -> Self {
|
||||||
|
let file_path = cache_dir.join(format!("index-{}.csv", algorithm));
|
||||||
|
Self {
|
||||||
|
file_path,
|
||||||
|
entries: HashMap::new(),
|
||||||
|
pending_writes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_key(filename: &str, size: u64, mtime: i64) -> String {
|
||||||
|
format!("{}|{}|{}", filename, size, mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load existing entries from CSV file
|
||||||
|
fn load(&mut self) -> Result<()> {
|
||||||
|
if !self.file_path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::open(&self.file_path)
|
||||||
|
.with_context(|| format!("Failed to open index file: {:?}", self.file_path))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut first_line = true;
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line?;
|
||||||
|
|
||||||
|
// Skip header
|
||||||
|
if first_line {
|
||||||
|
first_line = false;
|
||||||
|
if line.starts_with("path,") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CSV line: path,size,mtime,hash
|
||||||
|
let parts: Vec<&str> = line.splitn(4, ',').collect();
|
||||||
|
if parts.len() == 4 {
|
||||||
|
let filename = parts[0].to_string();
|
||||||
|
let size: u64 = parts[1].parse().unwrap_or(0);
|
||||||
|
let mtime: i64 = parts[2].parse().unwrap_or(0);
|
||||||
|
let hash = parts[3].to_string();
|
||||||
|
|
||||||
|
let key = Self::make_key(&filename, size, mtime);
|
||||||
|
self.entries.insert(key, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached hash for a file
|
||||||
|
fn get(&self, filename: &str, size: u64, mtime: i64) -> Option<&String> {
|
||||||
|
let key = Self::make_key(filename, size, mtime);
|
||||||
|
self.entries.get(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a hash to the cache (queues for append)
|
||||||
|
fn insert(&mut self, filename: String, size: u64, mtime: i64, hash: String) {
|
||||||
|
let key = Self::make_key(&filename, size, mtime);
|
||||||
|
|
||||||
|
// Only queue for write if not already in cache
|
||||||
|
if !self.entries.contains_key(&key) {
|
||||||
|
self.pending_writes.push(CacheEntry {
|
||||||
|
filename: filename.clone(),
|
||||||
|
size,
|
||||||
|
mtime,
|
||||||
|
hash: hash.clone(),
|
||||||
|
});
|
||||||
|
self.entries.insert(key, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append pending entries to CSV file
|
||||||
|
fn flush(&mut self) -> Result<()> {
|
||||||
|
if self.pending_writes.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = self.file_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists to determine if we need a header
|
||||||
|
let needs_header = !self.file_path.exists() || fs::metadata(&self.file_path)?.len() == 0;
|
||||||
|
|
||||||
|
// Open file in append mode
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.file_path)?;
|
||||||
|
|
||||||
|
// Write header if needed
|
||||||
|
if needs_header {
|
||||||
|
writeln!(file, "path,size,mtime,hash")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new entries
|
||||||
|
for entry in &self.pending_writes {
|
||||||
|
writeln!(file, "{},{},{},{}", entry.filename, entry.size, entry.mtime, entry.hash)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data is flushed to disk
|
||||||
|
file.flush()?;
|
||||||
|
file.sync_all()?;
|
||||||
|
|
||||||
|
let count = self.pending_writes.len();
|
||||||
|
self.pending_writes.clear();
|
||||||
|
|
||||||
|
tracing::debug!("Appended {} entries to {:?}", count, self.file_path);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache manager that handles multiple algorithm indexes
|
||||||
|
pub struct CacheManager {
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
/// Per-algorithm indexes
|
||||||
|
indexes: Arc<RwLock<HashMap<String, AlgorithmIndex>>>,
|
||||||
|
/// Track which algorithms have been loaded
|
||||||
|
loaded: Arc<RwLock<HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheManager {
|
||||||
|
pub fn new(cache_dir: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
cache_dir: PathBuf::from(cache_dir),
|
||||||
|
indexes: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
loaded: Arc::new(RwLock::new(HashSet::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all indexes (called at startup)
|
||||||
|
pub async fn load(&self) -> Result<()> {
|
||||||
|
// Load indexes for all known algorithms
|
||||||
|
for algo in HashAlgorithm::all() {
|
||||||
|
self.ensure_loaded(algo.key()).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a specific algorithm's index is loaded
|
||||||
|
async fn ensure_loaded(&self, algorithm: &str) -> Result<()> {
|
||||||
|
let mut loaded = self.loaded.write().await;
|
||||||
|
if loaded.contains(algorithm) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut indexes = self.indexes.write().await;
|
||||||
|
let mut index = AlgorithmIndex::new(&self.cache_dir, algorithm);
|
||||||
|
|
||||||
|
if let Err(e) = index.load() {
|
||||||
|
tracing::warn!("Failed to load index for {}: {}", algorithm, e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Loaded {} entries for {}", index.entries.len(), algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes.insert(algorithm.to_string(), index);
|
||||||
|
loaded.insert(algorithm.to_string());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached hashes for a file (returns all available hashes)
|
||||||
|
pub async fn get(&self, filename: &str, size: u64, mtime: i64) -> Option<HashMap<String, String>> {
|
||||||
|
let indexes = self.indexes.read().await;
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
for (algo, index) in indexes.iter() {
|
||||||
|
if let Some(hash) = index.get(filename, size, mtime) {
|
||||||
|
results.insert(algo.clone(), hash.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store hashes in cache (queues for append)
|
||||||
|
pub async fn insert(&self, filename: String, size: u64, mtime: i64, hashes: HashMap<String, String>) {
|
||||||
|
let mut indexes = self.indexes.write().await;
|
||||||
|
|
||||||
|
for (algo, hash) in hashes {
|
||||||
|
// Create index if it doesn't exist
|
||||||
|
if !indexes.contains_key(&algo) {
|
||||||
|
let mut index = AlgorithmIndex::new(&self.cache_dir, &algo);
|
||||||
|
let _ = index.load(); // Ignore errors, will create new file
|
||||||
|
indexes.insert(algo.clone(), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(index) = indexes.get_mut(&algo) {
|
||||||
|
index.insert(filename.clone(), size, mtime, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush all pending writes to disk (MUST be called before reporting done)
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let mut indexes = self.indexes.write().await;
|
||||||
|
let mut total_written = 0;
|
||||||
|
|
||||||
|
for (algo, index) in indexes.iter_mut() {
|
||||||
|
let pending = index.pending_writes.len();
|
||||||
|
if pending > 0 {
|
||||||
|
index.flush().with_context(|| format!("Failed to flush index for {}", algo))?;
|
||||||
|
total_written += pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_written > 0 {
|
||||||
|
tracing::info!("Flushed {} total cache entries to disk", total_written);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_append_mode() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let cache = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||||
|
|
||||||
|
// Insert first entry
|
||||||
|
let mut hashes1 = HashMap::new();
|
||||||
|
hashes1.insert("cid_sha2-256".to_string(), "baejbeitest1".to_string());
|
||||||
|
cache.insert("test1.mkv".to_string(), 1000, 12345, hashes1).await;
|
||||||
|
cache.save().await.unwrap();
|
||||||
|
|
||||||
|
// Insert second entry
|
||||||
|
let mut hashes2 = HashMap::new();
|
||||||
|
hashes2.insert("cid_sha2-256".to_string(), "baejbeitest2".to_string());
|
||||||
|
cache.insert("test2.mkv".to_string(), 2000, 12346, hashes2).await;
|
||||||
|
cache.save().await.unwrap();
|
||||||
|
|
||||||
|
// Verify file contents (should have header + 2 entries)
|
||||||
|
let csv_path = temp_dir.path().join("index-cid_sha2-256.csv");
|
||||||
|
let content = fs::read_to_string(&csv_path).unwrap();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
assert_eq!(lines.len(), 3); // header + 2 entries
|
||||||
|
assert!(lines[0].starts_with("path,"));
|
||||||
|
assert!(lines[1].contains("test1.mkv"));
|
||||||
|
assert!(lines[2].contains("test2.mkv"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_reload() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// First cache instance
|
||||||
|
{
|
||||||
|
let cache = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||||
|
let mut hashes = HashMap::new();
|
||||||
|
hashes.insert("cid_sha2-256".to_string(), "baejbeitest".to_string());
|
||||||
|
cache.insert("test.mkv".to_string(), 1000, 12345, hashes).await;
|
||||||
|
cache.save().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second cache instance (reload)
|
||||||
|
{
|
||||||
|
let cache = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||||
|
cache.load().await.unwrap();
|
||||||
|
|
||||||
|
let result = cache.get("test.mkv", 1000, 12345).await;
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap().get("cid_sha2-256").unwrap(), "baejbeitest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_no_duplicate_writes() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let cache = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||||
|
|
||||||
|
// Insert same entry twice
|
||||||
|
let mut hashes = HashMap::new();
|
||||||
|
hashes.insert("cid_sha2-256".to_string(), "baejbeitest".to_string());
|
||||||
|
cache.insert("test.mkv".to_string(), 1000, 12345, hashes.clone()).await;
|
||||||
|
cache.insert("test.mkv".to_string(), 1000, 12345, hashes).await;
|
||||||
|
cache.save().await.unwrap();
|
||||||
|
|
||||||
|
// Verify only one entry in file
|
||||||
|
let csv_path = temp_dir.path().join("index-cid_sha2-256.csv");
|
||||||
|
let content = fs::read_to_string(&csv_path).unwrap();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
assert_eq!(lines.len(), 2); // header + 1 entry
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const MAX_RETRIES: u32 = 3;
|
||||||
|
const RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
pub struct MetaCoreClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetaCoreClient {
|
||||||
|
pub fn new(base_url: &str) -> Result<Self> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a single property for a hash ID
|
||||||
|
pub async fn set_property(&self, hash_id: &str, key: &str, value: &str) -> Result<()> {
|
||||||
|
let url = format!("{}/meta/{}/{}", self.base_url, hash_id, key);
|
||||||
|
|
||||||
|
for attempt in 1..=MAX_RETRIES {
|
||||||
|
match self
|
||||||
|
.client
|
||||||
|
.put(&url)
|
||||||
|
.json(&json!({ "value": value }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
if attempt == MAX_RETRIES {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Failed to set property {} for {}: {} - {}",
|
||||||
|
key,
|
||||||
|
hash_id,
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
"Retry {}/{} for set_property: {} - {}",
|
||||||
|
attempt,
|
||||||
|
MAX_RETRIES,
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if attempt == MAX_RETRIES {
|
||||||
|
return Err(e).context(format!(
|
||||||
|
"Failed to set property {} for {}",
|
||||||
|
key, hash_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::warn!("Retry {}/{} for set_property: {}", attempt, MAX_RETRIES, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge multiple properties at once
|
||||||
|
pub async fn merge_metadata(
|
||||||
|
&self,
|
||||||
|
hash_id: &str,
|
||||||
|
metadata: &HashMap<String, String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Try batch endpoint first
|
||||||
|
let url = format!("{}/meta/{}", self.base_url, hash_id);
|
||||||
|
|
||||||
|
match self.client.patch(&url).json(metadata).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(resp) if resp.status().as_u16() == 404 => {
|
||||||
|
// Batch endpoint not available, fall back to individual sets
|
||||||
|
tracing::debug!("Batch endpoint not available, using individual sets");
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
tracing::warn!("Batch merge failed with {}, falling back to individual sets", status);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Batch merge error: {}, falling back to individual sets", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to individual property sets
|
||||||
|
for (key, value) in metadata {
|
||||||
|
self.set_property(hash_id, key, value).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check
|
||||||
|
pub async fn health(&self) -> Result<bool> {
|
||||||
|
let url = format!("{}/health", self.base_url);
|
||||||
|
match self.client.get(&url).send().await {
|
||||||
|
Ok(resp) => Ok(resp.status().is_success()),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_creation() {
|
||||||
|
let client = MetaCoreClient::new("http://localhost:9000");
|
||||||
|
assert!(client.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_normalization() {
|
||||||
|
let client = MetaCoreClient::new("http://localhost:9000/").unwrap();
|
||||||
|
assert_eq!(client.base_url, "http://localhost:9000");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mod meta_core;
|
||||||
|
|
||||||
|
pub use meta_core::MetaCoreClient;
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum HashAlgorithm {
|
||||||
|
#[serde(rename = "cid_sha2-256")]
|
||||||
|
Sha256,
|
||||||
|
#[serde(rename = "cid_sha1")]
|
||||||
|
Sha1,
|
||||||
|
#[serde(rename = "cid_md5")]
|
||||||
|
Md5,
|
||||||
|
#[serde(rename = "cid_crc32")]
|
||||||
|
Crc32,
|
||||||
|
#[serde(rename = "cid_sha3-256")]
|
||||||
|
Sha3_256,
|
||||||
|
#[serde(rename = "cid_sha3-384")]
|
||||||
|
Sha3_384,
|
||||||
|
#[serde(rename = "cid_midhash256")]
|
||||||
|
Midhash256,
|
||||||
|
#[serde(rename = "cid_bt_pieces_root")]
|
||||||
|
BtPiecesRoot,
|
||||||
|
#[serde(rename = "cid_bt_info_hash")]
|
||||||
|
BtInfoHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multicodec hash codes
|
||||||
|
// Standard codes from https://github.com/multiformats/multicodec
|
||||||
|
pub const SHA256_CODE: u64 = 0x12;
|
||||||
|
pub const SHA1_CODE: u64 = 0x11;
|
||||||
|
pub const MD5_CODE: u64 = 0xd5;
|
||||||
|
pub const CRC32_CODE: u64 = 0x0132;
|
||||||
|
pub const SHA3_256_CODE: u64 = 0x16;
|
||||||
|
pub const SHA3_384_CODE: u64 = 0x15;
|
||||||
|
|
||||||
|
// Custom hash codes
|
||||||
|
pub const MIDHASH256_CODE: u64 = 0x1000; // Custom, not in official registry
|
||||||
|
|
||||||
|
// Official BitTorrent v2 code
|
||||||
|
pub const BT_PIECES_ROOT_CODE: u64 = 0xb702; // Official bittorrent-pieces-root
|
||||||
|
|
||||||
|
// Custom BitTorrent v2 info-hash code (BEP 52), matching meta-hash's
|
||||||
|
// cid_btih_v2. The info hash is a SHA-256 of the bencoded info dict, but it
|
||||||
|
// MUST carry a distinct multihash code (not 0x12) so it can't be confused
|
||||||
|
// with the full-file sha2-256 in the bare-CID key-set — see METADATA_KEYS.md
|
||||||
|
// §2/§14.13. Consumers (meta-dup, meta-watch) disambiguate set members by
|
||||||
|
// this code.
|
||||||
|
pub const BTIH_V2_CODE: u64 = 0x10B7;
|
||||||
|
|
||||||
|
impl HashAlgorithm {
|
||||||
|
pub fn all() -> Vec<HashAlgorithm> {
|
||||||
|
vec![
|
||||||
|
HashAlgorithm::Sha256,
|
||||||
|
HashAlgorithm::Sha1,
|
||||||
|
HashAlgorithm::Md5,
|
||||||
|
HashAlgorithm::Crc32,
|
||||||
|
HashAlgorithm::Sha3_256,
|
||||||
|
HashAlgorithm::Sha3_384,
|
||||||
|
HashAlgorithm::Midhash256,
|
||||||
|
HashAlgorithm::BtPiecesRoot,
|
||||||
|
HashAlgorithm::BtInfoHash,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard algorithms that can be computed in a single I/O pass
|
||||||
|
pub fn standard_algorithms() -> Vec<HashAlgorithm> {
|
||||||
|
vec![
|
||||||
|
HashAlgorithm::Sha256,
|
||||||
|
HashAlgorithm::Sha1,
|
||||||
|
HashAlgorithm::Md5,
|
||||||
|
HashAlgorithm::Crc32,
|
||||||
|
HashAlgorithm::Sha3_256,
|
||||||
|
HashAlgorithm::Sha3_384,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Special algorithms that require separate file reads
|
||||||
|
pub fn special_algorithms() -> Vec<HashAlgorithm> {
|
||||||
|
vec![
|
||||||
|
HashAlgorithm::Midhash256,
|
||||||
|
HashAlgorithm::BtPiecesRoot,
|
||||||
|
HashAlgorithm::BtInfoHash,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this algorithm requires a separate file read
|
||||||
|
pub fn is_special(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
HashAlgorithm::Midhash256 | HashAlgorithm::BtPiecesRoot | HashAlgorithm::BtInfoHash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HashAlgorithm::Sha256 => "cid_sha2-256",
|
||||||
|
HashAlgorithm::Sha1 => "cid_sha1",
|
||||||
|
HashAlgorithm::Md5 => "cid_md5",
|
||||||
|
HashAlgorithm::Crc32 => "cid_crc32",
|
||||||
|
HashAlgorithm::Sha3_256 => "cid_sha3-256",
|
||||||
|
HashAlgorithm::Sha3_384 => "cid_sha3-384",
|
||||||
|
HashAlgorithm::Midhash256 => "cid_midhash256",
|
||||||
|
HashAlgorithm::BtPiecesRoot => "cid_bt_pieces_root",
|
||||||
|
HashAlgorithm::BtInfoHash => "cid_bt_info_hash",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_key(key: &str) -> Option<HashAlgorithm> {
|
||||||
|
match key {
|
||||||
|
"cid_sha2-256" => Some(HashAlgorithm::Sha256),
|
||||||
|
"cid_sha1" => Some(HashAlgorithm::Sha1),
|
||||||
|
"cid_md5" => Some(HashAlgorithm::Md5),
|
||||||
|
"cid_crc32" => Some(HashAlgorithm::Crc32),
|
||||||
|
"cid_sha3-256" => Some(HashAlgorithm::Sha3_256),
|
||||||
|
"cid_sha3-384" => Some(HashAlgorithm::Sha3_384),
|
||||||
|
"cid_midhash256" => Some(HashAlgorithm::Midhash256),
|
||||||
|
"cid_bt_pieces_root" => Some(HashAlgorithm::BtPiecesRoot),
|
||||||
|
"cid_bt_info_hash" => Some(HashAlgorithm::BtInfoHash),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the multicodec code for this hash algorithm
|
||||||
|
pub fn multicodec(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
HashAlgorithm::Sha256 => SHA256_CODE,
|
||||||
|
HashAlgorithm::Sha1 => SHA1_CODE,
|
||||||
|
HashAlgorithm::Md5 => MD5_CODE,
|
||||||
|
HashAlgorithm::Crc32 => CRC32_CODE,
|
||||||
|
HashAlgorithm::Sha3_256 => SHA3_256_CODE,
|
||||||
|
HashAlgorithm::Sha3_384 => SHA3_384_CODE,
|
||||||
|
HashAlgorithm::Midhash256 => MIDHASH256_CODE,
|
||||||
|
HashAlgorithm::BtPiecesRoot => BT_PIECES_ROOT_CODE,
|
||||||
|
HashAlgorithm::BtInfoHash => BTIH_V2_CODE, // distinct from sha2-256 (§14.13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PluginConfig {
|
||||||
|
pub enabled_hashes: HashSet<HashAlgorithm>,
|
||||||
|
pub cache_path: String,
|
||||||
|
pub buffer_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled_hashes: HashAlgorithm::all().into_iter().collect(),
|
||||||
|
cache_path: std::env::var("CACHE_PATH").unwrap_or_else(|_| "/cache".to_string()),
|
||||||
|
buffer_size: 1024 * 1024, // 1MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginConfig {
|
||||||
|
pub fn parse_enabled_hashes(s: &str) -> HashSet<HashAlgorithm> {
|
||||||
|
s.split(',')
|
||||||
|
.filter_map(|k| HashAlgorithm::from_key(k.trim()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedConfig = Arc<RwLock<PluginConfig>>;
|
||||||
|
|
||||||
|
pub fn create_shared_config() -> SharedConfig {
|
||||||
|
Arc::new(RwLock::new(PluginConfig::default()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
use crate::config::HashAlgorithm;
|
||||||
|
use anyhow::Result;
|
||||||
|
use md5::Md5;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sha3::{Sha3_256, Sha3_384};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::btih_v2::{compute_info_hash, compute_merkle_root, BT_BLOCK_SIZE};
|
||||||
|
use super::cid::to_cid;
|
||||||
|
use super::midhash256::compute_midhash256;
|
||||||
|
|
||||||
|
/// Hash a file using all enabled algorithms in a single I/O pass
|
||||||
|
///
|
||||||
|
/// All algorithms except Midhash256 are computed in the same pass:
|
||||||
|
/// - Standard algorithms update their hashers with each buffer chunk
|
||||||
|
/// - BitTorrent v2 computes 16KB block hashes from the same data
|
||||||
|
///
|
||||||
|
/// Midhash256 requires a separate O(1) read (middle 1MB only).
|
||||||
|
pub fn hash_file(
|
||||||
|
path: &Path,
|
||||||
|
enabled: &HashSet<HashAlgorithm>,
|
||||||
|
buffer_size: usize,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
// Check what we need to compute
|
||||||
|
let needs_bt = enabled.contains(&HashAlgorithm::BtPiecesRoot)
|
||||||
|
|| enabled.contains(&HashAlgorithm::BtInfoHash);
|
||||||
|
|
||||||
|
// Get filename for BT v2 info hash (needed before we start)
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Main single-pass computation (standard + BT v2)
|
||||||
|
let (standard_results, bt_leaf_hashes, file_size) =
|
||||||
|
hash_file_single_pass(path, enabled, buffer_size, needs_bt)?;
|
||||||
|
results.extend(standard_results);
|
||||||
|
|
||||||
|
// Finalize BT v2 hashes from collected leaf hashes
|
||||||
|
if needs_bt {
|
||||||
|
let pieces_root = compute_merkle_root(&bt_leaf_hashes);
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtPiecesRoot) {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtPiecesRoot.key().to_string(),
|
||||||
|
to_cid(&pieces_root, HashAlgorithm::BtPiecesRoot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtInfoHash) {
|
||||||
|
match compute_info_hash(&filename, file_size, &pieces_root) {
|
||||||
|
Ok(info_hash) => {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtInfoHash.key().to_string(),
|
||||||
|
to_cid(&info_hash, HashAlgorithm::BtInfoHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to compute BT info hash: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Midhash256 requires separate O(1) read (only reads middle 1MB)
|
||||||
|
if enabled.contains(&HashAlgorithm::Midhash256) {
|
||||||
|
match compute_midhash256(path) {
|
||||||
|
Ok(hash) => {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Midhash256.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Midhash256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to compute midhash256: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-pass file hashing for all standard algorithms + BT v2 leaf collection
|
||||||
|
///
|
||||||
|
/// Returns: (standard hash results, BT v2 leaf hashes, file size)
|
||||||
|
fn hash_file_single_pass(
|
||||||
|
path: &Path,
|
||||||
|
enabled: &HashSet<HashAlgorithm>,
|
||||||
|
buffer_size: usize,
|
||||||
|
collect_bt_leaves: bool,
|
||||||
|
) -> Result<(HashMap<String, String>, Vec<[u8; 32]>, u64)> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let file_size = file.metadata()?.len();
|
||||||
|
let mut reader = BufReader::with_capacity(buffer_size, file);
|
||||||
|
let mut buffer = vec![0u8; buffer_size];
|
||||||
|
|
||||||
|
// Initialize standard hashers (only if enabled)
|
||||||
|
let mut sha256 = enabled
|
||||||
|
.contains(&HashAlgorithm::Sha256)
|
||||||
|
.then(Sha256::new);
|
||||||
|
let mut sha1 = enabled.contains(&HashAlgorithm::Sha1).then(Sha1::new);
|
||||||
|
let mut md5 = enabled.contains(&HashAlgorithm::Md5).then(Md5::new);
|
||||||
|
let mut crc32 = enabled
|
||||||
|
.contains(&HashAlgorithm::Crc32)
|
||||||
|
.then(crc32fast::Hasher::new);
|
||||||
|
let mut sha3_256 = enabled
|
||||||
|
.contains(&HashAlgorithm::Sha3_256)
|
||||||
|
.then(Sha3_256::new);
|
||||||
|
let mut sha3_384 = enabled
|
||||||
|
.contains(&HashAlgorithm::Sha3_384)
|
||||||
|
.then(Sha3_384::new);
|
||||||
|
|
||||||
|
// BT v2: collect leaf hashes (SHA-256 of each 16KB block)
|
||||||
|
let mut bt_leaf_hashes: Vec<[u8; 32]> = Vec::new();
|
||||||
|
let mut bt_block_buffer = vec![0u8; BT_BLOCK_SIZE];
|
||||||
|
let mut bt_block_offset = 0usize;
|
||||||
|
|
||||||
|
let mut bytes_read: u64 = 0;
|
||||||
|
|
||||||
|
// Single read pass
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut buffer)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = &buffer[..n];
|
||||||
|
bytes_read += n as u64;
|
||||||
|
|
||||||
|
// Update all standard hashers
|
||||||
|
if let Some(ref mut h) = sha256 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha1 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = md5 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = crc32 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha3_256 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha3_384 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BT v2: Process chunk into 16KB blocks
|
||||||
|
if collect_bt_leaves {
|
||||||
|
let mut chunk_offset = 0;
|
||||||
|
while chunk_offset < n {
|
||||||
|
let space_in_block = BT_BLOCK_SIZE - bt_block_offset;
|
||||||
|
let bytes_to_copy = space_in_block.min(n - chunk_offset);
|
||||||
|
|
||||||
|
bt_block_buffer[bt_block_offset..bt_block_offset + bytes_to_copy]
|
||||||
|
.copy_from_slice(&chunk[chunk_offset..chunk_offset + bytes_to_copy]);
|
||||||
|
|
||||||
|
bt_block_offset += bytes_to_copy;
|
||||||
|
chunk_offset += bytes_to_copy;
|
||||||
|
|
||||||
|
// Block is full, hash it
|
||||||
|
if bt_block_offset == BT_BLOCK_SIZE {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bt_block_buffer);
|
||||||
|
bt_leaf_hashes.push(hasher.finalize().into());
|
||||||
|
bt_block_offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log progress for large files (every 100MB)
|
||||||
|
if bytes_read % (100 * 1024 * 1024) == 0 && file_size > 100 * 1024 * 1024 {
|
||||||
|
let pct = (bytes_read as f64 / file_size as f64 * 100.0) as u32;
|
||||||
|
tracing::debug!(
|
||||||
|
"Hashing progress: {}% ({}/{})",
|
||||||
|
pct,
|
||||||
|
bytes_read,
|
||||||
|
file_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BT v2: Handle final partial block (pad with zeros as per BEP 52)
|
||||||
|
if collect_bt_leaves {
|
||||||
|
if file_size == 0 {
|
||||||
|
// Empty file: single leaf of all zeros
|
||||||
|
bt_leaf_hashes.push([0u8; 32]);
|
||||||
|
} else if bt_block_offset > 0 {
|
||||||
|
// Pad remaining bytes with zeros
|
||||||
|
bt_block_buffer[bt_block_offset..].fill(0);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bt_block_buffer);
|
||||||
|
bt_leaf_hashes.push(hasher.finalize().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize standard hashes and format as CIDs
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(h) = sha256 {
|
||||||
|
let hash = h.finalize();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha256.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Sha256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(h) = sha1 {
|
||||||
|
let hash = h.finalize();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha1.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Sha1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(h) = md5 {
|
||||||
|
let hash = h.finalize();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Md5.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Md5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(h) = crc32 {
|
||||||
|
let hash = h.finalize().to_be_bytes();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Crc32.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Crc32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(h) = sha3_256 {
|
||||||
|
let hash = h.finalize();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha3_256.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Sha3_256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(h) = sha3_384 {
|
||||||
|
let hash = h.finalize();
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha3_384.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Sha3_384),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((results, bt_leaf_hashes, file_size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash data from bytes (for WebDAV streaming)
|
||||||
|
///
|
||||||
|
/// Similar to hash_file but works with in-memory data.
|
||||||
|
/// Note: Midhash256 requires knowing file size and reading middle section,
|
||||||
|
/// so we compute it from the bytes directly.
|
||||||
|
pub fn hash_bytes(
|
||||||
|
data: &[u8],
|
||||||
|
filename: &str,
|
||||||
|
enabled: &HashSet<HashAlgorithm>,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
let file_size = data.len() as u64;
|
||||||
|
|
||||||
|
// Check what we need to compute
|
||||||
|
let needs_bt = enabled.contains(&HashAlgorithm::BtPiecesRoot)
|
||||||
|
|| enabled.contains(&HashAlgorithm::BtInfoHash);
|
||||||
|
|
||||||
|
// Initialize standard hashers
|
||||||
|
let mut sha256 = enabled.contains(&HashAlgorithm::Sha256).then(Sha256::new);
|
||||||
|
let mut sha1 = enabled.contains(&HashAlgorithm::Sha1).then(Sha1::new);
|
||||||
|
let mut md5 = enabled.contains(&HashAlgorithm::Md5).then(Md5::new);
|
||||||
|
let mut crc32 = enabled.contains(&HashAlgorithm::Crc32).then(crc32fast::Hasher::new);
|
||||||
|
let mut sha3_256 = enabled.contains(&HashAlgorithm::Sha3_256).then(Sha3_256::new);
|
||||||
|
let mut sha3_384 = enabled.contains(&HashAlgorithm::Sha3_384).then(Sha3_384::new);
|
||||||
|
|
||||||
|
// Process data in chunks for BT leaf hashes
|
||||||
|
let mut bt_leaf_hashes: Vec<[u8; 32]> = Vec::new();
|
||||||
|
|
||||||
|
// Update all hashers with full data
|
||||||
|
if let Some(ref mut h) = sha256 { h.update(data); }
|
||||||
|
if let Some(ref mut h) = sha1 { h.update(data); }
|
||||||
|
if let Some(ref mut h) = md5 { h.update(data); }
|
||||||
|
if let Some(ref mut h) = crc32 { h.update(data); }
|
||||||
|
if let Some(ref mut h) = sha3_256 { h.update(data); }
|
||||||
|
if let Some(ref mut h) = sha3_384 { h.update(data); }
|
||||||
|
|
||||||
|
// Compute BT leaf hashes
|
||||||
|
if needs_bt {
|
||||||
|
if file_size == 0 {
|
||||||
|
bt_leaf_hashes.push([0u8; 32]);
|
||||||
|
} else {
|
||||||
|
for chunk in data.chunks(BT_BLOCK_SIZE) {
|
||||||
|
let mut block = [0u8; BT_BLOCK_SIZE];
|
||||||
|
block[..chunk.len()].copy_from_slice(chunk);
|
||||||
|
// Pad with zeros if needed (last block)
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&block);
|
||||||
|
bt_leaf_hashes.push(hasher.finalize().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pieces_root = compute_merkle_root(&bt_leaf_hashes);
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtPiecesRoot) {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtPiecesRoot.key().to_string(),
|
||||||
|
to_cid(&pieces_root, HashAlgorithm::BtPiecesRoot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtInfoHash) {
|
||||||
|
match compute_info_hash(filename, file_size, &pieces_root) {
|
||||||
|
Ok(info_hash) => {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtInfoHash.key().to_string(),
|
||||||
|
to_cid(&info_hash, HashAlgorithm::BtInfoHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to compute BT info hash: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Midhash256 from bytes
|
||||||
|
if enabled.contains(&HashAlgorithm::Midhash256) {
|
||||||
|
let hash = compute_midhash256_from_bytes(data);
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Midhash256.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Midhash256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize standard hashes
|
||||||
|
if let Some(h) = sha256 {
|
||||||
|
results.insert(HashAlgorithm::Sha256.key().to_string(), to_cid(&h.finalize(), HashAlgorithm::Sha256));
|
||||||
|
}
|
||||||
|
if let Some(h) = sha1 {
|
||||||
|
results.insert(HashAlgorithm::Sha1.key().to_string(), to_cid(&h.finalize(), HashAlgorithm::Sha1));
|
||||||
|
}
|
||||||
|
if let Some(h) = md5 {
|
||||||
|
results.insert(HashAlgorithm::Md5.key().to_string(), to_cid(&h.finalize(), HashAlgorithm::Md5));
|
||||||
|
}
|
||||||
|
if let Some(h) = crc32 {
|
||||||
|
results.insert(HashAlgorithm::Crc32.key().to_string(), to_cid(&h.finalize().to_be_bytes(), HashAlgorithm::Crc32));
|
||||||
|
}
|
||||||
|
if let Some(h) = sha3_256 {
|
||||||
|
results.insert(HashAlgorithm::Sha3_256.key().to_string(), to_cid(&h.finalize(), HashAlgorithm::Sha3_256));
|
||||||
|
}
|
||||||
|
if let Some(h) = sha3_384 {
|
||||||
|
results.insert(HashAlgorithm::Sha3_384.key().to_string(), to_cid(&h.finalize(), HashAlgorithm::Sha3_384));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute midhash256 from bytes (same algorithm as file version)
|
||||||
|
fn compute_midhash256_from_bytes(data: &[u8]) -> [u8; 32] {
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
|
const SAMPLE_SIZE: usize = 1024 * 1024; // 1MB
|
||||||
|
let file_size = data.len();
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
|
// Always include file size
|
||||||
|
hasher.update(file_size.to_le_bytes());
|
||||||
|
|
||||||
|
if file_size <= SAMPLE_SIZE {
|
||||||
|
// Small file: hash entire content
|
||||||
|
hasher.update(data);
|
||||||
|
} else {
|
||||||
|
// Large file: hash middle 1MB
|
||||||
|
let middle_start = (file_size - SAMPLE_SIZE) / 2;
|
||||||
|
hasher.update(&data[middle_start..middle_start + SAMPLE_SIZE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_file_standard() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"hello world").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256, HashAlgorithm::Md5]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = hash_file(file.path(), &enabled, 1024).unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_sha2-256"));
|
||||||
|
assert!(results.contains_key("cid_md5"));
|
||||||
|
assert!(!results.contains_key("cid_sha1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_file_with_midhash() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"test content for midhash").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256, HashAlgorithm::Midhash256]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = hash_file(file.path(), &enabled, 1024).unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_sha2-256"));
|
||||||
|
assert!(results.contains_key("cid_midhash256"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_file_with_bittorrent() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"test content for bittorrent").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::BtPiecesRoot, HashAlgorithm::BtInfoHash]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = hash_file(file.path(), &enabled, 1024).unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_bt_pieces_root"));
|
||||||
|
assert!(results.contains_key("cid_bt_info_hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_file_all_algorithms() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"comprehensive test content").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = HashAlgorithm::all().into_iter().collect();
|
||||||
|
|
||||||
|
let results = hash_file(file.path(), &enabled, 1024).unwrap();
|
||||||
|
|
||||||
|
// All 9 algorithms should be present
|
||||||
|
assert!(results.contains_key("cid_sha2-256"));
|
||||||
|
assert!(results.contains_key("cid_sha1"));
|
||||||
|
assert!(results.contains_key("cid_md5"));
|
||||||
|
assert!(results.contains_key("cid_crc32"));
|
||||||
|
assert!(results.contains_key("cid_sha3-256"));
|
||||||
|
assert!(results.contains_key("cid_sha3-384"));
|
||||||
|
assert!(results.contains_key("cid_midhash256"));
|
||||||
|
assert!(results.contains_key("cid_bt_pieces_root"));
|
||||||
|
assert!(results.contains_key("cid_bt_info_hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bt_hashes_match_standalone() {
|
||||||
|
// Verify that single-pass BT hashes match the standalone implementation
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
// Use data larger than one BT block to test multi-block handling
|
||||||
|
let data = vec![0xab; BT_BLOCK_SIZE * 2 + 1000];
|
||||||
|
file.write_all(&data).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
// Compute via single-pass
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::BtPiecesRoot, HashAlgorithm::BtInfoHash]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let single_pass_results = hash_file(file.path(), &enabled, 1024 * 1024).unwrap();
|
||||||
|
|
||||||
|
// Compute via standalone (which still exists for comparison)
|
||||||
|
use super::super::btih_v2::compute_btv2_hashes;
|
||||||
|
let standalone = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
let standalone_pieces_root = to_cid(&standalone.pieces_root, HashAlgorithm::BtPiecesRoot);
|
||||||
|
let standalone_info_hash = to_cid(&standalone.info_hash, HashAlgorithm::BtInfoHash);
|
||||||
|
|
||||||
|
// Results should match
|
||||||
|
assert_eq!(
|
||||||
|
single_pass_results.get("cid_bt_pieces_root").unwrap(),
|
||||||
|
&standalone_pieces_root,
|
||||||
|
"Pieces root mismatch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
single_pass_results.get("cid_bt_info_hash").unwrap(),
|
||||||
|
&standalone_info_hash,
|
||||||
|
"Info hash mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_file_bt() {
|
||||||
|
let file = NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::BtPiecesRoot, HashAlgorithm::BtInfoHash]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let results = hash_file(file.path(), &enabled, 1024).unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_bt_pieces_root"));
|
||||||
|
assert!(results.contains_key("cid_bt_info_hash"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! BitTorrent v2 Hash Computation (BEP 52)
|
||||||
|
//!
|
||||||
|
//! Computes two hashes for BitTorrent v2 compatibility:
|
||||||
|
//! - pieces_root: Merkle tree root of 16KB block hashes
|
||||||
|
//! - info_hash: SHA-256 of bencoded info dictionary
|
||||||
|
//!
|
||||||
|
//! Reference: https://www.bittorrent.org/beps/bep_0052.html
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bendy::encoding::{AsString, SingleItemEncoder, ToBencode};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Block size for BitTorrent v2 (16 KiB)
|
||||||
|
pub const BT_BLOCK_SIZE: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Piece size (must be power of 2, minimum 16 KiB)
|
||||||
|
/// We use 16 KiB for maximum granularity
|
||||||
|
const PIECE_SIZE: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Result of BitTorrent v2 hash computation
|
||||||
|
pub struct BtV2Hashes {
|
||||||
|
/// Merkle tree root of file pieces (32 bytes)
|
||||||
|
pub pieces_root: [u8; 32],
|
||||||
|
/// SHA-256 of bencoded info dict (32 bytes)
|
||||||
|
pub info_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute BitTorrent v2 hashes for a file
|
||||||
|
pub fn compute_btv2_hashes(path: &Path) -> Result<BtV2Hashes> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
let file_size = metadata.len();
|
||||||
|
|
||||||
|
// Get filename for info dict
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Compute leaf hashes (hash of each 16KB block)
|
||||||
|
let leaf_hashes = compute_leaf_hashes(&file, file_size)?;
|
||||||
|
|
||||||
|
// Build merkle tree and get root
|
||||||
|
let pieces_root = compute_merkle_root(&leaf_hashes);
|
||||||
|
|
||||||
|
// Compute info hash
|
||||||
|
let info_hash = compute_info_hash(&filename, file_size, &pieces_root)?;
|
||||||
|
|
||||||
|
Ok(BtV2Hashes {
|
||||||
|
pieces_root,
|
||||||
|
info_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute SHA-256 hash of each 16KB block
|
||||||
|
fn compute_leaf_hashes(file: &File, file_size: u64) -> Result<Vec<[u8; 32]>> {
|
||||||
|
if file_size == 0 {
|
||||||
|
// Empty file: single leaf of all zeros
|
||||||
|
return Ok(vec![[0u8; 32]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reader = BufReader::with_capacity(BT_BLOCK_SIZE * 4, file);
|
||||||
|
let mut buffer = vec![0u8; BT_BLOCK_SIZE];
|
||||||
|
let mut leaf_hashes = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = reader.read(&mut buffer)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad last block with zeros if needed (as per BEP 52)
|
||||||
|
if bytes_read < BT_BLOCK_SIZE {
|
||||||
|
buffer[bytes_read..].fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&buffer[..BT_BLOCK_SIZE]);
|
||||||
|
let hash: [u8; 32] = hasher.finalize().into();
|
||||||
|
leaf_hashes.push(hash);
|
||||||
|
|
||||||
|
// If this was a partial read, we're done
|
||||||
|
if bytes_read < BT_BLOCK_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(leaf_hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute Merkle tree root from leaf hashes
|
||||||
|
///
|
||||||
|
/// BEP 52 merkle tree:
|
||||||
|
/// - Leaves are SHA-256 hashes of 16KB blocks
|
||||||
|
/// - Internal nodes are SHA-256(left || right)
|
||||||
|
/// - Tree is padded to power of 2 with zero hashes
|
||||||
|
pub fn compute_merkle_root(leaf_hashes: &[[u8; 32]]) -> [u8; 32] {
|
||||||
|
if leaf_hashes.is_empty() {
|
||||||
|
return [0u8; 32];
|
||||||
|
}
|
||||||
|
|
||||||
|
if leaf_hashes.len() == 1 {
|
||||||
|
return leaf_hashes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad to next power of 2
|
||||||
|
let target_size = leaf_hashes.len().next_power_of_two();
|
||||||
|
let mut current_level: Vec<[u8; 32]> = leaf_hashes.to_vec();
|
||||||
|
|
||||||
|
// Pad with zero hashes
|
||||||
|
while current_level.len() < target_size {
|
||||||
|
current_level.push([0u8; 32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree bottom-up
|
||||||
|
while current_level.len() > 1 {
|
||||||
|
let mut next_level = Vec::with_capacity(current_level.len() / 2);
|
||||||
|
|
||||||
|
for chunk in current_level.chunks(2) {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&chunk[0]);
|
||||||
|
hasher.update(&chunk[1]);
|
||||||
|
let hash: [u8; 32] = hasher.finalize().into();
|
||||||
|
next_level.push(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_level = next_level;
|
||||||
|
}
|
||||||
|
|
||||||
|
current_level[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info dictionary structure for bencoding
|
||||||
|
struct InfoDict<'a> {
|
||||||
|
file_tree: FileTree<'a>,
|
||||||
|
meta_version: i64,
|
||||||
|
name: &'a str,
|
||||||
|
piece_length: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File tree structure (single file case)
|
||||||
|
struct FileTree<'a> {
|
||||||
|
filename: &'a str,
|
||||||
|
length: i64,
|
||||||
|
pieces_root: &'a [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToBencode for InfoDict<'_> {
|
||||||
|
const MAX_DEPTH: usize = 5;
|
||||||
|
|
||||||
|
fn encode(&self, encoder: SingleItemEncoder) -> Result<(), bendy::encoding::Error> {
|
||||||
|
encoder.emit_dict(|mut e| {
|
||||||
|
// file tree (required for v2)
|
||||||
|
e.emit_pair(b"file tree", &self.file_tree)?;
|
||||||
|
// meta version (2 for v2-only torrents)
|
||||||
|
e.emit_pair(b"meta version", self.meta_version)?;
|
||||||
|
// name
|
||||||
|
e.emit_pair(b"name", self.name)?;
|
||||||
|
// piece length
|
||||||
|
e.emit_pair(b"piece length", self.piece_length)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToBencode for FileTree<'_> {
|
||||||
|
const MAX_DEPTH: usize = 4;
|
||||||
|
|
||||||
|
fn encode(&self, encoder: SingleItemEncoder) -> Result<(), bendy::encoding::Error> {
|
||||||
|
encoder.emit_dict(|mut e| {
|
||||||
|
// File tree is: { filename: { "": { length: N, pieces root: HASH } } }
|
||||||
|
e.emit_pair(self.filename.as_bytes(), FileEntry {
|
||||||
|
length: self.length,
|
||||||
|
pieces_root: self.pieces_root,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileEntry<'a> {
|
||||||
|
length: i64,
|
||||||
|
pieces_root: &'a [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToBencode for FileEntry<'_> {
|
||||||
|
const MAX_DEPTH: usize = 3;
|
||||||
|
|
||||||
|
fn encode(&self, encoder: SingleItemEncoder) -> Result<(), bendy::encoding::Error> {
|
||||||
|
encoder.emit_dict(|mut e| {
|
||||||
|
// Empty string key contains file attributes
|
||||||
|
e.emit_pair(b"", FileAttrs {
|
||||||
|
length: self.length,
|
||||||
|
pieces_root: self.pieces_root,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileAttrs<'a> {
|
||||||
|
length: i64,
|
||||||
|
pieces_root: &'a [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToBencode for FileAttrs<'_> {
|
||||||
|
const MAX_DEPTH: usize = 2;
|
||||||
|
|
||||||
|
fn encode(&self, encoder: SingleItemEncoder) -> Result<(), bendy::encoding::Error> {
|
||||||
|
encoder.emit_dict(|mut e| {
|
||||||
|
e.emit_pair(b"length", self.length)?;
|
||||||
|
e.emit_pair(b"pieces root", AsString(self.pieces_root))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute info hash (SHA-256 of bencoded info dict)
|
||||||
|
pub fn compute_info_hash(filename: &str, file_size: u64, pieces_root: &[u8; 32]) -> Result<[u8; 32]> {
|
||||||
|
let info = InfoDict {
|
||||||
|
file_tree: FileTree {
|
||||||
|
filename,
|
||||||
|
length: file_size as i64,
|
||||||
|
pieces_root,
|
||||||
|
},
|
||||||
|
meta_version: 2,
|
||||||
|
name: filename,
|
||||||
|
piece_length: PIECE_SIZE as i64,
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = info
|
||||||
|
.to_bencode()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to bencode info dict: {:?}", e))?;
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&encoded);
|
||||||
|
Ok(hasher.finalize().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format info hash as magnet link URN
|
||||||
|
///
|
||||||
|
/// Format: urn:btmh:1220<hex_hash>
|
||||||
|
/// Where 1220 = 0x12 (SHA-256 code) + 0x20 (32 bytes length)
|
||||||
|
pub fn format_magnet_urn(info_hash: &[u8; 32]) -> String {
|
||||||
|
let hex = info_hash
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect::<String>();
|
||||||
|
format!("urn:btmh:1220{}", hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format full magnet link
|
||||||
|
pub fn format_magnet_link(info_hash: &[u8; 32], filename: &str) -> String {
|
||||||
|
let urn = format_magnet_urn(info_hash);
|
||||||
|
let encoded_name = urlencoding_filename(filename);
|
||||||
|
format!("magnet:?xt={}&dn={}", urn, encoded_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple URL encoding for filename (spaces and special chars)
|
||||||
|
fn urlencoding_filename(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
|
||||||
|
' ' => "%20".to_string(),
|
||||||
|
_ => format!("%{:02X}", c as u8),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_file() {
|
||||||
|
let file = NamedTempFile::new().unwrap();
|
||||||
|
let hashes = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(hashes.pieces_root.len(), 32);
|
||||||
|
assert_eq!(hashes.info_hash.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_file() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"hello world").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hashes = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(hashes.pieces_root.len(), 32);
|
||||||
|
assert_eq!(hashes.info_hash.len(), 32);
|
||||||
|
|
||||||
|
// Verify determinism
|
||||||
|
let hashes2 = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
assert_eq!(hashes.pieces_root, hashes2.pieces_root);
|
||||||
|
assert_eq!(hashes.info_hash, hashes2.info_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exactly_one_block() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
let data = vec![0xab; BT_BLOCK_SIZE];
|
||||||
|
file.write_all(&data).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hashes = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
assert_eq!(hashes.pieces_root.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_blocks() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
// 3 full blocks + partial
|
||||||
|
let data = vec![0xcd; BT_BLOCK_SIZE * 3 + 100];
|
||||||
|
file.write_all(&data).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hashes = compute_btv2_hashes(file.path()).unwrap();
|
||||||
|
assert_eq!(hashes.pieces_root.len(), 32);
|
||||||
|
assert_eq!(hashes.info_hash.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merkle_root_single_leaf() {
|
||||||
|
let leaf = [0xab; 32];
|
||||||
|
let root = compute_merkle_root(&[leaf]);
|
||||||
|
assert_eq!(root, leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merkle_root_two_leaves() {
|
||||||
|
let leaf1 = [0x11; 32];
|
||||||
|
let leaf2 = [0x22; 32];
|
||||||
|
|
||||||
|
let root = compute_merkle_root(&[leaf1, leaf2]);
|
||||||
|
|
||||||
|
// Manually compute expected root
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&leaf1);
|
||||||
|
hasher.update(&leaf2);
|
||||||
|
let expected: [u8; 32] = hasher.finalize().into();
|
||||||
|
|
||||||
|
assert_eq!(root, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magnet_urn_format() {
|
||||||
|
let hash = [0u8; 32];
|
||||||
|
let urn = format_magnet_urn(&hash);
|
||||||
|
assert!(urn.starts_with("urn:btmh:1220"));
|
||||||
|
assert_eq!(urn.len(), "urn:btmh:1220".len() + 64); // 64 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magnet_link_format() {
|
||||||
|
let hash = [0xab; 32];
|
||||||
|
let link = format_magnet_link(&hash, "test file.mkv");
|
||||||
|
assert!(link.starts_with("magnet:?xt=urn:btmh:1220"));
|
||||||
|
assert!(link.contains("&dn=test%20file.mkv"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use crate::config::HashAlgorithm;
|
||||||
|
use cid::Cid;
|
||||||
|
use multihash::Multihash;
|
||||||
|
|
||||||
|
/// CID codec for raw binary data
|
||||||
|
const RAW_CODEC: u64 = 0x55;
|
||||||
|
|
||||||
|
/// Maximum multihash size (64 bytes for SHA3-512)
|
||||||
|
const MAX_HASH_SIZE: usize = 64;
|
||||||
|
|
||||||
|
/// Convert raw hash bytes to proper CIDv1 format
|
||||||
|
///
|
||||||
|
/// CID structure:
|
||||||
|
/// - Version: 1
|
||||||
|
/// - Codec: 0x55 (raw binary data)
|
||||||
|
/// - Multihash: hash_code (varint) + hash_length (varint) + hash_bytes
|
||||||
|
///
|
||||||
|
/// Returns base32lower encoded CID with 'b' multibase prefix
|
||||||
|
pub fn to_cid(hash: &[u8], algo: HashAlgorithm) -> String {
|
||||||
|
let hash_code = algo.multicodec();
|
||||||
|
|
||||||
|
// Create multihash: wrap hash bytes with algorithm code
|
||||||
|
let multihash: Multihash<MAX_HASH_SIZE> =
|
||||||
|
Multihash::wrap(hash_code, hash).expect("Hash size exceeds maximum");
|
||||||
|
|
||||||
|
// Create CIDv1 with raw codec
|
||||||
|
let cid = Cid::new_v1(RAW_CODEC, multihash);
|
||||||
|
|
||||||
|
// Return base32lower string (multibase 'b' prefix is default for CIDv1)
|
||||||
|
cid.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the multihash code (the algorithm) of a bare CID string, or None
|
||||||
|
/// if it doesn't parse. Used to tell which digests a record already carries
|
||||||
|
/// in its bare-CID key-set (cids/<cid>), since the algorithm is no longer in
|
||||||
|
/// a field name.
|
||||||
|
pub fn multicodec_of(cid_str: &str) -> Option<u64> {
|
||||||
|
Cid::try_from(cid_str).ok().map(|c| c.hash().code())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cid_format() {
|
||||||
|
// Test with a known SHA-256 hash
|
||||||
|
let hash = [0u8; 32]; // 32 zero bytes
|
||||||
|
let cid = to_cid(&hash, HashAlgorithm::Sha256);
|
||||||
|
|
||||||
|
// CIDv1 base32lower starts with 'b'
|
||||||
|
assert!(cid.starts_with('b'), "CID should start with 'b' multibase prefix");
|
||||||
|
|
||||||
|
// Verify we can parse it back
|
||||||
|
let parsed = Cid::try_from(cid.as_str()).expect("Should parse valid CID");
|
||||||
|
assert_eq!(parsed.version(), cid::Version::V1);
|
||||||
|
assert_eq!(parsed.codec(), RAW_CODEC);
|
||||||
|
assert_eq!(parsed.hash().code(), 0x12); // SHA-256 code
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_algorithms() {
|
||||||
|
let test_data = b"test data for hashing";
|
||||||
|
|
||||||
|
for algo in HashAlgorithm::all() {
|
||||||
|
// Create a fake hash of appropriate size
|
||||||
|
let hash_size = match algo {
|
||||||
|
HashAlgorithm::Sha256 | HashAlgorithm::Sha3_256 | HashAlgorithm::Midhash256 => 32,
|
||||||
|
HashAlgorithm::Sha1 => 20,
|
||||||
|
HashAlgorithm::Md5 => 16,
|
||||||
|
HashAlgorithm::Crc32 => 4,
|
||||||
|
HashAlgorithm::Sha3_384 => 48,
|
||||||
|
HashAlgorithm::BtPiecesRoot | HashAlgorithm::BtInfoHash => 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash = vec![0xab; hash_size];
|
||||||
|
let cid = to_cid(&hash, algo);
|
||||||
|
|
||||||
|
// All CIDs should start with 'b' (base32lower)
|
||||||
|
assert!(
|
||||||
|
cid.starts_with('b'),
|
||||||
|
"CID for {:?} should start with 'b'",
|
||||||
|
algo
|
||||||
|
);
|
||||||
|
|
||||||
|
// All CIDs should be parseable
|
||||||
|
let parsed = Cid::try_from(cid.as_str())
|
||||||
|
.expect(&format!("Should parse valid CID for {:?}", algo));
|
||||||
|
|
||||||
|
assert_eq!(parsed.version(), cid::Version::V1);
|
||||||
|
assert_eq!(parsed.codec(), RAW_CODEC);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.hash().code(),
|
||||||
|
algo.multicodec(),
|
||||||
|
"Multicodec mismatch for {:?}",
|
||||||
|
algo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cid_determinism() {
|
||||||
|
// Same hash should always produce same CID
|
||||||
|
let hash = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||||
|
let cid1 = to_cid(&hash, HashAlgorithm::Md5);
|
||||||
|
let cid2 = to_cid(&hash, HashAlgorithm::Md5);
|
||||||
|
assert_eq!(cid1, cid2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_algorithms_different_cids() {
|
||||||
|
// Same hash bytes with different algorithms should produce different CIDs
|
||||||
|
let hash = [0u8; 32];
|
||||||
|
let sha256_cid = to_cid(&hash, HashAlgorithm::Sha256);
|
||||||
|
let sha3_256_cid = to_cid(&hash, HashAlgorithm::Sha3_256);
|
||||||
|
assert_ne!(sha256_cid, sha3_256_cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! MidHash256 - O(1) file identification hash
|
||||||
|
//!
|
||||||
|
//! Computes a SHA-256 hash of the file size and middle portion,
|
||||||
|
//! providing constant-time file identification regardless of file size.
|
||||||
|
//!
|
||||||
|
//! Algorithm:
|
||||||
|
//! - Files ≤1MB: SHA-256(size_u64_be || entire_content)
|
||||||
|
//! - Files >1MB: SHA-256(size_u64_be || middle_1MB)
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Size threshold: files at or below this size are hashed entirely
|
||||||
|
const SIZE_THRESHOLD: u64 = 1024 * 1024; // 1MB
|
||||||
|
|
||||||
|
/// Sample size for files larger than threshold
|
||||||
|
const SAMPLE_SIZE: usize = 1024 * 1024; // 1MB
|
||||||
|
|
||||||
|
/// Compute midhash256 for a file
|
||||||
|
///
|
||||||
|
/// Returns the raw 32-byte SHA-256 hash
|
||||||
|
pub fn compute_midhash256(path: &Path) -> Result<[u8; 32]> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
let file_size = metadata.len();
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
|
// Always include file size as 8-byte big-endian prefix
|
||||||
|
hasher.update(file_size.to_be_bytes());
|
||||||
|
|
||||||
|
if file_size <= SIZE_THRESHOLD {
|
||||||
|
// Small file: hash entire content
|
||||||
|
let mut buffer = Vec::with_capacity(file_size as usize);
|
||||||
|
file.read_to_end(&mut buffer)?;
|
||||||
|
hasher.update(&buffer);
|
||||||
|
} else {
|
||||||
|
// Large file: hash middle 1MB
|
||||||
|
let middle_start = (file_size - SAMPLE_SIZE as u64) / 2;
|
||||||
|
file.seek(SeekFrom::Start(middle_start))?;
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; SAMPLE_SIZE];
|
||||||
|
file.read_exact(&mut buffer)?;
|
||||||
|
hasher.update(&buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
Ok(hash.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_file() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"hello world").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hash = compute_midhash256(file.path()).unwrap();
|
||||||
|
assert_eq!(hash.len(), 32);
|
||||||
|
|
||||||
|
// Same content should produce same hash
|
||||||
|
let hash2 = compute_midhash256(file.path()).unwrap();
|
||||||
|
assert_eq!(hash, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_sizes_different_hashes() {
|
||||||
|
// Two files with same content but different sizes should have different hashes
|
||||||
|
// because we include the size in the hash
|
||||||
|
let mut file1 = NamedTempFile::new().unwrap();
|
||||||
|
file1.write_all(b"hello").unwrap();
|
||||||
|
file1.flush().unwrap();
|
||||||
|
|
||||||
|
let mut file2 = NamedTempFile::new().unwrap();
|
||||||
|
file2.write_all(b"hello world").unwrap();
|
||||||
|
file2.flush().unwrap();
|
||||||
|
|
||||||
|
let hash1 = compute_midhash256(file1.path()).unwrap();
|
||||||
|
let hash2 = compute_midhash256(file2.path()).unwrap();
|
||||||
|
assert_ne!(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_file() {
|
||||||
|
let file = NamedTempFile::new().unwrap();
|
||||||
|
let hash = compute_midhash256(file.path()).unwrap();
|
||||||
|
assert_eq!(hash.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exactly_1mb_file() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
let data = vec![0xab; SIZE_THRESHOLD as usize];
|
||||||
|
file.write_all(&data).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hash = compute_midhash256(file.path()).unwrap();
|
||||||
|
assert_eq!(hash.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_large_file_middle_sampling() {
|
||||||
|
// Create a file larger than 1MB with known content
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
// Write 2MB: first 512KB of 'A', middle 1MB of 'B', last 512KB of 'C'
|
||||||
|
let first_half = vec![b'A'; 512 * 1024];
|
||||||
|
let middle = vec![b'B'; 1024 * 1024];
|
||||||
|
let last_half = vec![b'C'; 512 * 1024];
|
||||||
|
|
||||||
|
file.write_all(&first_half).unwrap();
|
||||||
|
file.write_all(&middle).unwrap();
|
||||||
|
file.write_all(&last_half).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let hash = compute_midhash256(file.path()).unwrap();
|
||||||
|
assert_eq!(hash.len(), 32);
|
||||||
|
|
||||||
|
// Create another file with same middle but different edges
|
||||||
|
let mut file2 = NamedTempFile::new().unwrap();
|
||||||
|
let first_half2 = vec![b'X'; 512 * 1024];
|
||||||
|
let last_half2 = vec![b'Y'; 512 * 1024];
|
||||||
|
|
||||||
|
file2.write_all(&first_half2).unwrap();
|
||||||
|
file2.write_all(&middle).unwrap();
|
||||||
|
file2.write_all(&last_half2).unwrap();
|
||||||
|
file2.flush().unwrap();
|
||||||
|
|
||||||
|
let hash2 = compute_midhash256(file2.path()).unwrap();
|
||||||
|
|
||||||
|
// Both files have same size and same middle 1MB, so hashes should match
|
||||||
|
assert_eq!(hash, hash2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
mod algorithms;
|
||||||
|
pub mod btih_v2;
|
||||||
|
mod cid;
|
||||||
|
pub mod midhash256;
|
||||||
|
mod stream;
|
||||||
|
|
||||||
|
pub use algorithms::{hash_bytes, hash_file};
|
||||||
|
pub use stream::hash_stream;
|
||||||
|
pub use btih_v2::{
|
||||||
|
compute_btv2_hashes, compute_info_hash, compute_merkle_root, format_magnet_link,
|
||||||
|
format_magnet_urn, BtV2Hashes, BT_BLOCK_SIZE,
|
||||||
|
};
|
||||||
|
pub use cid::{multicodec_of, to_cid};
|
||||||
|
pub use midhash256::compute_midhash256;
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
use crate::config::HashAlgorithm;
|
||||||
|
use anyhow::Result;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::{Stream, StreamExt};
|
||||||
|
use md5::Md5;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sha3::{Sha3_256, Sha3_384};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use super::btih_v2::{compute_info_hash, compute_merkle_root, BT_BLOCK_SIZE};
|
||||||
|
use super::cid::to_cid;
|
||||||
|
|
||||||
|
/// Hash data from an async byte stream
|
||||||
|
///
|
||||||
|
/// Processes chunks as they arrive from the network, maintaining O(1) memory
|
||||||
|
/// regardless of file size. All hashers are updated incrementally.
|
||||||
|
pub async fn hash_stream<S, E>(
|
||||||
|
mut stream: S,
|
||||||
|
filename: &str,
|
||||||
|
file_size: Option<u64>,
|
||||||
|
enabled: &HashSet<HashAlgorithm>,
|
||||||
|
) -> Result<HashMap<String, String>>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Bytes, E>> + Unpin,
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
let needs_bt = enabled.contains(&HashAlgorithm::BtPiecesRoot)
|
||||||
|
|| enabled.contains(&HashAlgorithm::BtInfoHash);
|
||||||
|
let needs_midhash = enabled.contains(&HashAlgorithm::Midhash256);
|
||||||
|
|
||||||
|
// Initialize standard hashers
|
||||||
|
let mut sha256 = enabled.contains(&HashAlgorithm::Sha256).then(Sha256::new);
|
||||||
|
let mut sha1 = enabled.contains(&HashAlgorithm::Sha1).then(Sha1::new);
|
||||||
|
let mut md5 = enabled.contains(&HashAlgorithm::Md5).then(Md5::new);
|
||||||
|
let mut crc32 = enabled
|
||||||
|
.contains(&HashAlgorithm::Crc32)
|
||||||
|
.then(crc32fast::Hasher::new);
|
||||||
|
let mut sha3_256 = enabled
|
||||||
|
.contains(&HashAlgorithm::Sha3_256)
|
||||||
|
.then(Sha3_256::new);
|
||||||
|
let mut sha3_384 = enabled
|
||||||
|
.contains(&HashAlgorithm::Sha3_384)
|
||||||
|
.then(Sha3_384::new);
|
||||||
|
|
||||||
|
// BT v2 state
|
||||||
|
let mut bt_leaf_hashes: Vec<[u8; 32]> = Vec::new();
|
||||||
|
let mut bt_block_buffer = vec![0u8; BT_BLOCK_SIZE];
|
||||||
|
let mut bt_block_offset = 0usize;
|
||||||
|
|
||||||
|
// Midhash256 state - we need to buffer the middle section
|
||||||
|
// This is the one algorithm that can't be purely streaming
|
||||||
|
const MIDHASH_SAMPLE_SIZE: usize = 1024 * 1024; // 1MB
|
||||||
|
let mut midhash_collector: Option<MidhashCollector> = if needs_midhash {
|
||||||
|
Some(MidhashCollector::new(file_size, MIDHASH_SAMPLE_SIZE))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut bytes_read: u64 = 0;
|
||||||
|
let known_size = file_size.unwrap_or(0);
|
||||||
|
|
||||||
|
// Process stream chunks as they arrive
|
||||||
|
while let Some(chunk_result) = stream.next().await {
|
||||||
|
let chunk = chunk_result.map_err(|e| anyhow::anyhow!("Stream error: {}", e))?;
|
||||||
|
let chunk = chunk.as_ref();
|
||||||
|
let chunk_start = bytes_read;
|
||||||
|
bytes_read += chunk.len() as u64;
|
||||||
|
|
||||||
|
// Update all standard hashers
|
||||||
|
if let Some(ref mut h) = sha256 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha1 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = md5 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = crc32 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha3_256 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
if let Some(ref mut h) = sha3_384 {
|
||||||
|
h.update(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BT v2: Process chunk into 16KB blocks
|
||||||
|
if needs_bt {
|
||||||
|
let mut chunk_offset = 0;
|
||||||
|
while chunk_offset < chunk.len() {
|
||||||
|
let space_in_block = BT_BLOCK_SIZE - bt_block_offset;
|
||||||
|
let bytes_to_copy = space_in_block.min(chunk.len() - chunk_offset);
|
||||||
|
|
||||||
|
bt_block_buffer[bt_block_offset..bt_block_offset + bytes_to_copy]
|
||||||
|
.copy_from_slice(&chunk[chunk_offset..chunk_offset + bytes_to_copy]);
|
||||||
|
|
||||||
|
bt_block_offset += bytes_to_copy;
|
||||||
|
chunk_offset += bytes_to_copy;
|
||||||
|
|
||||||
|
if bt_block_offset == BT_BLOCK_SIZE {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bt_block_buffer);
|
||||||
|
bt_leaf_hashes.push(hasher.finalize().into());
|
||||||
|
bt_block_offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Midhash: collect the middle section
|
||||||
|
if let Some(ref mut collector) = midhash_collector {
|
||||||
|
collector.process_chunk(chunk, chunk_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress logging
|
||||||
|
if known_size > 100 * 1024 * 1024 && bytes_read % (100 * 1024 * 1024) < chunk.len() as u64 {
|
||||||
|
let pct = (bytes_read as f64 / known_size as f64 * 100.0) as u32;
|
||||||
|
tracing::debug!(
|
||||||
|
"Stream hashing progress: {}% ({}/{})",
|
||||||
|
pct,
|
||||||
|
bytes_read,
|
||||||
|
known_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let actual_size = bytes_read;
|
||||||
|
|
||||||
|
// Finalize BT v2
|
||||||
|
if needs_bt {
|
||||||
|
if actual_size == 0 {
|
||||||
|
bt_leaf_hashes.push([0u8; 32]);
|
||||||
|
} else if bt_block_offset > 0 {
|
||||||
|
bt_block_buffer[bt_block_offset..].fill(0);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bt_block_buffer);
|
||||||
|
bt_leaf_hashes.push(hasher.finalize().into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pieces_root = compute_merkle_root(&bt_leaf_hashes);
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtPiecesRoot) {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtPiecesRoot.key().to_string(),
|
||||||
|
to_cid(&pieces_root, HashAlgorithm::BtPiecesRoot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled.contains(&HashAlgorithm::BtInfoHash) {
|
||||||
|
if let Ok(info_hash) = compute_info_hash(filename, actual_size, &pieces_root) {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::BtInfoHash.key().to_string(),
|
||||||
|
to_cid(&info_hash, HashAlgorithm::BtInfoHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize midhash256
|
||||||
|
if let Some(collector) = midhash_collector {
|
||||||
|
let hash = collector.finalize(actual_size);
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Midhash256.key().to_string(),
|
||||||
|
to_cid(&hash, HashAlgorithm::Midhash256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize standard hashes
|
||||||
|
if let Some(h) = sha256 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha256.key().to_string(),
|
||||||
|
to_cid(&h.finalize(), HashAlgorithm::Sha256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = sha1 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha1.key().to_string(),
|
||||||
|
to_cid(&h.finalize(), HashAlgorithm::Sha1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = md5 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Md5.key().to_string(),
|
||||||
|
to_cid(&h.finalize(), HashAlgorithm::Md5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = crc32 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Crc32.key().to_string(),
|
||||||
|
to_cid(&h.finalize().to_be_bytes(), HashAlgorithm::Crc32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = sha3_256 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha3_256.key().to_string(),
|
||||||
|
to_cid(&h.finalize(), HashAlgorithm::Sha3_256),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(h) = sha3_384 {
|
||||||
|
results.insert(
|
||||||
|
HashAlgorithm::Sha3_384.key().to_string(),
|
||||||
|
to_cid(&h.finalize(), HashAlgorithm::Sha3_384),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the middle section of a file for midhash256
|
||||||
|
///
|
||||||
|
/// If file size is known upfront, only buffers the exact middle 1MB.
|
||||||
|
/// If unknown, buffers more conservatively and extracts middle at the end.
|
||||||
|
struct MidhashCollector {
|
||||||
|
/// Sample size (1MB)
|
||||||
|
sample_size: usize,
|
||||||
|
/// Buffered data for middle section
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
/// Start offset of middle section (calculated from known size)
|
||||||
|
middle_start: Option<u64>,
|
||||||
|
/// Have we finished collecting?
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MidhashCollector {
|
||||||
|
fn new(known_size: Option<u64>, sample_size: usize) -> Self {
|
||||||
|
let middle_start = known_size.and_then(|size| {
|
||||||
|
if (size as usize) <= sample_size {
|
||||||
|
None // Small file, collect everything
|
||||||
|
} else {
|
||||||
|
Some((size - sample_size as u64) / 2)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-allocate based on whether we know the size
|
||||||
|
let capacity = if known_size.is_some() {
|
||||||
|
sample_size
|
||||||
|
} else {
|
||||||
|
// Unknown size - we'll need to buffer everything
|
||||||
|
// Start with a reasonable default
|
||||||
|
sample_size * 2
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
sample_size,
|
||||||
|
buffer: Vec::with_capacity(capacity),
|
||||||
|
middle_start,
|
||||||
|
done: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_chunk(&mut self, chunk: &[u8], chunk_start: u64) {
|
||||||
|
if self.done {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.middle_start {
|
||||||
|
Some(middle_start) => {
|
||||||
|
// We know the file size - only collect the middle section
|
||||||
|
let middle_end = middle_start + self.sample_size as u64;
|
||||||
|
let chunk_end = chunk_start + chunk.len() as u64;
|
||||||
|
|
||||||
|
// Check if this chunk overlaps with our target range
|
||||||
|
if chunk_end <= middle_start || chunk_start >= middle_end {
|
||||||
|
return; // No overlap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overlap within the chunk
|
||||||
|
let copy_start = if chunk_start < middle_start {
|
||||||
|
(middle_start - chunk_start) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let copy_end = if chunk_end > middle_end {
|
||||||
|
chunk.len() - (chunk_end - middle_end) as usize
|
||||||
|
} else {
|
||||||
|
chunk.len()
|
||||||
|
};
|
||||||
|
|
||||||
|
if copy_start < chunk.len() && copy_end > copy_start {
|
||||||
|
self.buffer.extend_from_slice(&chunk[copy_start..copy_end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.buffer.len() >= self.sample_size {
|
||||||
|
self.done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Unknown size or small file - collect everything
|
||||||
|
// We'll extract the middle at the end
|
||||||
|
self.buffer.extend_from_slice(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(self, actual_size: u64) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(actual_size.to_le_bytes());
|
||||||
|
|
||||||
|
if (actual_size as usize) <= self.sample_size {
|
||||||
|
// Small file: hash entire content
|
||||||
|
hasher.update(&self.buffer);
|
||||||
|
} else if self.middle_start.is_some() {
|
||||||
|
// We collected exactly the middle
|
||||||
|
hasher.update(&self.buffer);
|
||||||
|
} else {
|
||||||
|
// We collected everything, extract middle now
|
||||||
|
let middle_start = (self.buffer.len() - self.sample_size) / 2;
|
||||||
|
hasher.update(&self.buffer[middle_start..middle_start + self.sample_size]);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use futures_util::stream;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_hash_stream_basic() {
|
||||||
|
let data = b"hello world";
|
||||||
|
let chunks: Vec<Result<Bytes, std::io::Error>> =
|
||||||
|
vec![Ok(Bytes::from_static(data))];
|
||||||
|
let stream = stream::iter(chunks);
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256, HashAlgorithm::Md5]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = hash_stream(stream, "test.txt", Some(data.len() as u64), &enabled)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_sha2-256"));
|
||||||
|
assert!(results.contains_key("cid_md5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_hash_stream_chunked() {
|
||||||
|
// Split "hello world" into multiple chunks
|
||||||
|
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
|
||||||
|
Ok(Bytes::from_static(b"hello")),
|
||||||
|
Ok(Bytes::from_static(b" ")),
|
||||||
|
Ok(Bytes::from_static(b"world")),
|
||||||
|
];
|
||||||
|
let stream = stream::iter(chunks);
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256].into_iter().collect();
|
||||||
|
|
||||||
|
let results = hash_stream(stream, "test.txt", Some(11), &enabled)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Single chunk version
|
||||||
|
let single_chunks: Vec<Result<Bytes, std::io::Error>> =
|
||||||
|
vec![Ok(Bytes::from_static(b"hello world"))];
|
||||||
|
let single_stream = stream::iter(single_chunks);
|
||||||
|
|
||||||
|
let single_results = hash_stream(single_stream, "test.txt", Some(11), &enabled)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Results should match regardless of chunking
|
||||||
|
assert_eq!(
|
||||||
|
results.get("cid_sha2-256"),
|
||||||
|
single_results.get("cid_sha2-256")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_hash_stream_empty() {
|
||||||
|
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![];
|
||||||
|
let stream = stream::iter(chunks);
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256, HashAlgorithm::BtPiecesRoot]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = hash_stream(stream, "empty.txt", Some(0), &enabled)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(results.contains_key("cid_sha2-256"));
|
||||||
|
assert!(results.contains_key("cid_bt_pieces_root"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetaMesh Plugin: full-hash
|
|
||||||
*
|
|
||||||
* ============================================================================
|
|
||||||
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Each plugin container has exactly 3 mounts (2 for plugins without output):
|
|
||||||
*
|
|
||||||
* 1. /files (READ-ONLY) - Shared media files, read access only
|
|
||||||
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
|
|
||||||
* 3. /files/plugin/<id> (READ-WRITE) - Plugin output folder (if needed)
|
|
||||||
*
|
|
||||||
* This plugin (full-hash) only requires mounts 1 and 2:
|
|
||||||
* - /files (RO) - to read entire files for SHA-256 hashing
|
|
||||||
* - /cache (RW) - to cache computed hashes
|
|
||||||
*
|
|
||||||
* SECURITY: Plugins must NEVER write to /files directly.
|
|
||||||
* All write operations go to /cache or /files/plugin/<id> only.
|
|
||||||
*
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Fastify from 'fastify';
|
|
||||||
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
|
|
||||||
import { manifest, process as processFile } from './plugin.js';
|
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
|
||||||
let ready = false;
|
|
||||||
|
|
||||||
app.get('/health', async (): Promise<HealthResponse> => ({ status: 'healthy', ready, version: manifest.version }));
|
|
||||||
app.get('/manifest', async () => manifest);
|
|
||||||
app.post<{ Body: ConfigureRequest }>('/configure', async (request): Promise<ConfigureResponse> => {
|
|
||||||
console.log(`[${manifest.id}] Configuration updated`);
|
|
||||||
return { status: 'ok' };
|
|
||||||
});
|
|
||||||
app.post<{ Body: ProcessRequest }>('/process', async (request, reply) => {
|
|
||||||
const { taskId, cid, filePath, callbackUrl, metaCoreUrl } = request.body;
|
|
||||||
if (!taskId || !cid || !filePath || !callbackUrl || !metaCoreUrl) {
|
|
||||||
return reply.send({ status: 'rejected', error: 'Missing required fields' } as ProcessResponse);
|
|
||||||
}
|
|
||||||
processFile(request.body, async (payload: CallbackPayload) => {
|
|
||||||
try { await fetch(callbackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
|
|
||||||
catch (error) { console.error(`[${manifest.id}] Callback error:`, error); }
|
|
||||||
}).catch(console.error);
|
|
||||||
return reply.send({ status: 'accepted' } as ProcessResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT || '8080', 10);
|
|
||||||
app.listen({ port, host: '0.0.0.0' }).then(() => { ready = true; console.log(`[${manifest.id}] Listening on port ${port}`); });
|
|
||||||
process.on('SIGTERM', async () => { ready = false; await app.close(); process.exit(0); });
|
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
//! MetaMesh Fast Full Hash Plugin
|
||||||
|
//!
|
||||||
|
//! High-performance file hashing with multiple algorithms in a single I/O pass.
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
|
pub mod hasher;
|
||||||
|
pub mod webdav;
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use metamesh_plugin_fast_full_hash::api::{configure, health, manifest, process, ProcessState};
|
||||||
|
use metamesh_plugin_fast_full_hash::cache::CacheManager;
|
||||||
|
use metamesh_plugin_fast_full_hash::config::create_shared_config;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Get configuration from environment
|
||||||
|
let port: u16 = std::env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(8080);
|
||||||
|
|
||||||
|
let cache_path = std::env::var("CACHE_PATH").unwrap_or_else(|_| "/cache".to_string());
|
||||||
|
|
||||||
|
// Initialize shared state
|
||||||
|
let config = create_shared_config();
|
||||||
|
let cache = Arc::new(CacheManager::new(&cache_path));
|
||||||
|
|
||||||
|
// Load existing cache
|
||||||
|
if let Err(e) = cache.load().await {
|
||||||
|
tracing::warn!("Failed to load cache: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shared app state
|
||||||
|
let app_state = Arc::new(ProcessState {
|
||||||
|
config,
|
||||||
|
cache,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build router
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/manifest", get(manifest))
|
||||||
|
.route("/configure", post(configure))
|
||||||
|
.route("/process", post(process))
|
||||||
|
.with_state(app_state)
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let addr = format!("0.0.0.0:{}", port);
|
||||||
|
tracing::info!("Starting fast-full-hash plugin on {}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* meta-core API Client for writing metadata
|
|
||||||
* Fails gracefully if meta-core is unavailable (logs warning, returns without throwing)
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class MetaCoreClient {
|
|
||||||
constructor(private baseUrl: string) {}
|
|
||||||
|
|
||||||
private async safeFetch(url: string, options: RequestInit): Promise<Response | null> {
|
|
||||||
try {
|
|
||||||
return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setProperty(hashId: string, key: string, value: string): Promise<void> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ value }),
|
|
||||||
});
|
|
||||||
if (response && !response.ok) {
|
|
||||||
console.warn(`[MetaCoreClient] Failed to set property: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProperty(hashId: string, key: string): Promise<string | null> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' });
|
|
||||||
if (!response || response.status === 404) return null;
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json() as { value?: string };
|
|
||||||
return data.value ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<void> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(metadata),
|
|
||||||
});
|
|
||||||
if (response && !response.ok) {
|
|
||||||
console.warn(`[MetaCoreClient] Failed to merge metadata: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteProperty(hashId: string, key: string): Promise<void> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' });
|
|
||||||
if (response && !response.ok && response.status !== 404) {
|
|
||||||
console.warn(`[MetaCoreClient] Failed to delete property: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addToSet(hashId: string, key: string, value: string): Promise<void> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ value }),
|
|
||||||
});
|
|
||||||
if (response && !response.ok) {
|
|
||||||
console.warn(`[MetaCoreClient] Failed to add to set: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMetadata(hashId: string): Promise<Record<string, string>> {
|
|
||||||
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' });
|
|
||||||
if (!response || response.status === 404) return {};
|
|
||||||
if (!response.ok) return {};
|
|
||||||
const data = await response.json() as { metadata?: Record<string, string> };
|
|
||||||
return data.metadata ?? {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Full Hash Plugin
|
|
||||||
*
|
|
||||||
* Computes the SHA-256 hash of the entire file.
|
|
||||||
* This is a background queue plugin - it's slow but provides
|
|
||||||
* definitive content identification.
|
|
||||||
*
|
|
||||||
* Output:
|
|
||||||
* - cid_sha2-256: SHA-256 hash formatted as CID
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createReadStream } from 'fs';
|
|
||||||
import { createHash } from 'crypto';
|
|
||||||
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
|
|
||||||
import { MetaCoreClient } from './meta-core-client.js';
|
|
||||||
|
|
||||||
export const manifest: PluginManifest = {
|
|
||||||
id: 'full-hash',
|
|
||||||
name: 'Full File Hash',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Computes SHA-256 hash of the entire file for content identification',
|
|
||||||
author: 'MetaMesh',
|
|
||||||
dependencies: [],
|
|
||||||
priority: 100,
|
|
||||||
color: '#9C27B0',
|
|
||||||
defaultQueue: 'background',
|
|
||||||
timeout: 300000, // 5 minutes for large files
|
|
||||||
schema: {
|
|
||||||
'cid_sha2-256': {
|
|
||||||
label: 'SHA-256 Hash (CID)',
|
|
||||||
type: 'cid',
|
|
||||||
readonly: true,
|
|
||||||
hint: 'Content identifier using SHA-256 hash of the entire file',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute SHA-256 hash of a file using streaming
|
|
||||||
*/
|
|
||||||
async function computeSHA256(filePath: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hash = createHash('sha256');
|
|
||||||
const stream = createReadStream(filePath);
|
|
||||||
stream.on('data', (data) => hash.update(data));
|
|
||||||
stream.on('end', () => resolve(hash.digest('hex')));
|
|
||||||
stream.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function process(
|
|
||||||
request: ProcessRequest,
|
|
||||||
sendCallback: (payload: CallbackPayload) => Promise<void>
|
|
||||||
): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const metaCore = new MetaCoreClient(request.metaCoreUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { cid, filePath, existingMeta } = request;
|
|
||||||
|
|
||||||
// Skip if already computed
|
|
||||||
if (existingMeta?.['cid_sha2-256']) {
|
|
||||||
console.log(`[full-hash] SHA-256 already computed for ${filePath}, skipping`);
|
|
||||||
await sendCallback({
|
|
||||||
taskId: request.taskId,
|
|
||||||
status: 'skipped',
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
reason: 'Already computed',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[full-hash] Computing SHA-256 hash for ${filePath}`);
|
|
||||||
|
|
||||||
// Compute SHA-256 hash
|
|
||||||
const sha256Hex = await computeSHA256(filePath);
|
|
||||||
|
|
||||||
// Format as CID-like string (matching old format)
|
|
||||||
const sha256Cid = `sha256-${sha256Hex}`;
|
|
||||||
|
|
||||||
await metaCore.setProperty(cid, 'cid_sha2-256', sha256Cid);
|
|
||||||
console.log(`[full-hash] SHA-256 computed: ${sha256Cid.substring(0, 20)}...`);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
await sendCallback({
|
|
||||||
taskId: request.taskId,
|
|
||||||
status: 'completed',
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(`[full-hash] Error computing SHA-256 hash for ${request.filePath}:`, errorMessage);
|
|
||||||
|
|
||||||
await sendCallback({
|
|
||||||
taskId: request.taskId,
|
|
||||||
status: 'failed',
|
|
||||||
duration,
|
|
||||||
error: errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetaMesh Plugin Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PluginManifest {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
description?: string;
|
|
||||||
author?: string;
|
|
||||||
dependencies?: string[];
|
|
||||||
schema?: Record<string, SchemaField>;
|
|
||||||
config?: Record<string, ConfigField>;
|
|
||||||
priority?: number;
|
|
||||||
color?: string;
|
|
||||||
defaultQueue?: 'fast' | 'background';
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchemaField {
|
|
||||||
label: string;
|
|
||||||
type?: 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid';
|
|
||||||
readonly?: boolean;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigField {
|
|
||||||
type: 'string' | 'number' | 'boolean' | 'select';
|
|
||||||
label?: string;
|
|
||||||
required?: boolean;
|
|
||||||
default?: unknown;
|
|
||||||
secret?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthResponse {
|
|
||||||
status: 'healthy' | 'unhealthy';
|
|
||||||
ready: boolean;
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessRequest {
|
|
||||||
taskId: string;
|
|
||||||
cid: string;
|
|
||||||
filePath: string;
|
|
||||||
callbackUrl: string;
|
|
||||||
metaCoreUrl: string;
|
|
||||||
existingMeta?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessResponse {
|
|
||||||
status: 'accepted' | 'rejected';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CallbackPayload {
|
|
||||||
taskId: string;
|
|
||||||
status: 'completed' | 'failed' | 'skipped';
|
|
||||||
duration: number;
|
|
||||||
error?: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigureRequest {
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigureResponse {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::Stream;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
/// WebDAV client for streaming file access
|
||||||
|
pub struct WebDavClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebDavClient {
|
||||||
|
pub fn new(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build full URL for a file path
|
||||||
|
/// The input path is a filesystem path like /files/watch/movie.mkv
|
||||||
|
/// The WebDAV server exposes /files as /webdav, so we strip /files prefix
|
||||||
|
fn file_url(&self, path: &str) -> String {
|
||||||
|
// Strip /files prefix since WebDAV mounts /files at the base URL
|
||||||
|
let clean_path = path
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.strip_prefix("files/")
|
||||||
|
.unwrap_or(path.trim_start_matches('/'));
|
||||||
|
format!("{}/{}", self.base_url, clean_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream file contents via HTTP GET
|
||||||
|
pub async fn get_file(&self, path: &str) -> Result<WebDavReader> {
|
||||||
|
let url = self.file_url(path);
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to GET {}", url))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
anyhow::bail!("WebDAV GET failed: {} for {}", response.status(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length = response.content_length();
|
||||||
|
|
||||||
|
Ok(WebDavReader {
|
||||||
|
inner: response,
|
||||||
|
size: content_length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file size without downloading content (HEAD request)
|
||||||
|
pub async fn get_size(&self, path: &str) -> Result<u64> {
|
||||||
|
let url = self.file_url(path);
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.head(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to HEAD {}", url))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
anyhow::bail!("WebDAV HEAD failed: {} for {}", response.status(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.content_length()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No Content-Length header for {}", url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async reader wrapper for WebDAV response
|
||||||
|
pub struct WebDavReader {
|
||||||
|
inner: reqwest::Response,
|
||||||
|
size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebDavReader {
|
||||||
|
pub fn size(&self) -> Option<u64> {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read all bytes (for smaller files or when you need the whole thing)
|
||||||
|
pub async fn bytes(self) -> Result<Vec<u8>> {
|
||||||
|
self.inner.bytes().await
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.context("Failed to read response bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a stream of bytes for streaming processing
|
||||||
|
/// This is the efficient path - data is processed as it arrives
|
||||||
|
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, reqwest::Error>> {
|
||||||
|
self.inner.bytes_stream()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the underlying response for streaming
|
||||||
|
pub fn into_response(self) -> reqwest::Response {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_url() {
|
||||||
|
let client = WebDavClient::new("http://localhost/webdav");
|
||||||
|
// /files prefix should be stripped
|
||||||
|
assert_eq!(client.file_url("/files/watch/test.mkv"), "http://localhost/webdav/watch/test.mkv");
|
||||||
|
assert_eq!(client.file_url("files/watch/test.mkv"), "http://localhost/webdav/watch/test.mkv");
|
||||||
|
|
||||||
|
// Trailing slash in base URL should be handled
|
||||||
|
let client2 = WebDavClient::new("http://localhost/webdav/");
|
||||||
|
assert_eq!(client2.file_url("/files/watch/test.mkv"), "http://localhost/webdav/watch/test.mkv");
|
||||||
|
|
||||||
|
// Paths without /files prefix should work too
|
||||||
|
assert_eq!(client.file_url("watch/test.mkv"), "http://localhost/webdav/watch/test.mkv");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Compute ground truth hash values for test reference files
|
||||||
|
# Uses standard Unix tools to verify our implementation
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Reference file: Sintel trailer (Creative Commons licensed)
|
||||||
|
# Source: https://durian.blender.org/
|
||||||
|
REFERENCE_URL="https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4"
|
||||||
|
REFERENCE_FILE="sintel_trailer-480p.mp4"
|
||||||
|
EXPECTED_SIZE=23014356 # bytes
|
||||||
|
|
||||||
|
echo "=== Ground Truth Hash Computation ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Download reference file if not present
|
||||||
|
if [ ! -f "$REFERENCE_FILE" ]; then
|
||||||
|
echo "Downloading reference file: $REFERENCE_FILE"
|
||||||
|
curl -L -o "$REFERENCE_FILE" "$REFERENCE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify file size
|
||||||
|
ACTUAL_SIZE=$(stat -f%z "$REFERENCE_FILE" 2>/dev/null || stat -c%s "$REFERENCE_FILE" 2>/dev/null)
|
||||||
|
if [ "$ACTUAL_SIZE" != "$EXPECTED_SIZE" ]; then
|
||||||
|
echo "WARNING: File size mismatch! Expected $EXPECTED_SIZE, got $ACTUAL_SIZE"
|
||||||
|
echo "The file may have changed. Please verify manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Reference file: $REFERENCE_FILE"
|
||||||
|
echo "File size: $ACTUAL_SIZE bytes"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Computing Standard Hashes ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# SHA-256
|
||||||
|
echo -n "SHA-256: "
|
||||||
|
sha256sum "$REFERENCE_FILE" | cut -d' ' -f1
|
||||||
|
|
||||||
|
# SHA-1
|
||||||
|
echo -n "SHA-1: "
|
||||||
|
sha1sum "$REFERENCE_FILE" | cut -d' ' -f1
|
||||||
|
|
||||||
|
# MD5
|
||||||
|
echo -n "MD5: "
|
||||||
|
md5sum "$REFERENCE_FILE" | cut -d' ' -f1
|
||||||
|
|
||||||
|
# CRC32 (using Python as crc32 tool varies)
|
||||||
|
echo -n "CRC32: "
|
||||||
|
python3 -c "
|
||||||
|
import binascii
|
||||||
|
with open('$REFERENCE_FILE', 'rb') as f:
|
||||||
|
crc = 0
|
||||||
|
while chunk := f.read(65536):
|
||||||
|
crc = binascii.crc32(chunk, crc)
|
||||||
|
print(format(crc & 0xffffffff, '08x'))
|
||||||
|
"
|
||||||
|
|
||||||
|
# SHA3-256 (using openssl if available, otherwise Python)
|
||||||
|
echo -n "SHA3-256: "
|
||||||
|
if command -v openssl &> /dev/null && openssl dgst -sha3-256 /dev/null &> /dev/null 2>&1; then
|
||||||
|
openssl dgst -sha3-256 "$REFERENCE_FILE" | awk '{print $2}'
|
||||||
|
else
|
||||||
|
python3 -c "
|
||||||
|
import hashlib
|
||||||
|
with open('$REFERENCE_FILE', 'rb') as f:
|
||||||
|
h = hashlib.sha3_256()
|
||||||
|
while chunk := f.read(65536):
|
||||||
|
h.update(chunk)
|
||||||
|
print(h.hexdigest())
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SHA3-384
|
||||||
|
echo -n "SHA3-384: "
|
||||||
|
if command -v openssl &> /dev/null && openssl dgst -sha3-384 /dev/null &> /dev/null 2>&1; then
|
||||||
|
openssl dgst -sha3-384 "$REFERENCE_FILE" | awk '{print $2}'
|
||||||
|
else
|
||||||
|
python3 -c "
|
||||||
|
import hashlib
|
||||||
|
with open('$REFERENCE_FILE', 'rb') as f:
|
||||||
|
h = hashlib.sha3_384()
|
||||||
|
while chunk := f.read(65536):
|
||||||
|
h.update(chunk)
|
||||||
|
print(h.hexdigest())
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Computing MidHash256 ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# MidHash256: SHA-256(size_u64_be || middle_1MB)
|
||||||
|
python3 << 'PYTHON_EOF'
|
||||||
|
import hashlib
|
||||||
|
import struct
|
||||||
|
import os
|
||||||
|
|
||||||
|
filename = 'sintel_trailer-480p.mp4'
|
||||||
|
size = os.path.getsize(filename)
|
||||||
|
THRESHOLD = 1024 * 1024 # 1MB
|
||||||
|
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
# Add size as 8-byte big-endian
|
||||||
|
hasher.update(struct.pack('>Q', size))
|
||||||
|
|
||||||
|
if size <= THRESHOLD:
|
||||||
|
# Small file: hash entire content
|
||||||
|
hasher.update(f.read())
|
||||||
|
else:
|
||||||
|
# Large file: hash middle 1MB
|
||||||
|
middle_start = (size - THRESHOLD) // 2
|
||||||
|
f.seek(middle_start)
|
||||||
|
hasher.update(f.read(THRESHOLD))
|
||||||
|
|
||||||
|
print(f"MidHash256: {hasher.hexdigest()}")
|
||||||
|
PYTHON_EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Computing BitTorrent v2 Hashes ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# BitTorrent v2 pieces root and info hash
|
||||||
|
python3 << 'PYTHON_EOF'
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
filename = 'sintel_trailer-480p.mp4'
|
||||||
|
BLOCK_SIZE = 16 * 1024 # 16 KiB
|
||||||
|
|
||||||
|
def compute_leaf_hashes(filepath):
|
||||||
|
"""Compute SHA-256 hash of each 16KB block"""
|
||||||
|
leaves = []
|
||||||
|
size = os.path.getsize(filepath)
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
return [bytes(32)]
|
||||||
|
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
block = f.read(BLOCK_SIZE)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
# Pad last block with zeros
|
||||||
|
if len(block) < BLOCK_SIZE:
|
||||||
|
block = block + bytes(BLOCK_SIZE - len(block))
|
||||||
|
leaves.append(hashlib.sha256(block).digest())
|
||||||
|
|
||||||
|
return leaves
|
||||||
|
|
||||||
|
def compute_merkle_root(leaves):
|
||||||
|
"""Compute merkle root from leaf hashes"""
|
||||||
|
if not leaves:
|
||||||
|
return bytes(32)
|
||||||
|
if len(leaves) == 1:
|
||||||
|
return leaves[0]
|
||||||
|
|
||||||
|
# Pad to power of 2
|
||||||
|
target = 1
|
||||||
|
while target < len(leaves):
|
||||||
|
target *= 2
|
||||||
|
|
||||||
|
current = list(leaves)
|
||||||
|
while len(current) < target:
|
||||||
|
current.append(bytes(32))
|
||||||
|
|
||||||
|
# Build tree bottom-up
|
||||||
|
while len(current) > 1:
|
||||||
|
next_level = []
|
||||||
|
for i in range(0, len(current), 2):
|
||||||
|
combined = current[i] + current[i+1]
|
||||||
|
next_level.append(hashlib.sha256(combined).digest())
|
||||||
|
current = next_level
|
||||||
|
|
||||||
|
return current[0]
|
||||||
|
|
||||||
|
def bencode_int(i):
|
||||||
|
return f'i{i}e'.encode()
|
||||||
|
|
||||||
|
def bencode_str(s):
|
||||||
|
if isinstance(s, str):
|
||||||
|
s = s.encode()
|
||||||
|
return f'{len(s)}:'.encode() + s
|
||||||
|
|
||||||
|
def bencode_dict(d):
|
||||||
|
result = b'd'
|
||||||
|
for k in sorted(d.keys()):
|
||||||
|
result += bencode_str(k)
|
||||||
|
v = d[k]
|
||||||
|
if isinstance(v, int):
|
||||||
|
result += bencode_int(v)
|
||||||
|
elif isinstance(v, (str, bytes)):
|
||||||
|
result += bencode_str(v)
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
result += bencode_dict(v)
|
||||||
|
result += b'e'
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Compute pieces root
|
||||||
|
leaves = compute_leaf_hashes(filename)
|
||||||
|
pieces_root = compute_merkle_root(leaves)
|
||||||
|
print(f"BT Pieces Root: {pieces_root.hex()}")
|
||||||
|
|
||||||
|
# Compute info hash
|
||||||
|
size = os.path.getsize(filename)
|
||||||
|
basename = os.path.basename(filename)
|
||||||
|
|
||||||
|
# Build info dict as per BEP 52
|
||||||
|
info_dict = {
|
||||||
|
'file tree': {
|
||||||
|
basename: {
|
||||||
|
'': {
|
||||||
|
'length': size,
|
||||||
|
'pieces root': pieces_root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'meta version': 2,
|
||||||
|
'name': basename,
|
||||||
|
'piece length': BLOCK_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded = bencode_dict(info_dict)
|
||||||
|
info_hash = hashlib.sha256(encoded).digest()
|
||||||
|
print(f"BT Info Hash: {info_hash.hex()}")
|
||||||
|
|
||||||
|
# Magnet link
|
||||||
|
print(f"Magnet: magnet:?xt=urn:btmh:1220{info_hash.hex()}&dn={basename}")
|
||||||
|
PYTHON_EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Copy these values to tests/ground_truth.rs"
|
||||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,330 @@
|
|||||||
|
//! Ground Truth Integration Tests
|
||||||
|
//!
|
||||||
|
//! These tests verify our hash implementations against known-good values
|
||||||
|
//! computed using standard third-party tools (sha256sum, sha1sum, md5sum, Python hashlib).
|
||||||
|
//!
|
||||||
|
//! Reference file: Sintel trailer (480p) - Creative Commons licensed
|
||||||
|
//! Source: https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4
|
||||||
|
//!
|
||||||
|
//! To regenerate ground truth values, run:
|
||||||
|
//! cd testdata && ./compute_ground_truth.sh
|
||||||
|
|
||||||
|
use metamesh_plugin_fast_full_hash::config::HashAlgorithm;
|
||||||
|
use metamesh_plugin_fast_full_hash::hasher::hash_file;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Reference file details
|
||||||
|
const REFERENCE_FILE: &str = "testdata/sintel_trailer-480p.mp4";
|
||||||
|
const REFERENCE_SIZE: u64 = 4372373;
|
||||||
|
|
||||||
|
/// Ground truth hash values (hex strings)
|
||||||
|
/// Computed using standard Unix tools and Python hashlib
|
||||||
|
mod expected {
|
||||||
|
pub const SHA256: &str = "b670602fa00934ca27c4351bb0efe7ea7a07fae57284e44226025eeed7c51254";
|
||||||
|
pub const SHA1: &str = "9b678890fb8ca401c28e7ca09171ec008a154b97";
|
||||||
|
pub const MD5: &str = "df6ed4bbc93613c68c8525e21bbddf98";
|
||||||
|
pub const CRC32: &str = "61accd5e";
|
||||||
|
pub const SHA3_256: &str = "fefc0c74ebd79a9872464ebbb35a0545db97431a6c9a96d36c061244883e9213";
|
||||||
|
pub const SHA3_384: &str = "da29f6604c96d09332531a4a2f4881b7bd2401a995053db868d5aea4056ada0b45c0ba69016e931bc468c38788afad94";
|
||||||
|
pub const MIDHASH256: &str = "297a6662a49c60da0cf2a75407a0eb0045a36f838d2921ab602483b708157773";
|
||||||
|
pub const BT_PIECES_ROOT: &str =
|
||||||
|
"424febb4268900457464928a99547dacd07d2075cbbd7ae27fdf5c8d2d982817";
|
||||||
|
pub const BT_INFO_HASH: &str =
|
||||||
|
"e2f048c0c8bb56c910c05b9efb4f39d2ff6fd0cf01e96e1dacecb56a067f8418";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert hex string to bytes
|
||||||
|
fn hex_to_bytes(hex: &str) -> Vec<u8> {
|
||||||
|
(0..hex.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract raw hash from CID (parse the CID and get the hash bytes)
|
||||||
|
fn extract_hash_from_cid(cid_str: &str) -> Vec<u8> {
|
||||||
|
use cid::Cid;
|
||||||
|
let cid = Cid::try_from(cid_str).expect("Invalid CID");
|
||||||
|
cid.hash().digest().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if reference file exists, skip test if not
|
||||||
|
fn check_reference_file() -> Option<&'static Path> {
|
||||||
|
let path = Path::new(REFERENCE_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping ground truth test: reference file not found at {}",
|
||||||
|
REFERENCE_FILE
|
||||||
|
);
|
||||||
|
eprintln!("To download, run: cd testdata && ./compute_ground_truth.sh");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size
|
||||||
|
let metadata = std::fs::metadata(path).expect("Failed to read file metadata");
|
||||||
|
if metadata.len() != REFERENCE_SIZE {
|
||||||
|
eprintln!(
|
||||||
|
"WARNING: Reference file size mismatch. Expected {}, got {}",
|
||||||
|
REFERENCE_SIZE,
|
||||||
|
metadata.len()
|
||||||
|
);
|
||||||
|
eprintln!("Ground truth values may not match. Re-run compute_ground_truth.sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sha256_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha256].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_sha2-256").expect("SHA-256 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::SHA256);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"SHA-256 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::SHA256,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sha1_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha1].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_sha1").expect("SHA-1 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::SHA1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"SHA-1 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::SHA1,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_md5_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Md5].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_md5").expect("MD5 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::MD5);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"MD5 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::MD5,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc32_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Crc32].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_crc32").expect("CRC32 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::CRC32);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"CRC32 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::CRC32,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sha3_256_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha3_256].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_sha3-256").expect("SHA3-256 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::SHA3_256);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"SHA3-256 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::SHA3_256,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sha3_384_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Sha3_384].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results.get("cid_sha3-384").expect("SHA3-384 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::SHA3_384);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"SHA3-384 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::SHA3_384,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_midhash256_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::Midhash256].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results
|
||||||
|
.get("cid_midhash256")
|
||||||
|
.expect("MidHash256 not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::MIDHASH256);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"MidHash256 mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::MIDHASH256,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bt_pieces_root_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::BtPiecesRoot].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results
|
||||||
|
.get("cid_bt_pieces_root")
|
||||||
|
.expect("BT Pieces Root not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::BT_PIECES_ROOT);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"BT Pieces Root mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::BT_PIECES_ROOT,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bt_info_hash_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled: HashSet<_> = vec![HashAlgorithm::BtInfoHash].into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
let cid = results
|
||||||
|
.get("cid_bt_info_hash")
|
||||||
|
.expect("BT Info Hash not computed");
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected::BT_INFO_HASH);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hash_bytes, expected_bytes,
|
||||||
|
"BT Info Hash mismatch!\nExpected: {}\nGot: {}",
|
||||||
|
expected::BT_INFO_HASH,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_algorithms_ground_truth() {
|
||||||
|
let Some(path) = check_reference_file() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute all algorithms in single pass
|
||||||
|
let enabled: HashSet<_> = HashAlgorithm::all().into_iter().collect();
|
||||||
|
let results = hash_file(path, &enabled, 1024 * 1024).expect("Hashing failed");
|
||||||
|
|
||||||
|
// Verify all 9 algorithms
|
||||||
|
let test_cases = vec![
|
||||||
|
("cid_sha2-256", expected::SHA256, "SHA-256"),
|
||||||
|
("cid_sha1", expected::SHA1, "SHA-1"),
|
||||||
|
("cid_md5", expected::MD5, "MD5"),
|
||||||
|
("cid_crc32", expected::CRC32, "CRC32"),
|
||||||
|
("cid_sha3-256", expected::SHA3_256, "SHA3-256"),
|
||||||
|
("cid_sha3-384", expected::SHA3_384, "SHA3-384"),
|
||||||
|
("cid_midhash256", expected::MIDHASH256, "MidHash256"),
|
||||||
|
(
|
||||||
|
"cid_bt_pieces_root",
|
||||||
|
expected::BT_PIECES_ROOT,
|
||||||
|
"BT Pieces Root",
|
||||||
|
),
|
||||||
|
("cid_bt_info_hash", expected::BT_INFO_HASH, "BT Info Hash"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
for (key, expected_hex, name) in test_cases {
|
||||||
|
let cid = match results.get(key) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
failures.push(format!("{}: NOT COMPUTED", name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash_bytes = extract_hash_from_cid(cid);
|
||||||
|
let expected_bytes = hex_to_bytes(expected_hex);
|
||||||
|
|
||||||
|
if hash_bytes != expected_bytes {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: MISMATCH\n Expected: {}\n Got: {}",
|
||||||
|
name,
|
||||||
|
expected_hex,
|
||||||
|
hex::encode(&hash_bytes)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failures.is_empty() {
|
||||||
|
panic!(
|
||||||
|
"Ground truth verification failed:\n{}",
|
||||||
|
failures.join("\n\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user