perf improved hash plugin
This commit is contained in:
@@ -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,99 @@
|
||||
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: "fast-full-hash",
|
||||
name: "Fast Full File Hash (Rust)",
|
||||
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": "SHA-256 of bencoded info dict for magnet links (multicodec 0x12)"
|
||||
}
|
||||
}),
|
||||
config: json!({
|
||||
"enabledHashes": {
|
||||
"type": "string",
|
||||
"label": "Enabled hash algorithms (comma-separated)",
|
||||
"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,249 @@
|
||||
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;
|
||||
|
||||
#[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, String>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
async fn do_process_file(
|
||||
state: Arc<ProcessState>,
|
||||
req: ProcessRequest,
|
||||
) -> anyhow::Result<ProcessResult> {
|
||||
let path = Path::new(&req.file_path);
|
||||
|
||||
// Check if file exists
|
||||
if !path.exists() {
|
||||
anyhow::bail!("File not found: {}", req.file_path);
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
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);
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Get config
|
||||
let config = state.config.read().await.clone();
|
||||
|
||||
// Determine which hashes we need to compute
|
||||
let required_keys: Vec<&str> = config
|
||||
.enabled_hashes
|
||||
.iter()
|
||||
.map(|a| a.key())
|
||||
.collect();
|
||||
|
||||
// Check existing metadata - skip if all hashes already present
|
||||
let missing_from_meta: Vec<&str> = required_keys
|
||||
.iter()
|
||||
.filter(|k| !req.existing_meta.contains_key(**k))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if missing_from_meta.is_empty() {
|
||||
return Ok(ProcessResult::Skipped(
|
||||
"All hashes already computed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check cache for remaining hashes
|
||||
let cached = state.cache.get(&filename, size, mtime).await;
|
||||
let mut results: HashMap<String, String> = HashMap::new();
|
||||
|
||||
let to_compute: Vec<HashAlgorithm> = missing_from_meta
|
||||
.iter()
|
||||
.filter_map(|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;
|
||||
}
|
||||
}
|
||||
HashAlgorithm::from_key(key)
|
||||
})
|
||||
.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 = tokio::task::spawn_blocking({
|
||||
let path = path.to_path_buf();
|
||||
let buffer_size = config.buffer_size;
|
||||
move || hash_file(&path, &enabled_set, buffer_size)
|
||||
})
|
||||
.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;
|
||||
|
||||
// Save cache periodically (could be optimized with debouncing)
|
||||
if let Err(e) = state.cache.save().await {
|
||||
tracing::warn!("Failed to save cache: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Store results to meta-core
|
||||
if !results.is_empty() {
|
||||
match MetaCoreClient::new(&req.meta_core_url) {
|
||||
Ok(client) => {
|
||||
if let Err(e) = client.merge_metadata(&req.cid, &results).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
+200
@@ -0,0 +1,200 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const CACHE_VERSION: u32 = 1;
|
||||
const CACHE_FILENAME: &str = "hash_cache.bin";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheEntry {
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub mtime: i64,
|
||||
pub hashes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheIndex {
|
||||
pub version: u32,
|
||||
pub entries: HashMap<String, CacheEntry>,
|
||||
}
|
||||
|
||||
impl Default for CacheIndex {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: CACHE_VERSION,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheIndex {
|
||||
/// Generate cache key from file metadata
|
||||
pub fn make_key(filename: &str, size: u64, mtime: i64) -> String {
|
||||
format!("{}-{}-{}", filename, size, mtime)
|
||||
}
|
||||
|
||||
/// Get cached entry if it exists and matches file metadata
|
||||
pub fn get(&self, filename: &str, size: u64, mtime: i64) -> Option<&CacheEntry> {
|
||||
let key = Self::make_key(filename, size, mtime);
|
||||
self.entries.get(&key)
|
||||
}
|
||||
|
||||
/// Insert or update a cache entry
|
||||
pub fn insert(&mut self, filename: String, size: u64, mtime: i64, hashes: HashMap<String, String>) {
|
||||
let key = Self::make_key(&filename, size, mtime);
|
||||
self.entries.insert(
|
||||
key,
|
||||
CacheEntry {
|
||||
filename,
|
||||
size,
|
||||
mtime,
|
||||
hashes,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CacheManager {
|
||||
cache_path: PathBuf,
|
||||
index: Arc<RwLock<CacheIndex>>,
|
||||
}
|
||||
|
||||
impl CacheManager {
|
||||
pub fn new(cache_dir: &str) -> Self {
|
||||
let cache_path = PathBuf::from(cache_dir).join(CACHE_FILENAME);
|
||||
Self {
|
||||
cache_path,
|
||||
index: Arc::new(RwLock::new(CacheIndex::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load cache from disk
|
||||
pub async fn load(&self) -> Result<()> {
|
||||
if !self.cache_path.exists() {
|
||||
tracing::info!("No existing cache found at {:?}", self.cache_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = self.cache_path.clone();
|
||||
let index = tokio::task::spawn_blocking(move || -> Result<CacheIndex> {
|
||||
let file = File::open(&path).context("Failed to open cache file")?;
|
||||
let reader = BufReader::new(file);
|
||||
let index: CacheIndex =
|
||||
bincode::deserialize_from(reader).context("Failed to deserialize cache")?;
|
||||
|
||||
// Version check
|
||||
if index.version != CACHE_VERSION {
|
||||
tracing::warn!(
|
||||
"Cache version mismatch (got {}, expected {}), starting fresh",
|
||||
index.version,
|
||||
CACHE_VERSION
|
||||
);
|
||||
return Ok(CacheIndex::default());
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
})
|
||||
.await??;
|
||||
|
||||
tracing::info!("Loaded {} cache entries", index.entries.len());
|
||||
*self.index.write().await = index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save cache to disk atomically
|
||||
pub async fn save(&self) -> Result<()> {
|
||||
let index = self.index.read().await.clone();
|
||||
let cache_path = self.cache_path.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Write to temp file first
|
||||
let temp_path = cache_path.with_extension("tmp");
|
||||
{
|
||||
let file = File::create(&temp_path)?;
|
||||
let writer = BufWriter::new(file);
|
||||
bincode::serialize_into(writer, &index)?;
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
fs::rename(&temp_path, &cache_path)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get cached hashes for a file
|
||||
pub async fn get(&self, filename: &str, size: u64, mtime: i64) -> Option<HashMap<String, String>> {
|
||||
let index = self.index.read().await;
|
||||
index.get(filename, size, mtime).map(|e| e.hashes.clone())
|
||||
}
|
||||
|
||||
/// Store hashes in cache
|
||||
pub async fn insert(&self, filename: String, size: u64, mtime: i64, hashes: HashMap<String, String>) {
|
||||
let mut index = self.index.write().await;
|
||||
index.insert(filename, size, mtime, hashes);
|
||||
}
|
||||
|
||||
/// Get missing hash algorithms for a file (not in cache or not computed)
|
||||
pub async fn get_missing_algos(
|
||||
&self,
|
||||
filename: &str,
|
||||
size: u64,
|
||||
mtime: i64,
|
||||
required: &[&str],
|
||||
) -> Vec<String> {
|
||||
let index = self.index.read().await;
|
||||
let cached = index.get(filename, size, mtime);
|
||||
|
||||
required
|
||||
.iter()
|
||||
.filter(|algo| {
|
||||
cached
|
||||
.map(|e| !e.hashes.contains_key(**algo))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_roundtrip() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||
|
||||
// Insert some data
|
||||
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;
|
||||
|
||||
// Save and reload
|
||||
cache.save().await.unwrap();
|
||||
|
||||
let cache2 = CacheManager::new(temp_dir.path().to_str().unwrap());
|
||||
cache2.load().await.unwrap();
|
||||
|
||||
let result = cache2.get("test.mkv", 1000, 12345).await;
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().get("cid_sha2-256").unwrap(), "baejbeitest");
|
||||
}
|
||||
}
|
||||
@@ -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!("{}/api/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!("{}/api/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;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
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
|
||||
// BT info hash uses SHA256_CODE (0x12) since it's a SHA-256 hash
|
||||
|
||||
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 => SHA256_CODE, // Info hash is SHA-256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,383 @@
|
||||
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))
|
||||
}
|
||||
|
||||
#[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,110 @@
|
||||
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()
|
||||
}
|
||||
|
||||
#[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,12 @@
|
||||
mod algorithms;
|
||||
pub mod btih_v2;
|
||||
mod cid;
|
||||
pub mod midhash256;
|
||||
|
||||
pub use algorithms::hash_file;
|
||||
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::to_cid;
|
||||
pub use midhash256::compute_midhash256;
|
||||
@@ -0,0 +1,9 @@
|
||||
//! 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;
|
||||
+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(())
|
||||
}
|
||||
Reference in New Issue
Block a user