feat: 增加 Metadata 安全过滤

This commit is contained in:
chuan
2026-08-10 01:52:25 +08:00
parent b898cfb01b
commit c7ddde5741
23 changed files with 870 additions and 123 deletions
+1
View File
@@ -4,6 +4,7 @@
/.run-data/
/.remote-data/
/data/
/data-filter-test/
/dht-search.toml
**/*.rs.bk
+6 -5
View File
@@ -213,12 +213,13 @@
- [ ] 统计真实数据的 infohash 重复率和内容重复率
- [ ] 定义可配置的名称路径扩展名和大小过滤规则
- [ ] 定义 Metadata 最大大小文件数路径长度限制
- [ ] 识别空名称异常路径大小溢出和文件数量攻击
- [x] 定义可配置的 Metadata 最大大小文件数名称路径长度和目录层级限制
- [x] 识别空名称控制字符异常路径大小溢出总大小不一致和文件数量攻击
- [ ] 设计可解释的名称标准化规则
- [ ] 为模糊相似结果生成聚合候选但不自动删除
- [ ] 支持黑名单规则版本和命中原因
- [ ] 保留被过滤记录的计数指标但避免保存大内容
- [x] 使用带规则指纹的 RocksDB 轻量拒绝记录阻止相同异常 infohash 重复下载
- [x] 保留按原因分类的过滤指标但避免保存名称和大文件列表
- [ ] 增加误判测试和边界数据集
### 验收标准
@@ -308,6 +309,6 @@
## 当前下一步
使用真实采集数据进行 Web 浏览器人工验收并根据使用反馈调整交互细节
建立可重复的百万级数据基准并测量查询延迟索引速度磁盘占用和内存使用
随后进行百万级查询基准和更长时间的资源稳定性测试
随后进行更长时间的真实采集与资源稳定性测试
+1
View File
@@ -12,6 +12,7 @@
### Changed
- Metadata 最大 info 字典大小改为可通过 `max_metadata_size_bytes` 配置,并拒绝负数文件大小和总大小溢出。
- 主动 `get_peers` ingress 改为有界排队,避免突发采样在速率预算耗尽时直接丢弃。
- 默认主动 Peer lookup 提升到每秒 128 个、最多 256 个并发 lookup。
- 空节点池的 Bootstrap 默认改为 30 秒重试、每轮最多 16 个端点,降低坏 DNS
+1
View File
@@ -158,6 +158,7 @@ let options = DHTOptions {
max_queue_size: 20_000,
max_worker_count: 8,
max_connects_per_second: 2,
max_metadata_size_bytes: 10 * 1024 * 1024,
..Default::default()
},
crawl: CrawlOptions {
+40 -20
View File
@@ -192,6 +192,7 @@ impl PeerFailureCache {
/// BEP-9 Metadata fetcher with an end-to-end timeout and shared Peer failure cache.
pub struct RbitFetcher {
total_timeout: Duration,
max_metadata_size_bytes: usize,
runtime_stats: DhtRuntimeStats,
peer_failure_cache: Arc<PeerFailureCache>,
connect_rate_limiter: Arc<ConnectRateLimiter>,
@@ -203,18 +204,27 @@ impl RbitFetcher {
/// [`DHTServer`](crate::DHTServer) normally constructs this component from
/// [`MetadataOptions`](crate::MetadataOptions).
pub fn new(timeout_secs: u64) -> Self {
Self::new_with_runtime_stats(timeout_secs, 32, 200_000, 60, DhtRuntimeStats::default())
Self::new_with_runtime_stats(
timeout_secs,
32,
10 * 1024 * 1024,
200_000,
60,
DhtRuntimeStats::default(),
)
}
pub(crate) fn new_with_runtime_stats(
timeout_secs: u64,
max_connects_per_second: u32,
max_metadata_size_bytes: usize,
peer_failure_cache_capacity: usize,
peer_failure_ttl_secs: u64,
runtime_stats: DhtRuntimeStats,
) -> Self {
Self {
total_timeout: Duration::from_secs(if timeout_secs == 0 { 15 } else { timeout_secs }),
max_metadata_size_bytes: max_metadata_size_bytes.max(1),
runtime_stats,
peer_failure_cache: Arc::new(PeerFailureCache::new(
peer_failure_cache_capacity,
@@ -457,7 +467,7 @@ impl RbitFetcher {
}
if metadata_size > 0 && remote_ut_metadata_id > 0 && !request_sent {
if metadata_size > 10 * 1024 * 1024 {
if metadata_size as usize > self.max_metadata_size_bytes {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "size_limit")
.increment(1);
@@ -578,9 +588,10 @@ fn parse_metadata(info_bytes: &[u8]) -> Option<FetchedMetadata> {
let piece_length = dict
.get(&b"piece length"[..])
.and_then(|value| value.as_integer())
.unwrap_or(0) as u64;
.and_then(|value| u64::try_from(value).ok())
.unwrap_or(0);
let mut total_size = 0;
let mut total_size = 0_u64;
let mut file_list = Vec::new();
if let Some(files) = dict.get(&b"files"[..]).and_then(|value| value.as_list()) {
for file in files {
@@ -593,26 +604,23 @@ fn parse_metadata(info_bytes: &[u8]) -> Option<FetchedMetadata> {
else {
continue;
};
let length = length as u64;
total_size += length;
let path = file_dict
let length = u64::try_from(length).ok()?;
total_size = total_size.checked_add(length)?;
let parts = file_dict
.get(&b"path"[..])
.and_then(|value| value.as_list())
.map(|parts| {
parts
.iter()
.filter_map(|part| part.as_str())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default();
.and_then(|value| value.as_list())?;
let path = parts
.iter()
.map(|part| part.as_str())
.collect::<Option<Vec<_>>>()?
.join("/");
file_list.push(FileInfo { path, size: length });
}
} else if let Some(length) = dict
.get(&b"length"[..])
.and_then(|value| value.as_integer())
{
total_size = length as u64;
total_size = u64::try_from(length).ok()?;
file_list.push(FileInfo {
path: name.clone(),
size: total_size,
@@ -625,6 +633,11 @@ fn parse_metadata(info_bytes: &[u8]) -> Option<FetchedMetadata> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_rejects_negative_file_size() {
assert!(parse_metadata(b"d6:lengthi-1e4:name1:ae").is_none());
}
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
@@ -677,7 +690,8 @@ mod tests {
});
let stats = DhtRuntimeStats::default();
let fetcher = RbitFetcher::new_with_runtime_stats(1, 10, 10, 60, stats.clone());
let fetcher =
RbitFetcher::new_with_runtime_stats(1, 10, 10 * 1024 * 1024, 10, 60, stats.clone());
let started = Instant::now();
assert!(matches!(
fetcher.fetch(&[7; 20], addr).await,
@@ -712,8 +726,14 @@ mod tests {
stream.read_exact(&mut handshake).await.unwrap();
stream.write_all(&handshake).await.unwrap();
});
let fetcher =
RbitFetcher::new_with_runtime_stats(1, 10, 10, 60, DhtRuntimeStats::default());
let fetcher = RbitFetcher::new_with_runtime_stats(
1,
10,
10 * 1024 * 1024,
10,
60,
DhtRuntimeStats::default(),
);
assert!(fetcher.verify_handshake(&[7; 20], addr).await);
peer.await.unwrap();
}
+1
View File
@@ -358,6 +358,7 @@ impl DHTServer {
let fetcher = Arc::new(RbitFetcher::new_with_runtime_stats(
options.metadata.timeout_secs,
options.metadata.max_connects_per_second,
options.metadata.max_metadata_size_bytes,
options.metadata.peer_failure_cache_capacity,
options.metadata.peer_failure_ttl_secs,
runtime_stats.clone(),
+3
View File
@@ -142,6 +142,8 @@ pub struct MetadataOptions {
pub max_worker_count: usize,
/// Maximum real TCP connection attempts started per second.
pub max_connects_per_second: u32,
/// Maximum accepted BEP-9 info dictionary size in bytes.
pub max_metadata_size_bytes: usize,
/// Maximum number of cached bad Peer socket addresses.
pub peer_failure_cache_capacity: usize,
/// Timeout/connect failure cache lifetime in seconds.
@@ -308,6 +310,7 @@ impl Default for MetadataOptions {
max_queue_size: 10_000,
max_worker_count: 8,
max_connects_per_second: 2,
max_metadata_size_bytes: 10 * 1024 * 1024,
peer_failure_cache_capacity: 200_000,
peer_failure_ttl_secs: 60,
}
+7
View File
@@ -6,6 +6,13 @@ stats_interval_secs = 10
index_batch_size = 1024
index_interval_millis = 5000
[metadata_limits]
max_metadata_bytes = 10485760
max_files = 20000
max_name_bytes = 1024
max_path_bytes = 4096
max_path_depth = 64
[dht]
port = 12313
netmode = "ipv4-only"
+13
View File
@@ -0,0 +1,13 @@
data_dir = "data-filter-test"
run_duration_secs = 300
[metadata_limits]
max_metadata_bytes = 65536
max_files = 10
max_name_bytes = 64
max_path_bytes = 128
max_path_depth = 4
[http]
listen = "127.0.0.1:8080"
web_dir = "web/dist"
+27
View File
@@ -80,6 +80,33 @@ Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不
Windows 下索引每五秒批量提交 临时文件占用会自动指数退避重试且不会停止采集 HTTP 服务或丢失 RocksDB 待索引状态
### Metadata 安全限制
应用会在 Metadata 下载和进入 RocksDB 前执行两层资源与结构校验
| 配置项 | 默认值 | 作用 |
|---|---:|---|
| `metadata_limits.max_metadata_bytes` | `10485760` | 下载阶段允许的最大 info 字典字节数 |
| `metadata_limits.max_files` | `20000` | 单个种子允许的最大文件数量 |
| `metadata_limits.max_name_bytes` | `1024` | 种子名称最大 UTF-8 字节数 |
| `metadata_limits.max_path_bytes` | `4096` | 单个文件路径最大 UTF-8 字节数 |
| `metadata_limits.max_path_depth` | `64` | 单个文件路径最大目录层级 |
空名称 空文件列表 控制字符 空路径段 `.` `..` 大小溢出和声明总大小不一致会被分类拒绝
通过完整 Metadata 校验后被拒绝的 infohash 只在 RocksDB 保存原因规则指纹时间和次数 不保存名称或文件列表 相同规则下再次发现时不会重复下载 修改限制后规则指纹变化并允许重新判断
`/stats` 返回 `metadata_filtered` 总数以及 `metadata_filtered_*` 分类计数 Web 运行状态展示本次运行的过滤总数
可以使用独立数据目录和严格限制运行五分钟测试 不会污染正式数据目录
```powershell
cargo run -p dht-search -- --config dht-search.filter-test.toml
Invoke-RestMethod http://127.0.0.1:8080/stats | ConvertTo-Json -Depth 5
```
严格测试配置仅用于观察过滤效果 不应作为正式采集配置
### 采样去重和 Peer 查找
BEP-51 返回的 infohash 会先进入有界批量准入队列并由 RocksDB 精确判断
+20 -1
View File
@@ -9,7 +9,7 @@ use axum::{
response::{IntoResponse, Response},
};
use dht_search::{
domain::InfoHash,
domain::{InfoHash, MetadataRejectionReason},
search::{SearchOptions, SearchPage, SearchSort},
storage::VerificationPriority,
};
@@ -35,6 +35,7 @@ pub(crate) async fn stats(State(state): State<ApiState>) -> Json<StatsResponse>
let dht = state.dht_stats.snapshot();
let observability = state.dht_stats.observability_snapshot();
let persistence = state.persistence.snapshot();
let filtered = persistence.filtered;
let verification = state
.verification
.as_ref()
@@ -56,6 +57,24 @@ pub(crate) async fn stats(State(state): State<ApiState>) -> Json<StatsResponse>
metadata_in_flight: dht.metadata_in_flight,
metadata_ok: dht.metadata_peer_succeeded,
metadata_failed: dht.metadata_peer_failed,
metadata_filtered: observability
.metadata_failure_size_limit
.saturating_add(filtered.total()),
metadata_filtered_too_large: observability.metadata_failure_size_limit,
metadata_filtered_invalid_info_hash: filtered
.count(MetadataRejectionReason::InvalidInfoHash),
metadata_filtered_empty_name: filtered.count(MetadataRejectionReason::EmptyName),
metadata_filtered_name_too_long: filtered.count(MetadataRejectionReason::NameTooLong),
metadata_filtered_invalid_name: filtered.count(MetadataRejectionReason::InvalidName),
metadata_filtered_empty_file_list: filtered.count(MetadataRejectionReason::EmptyFileList),
metadata_filtered_too_many_files: filtered.count(MetadataRejectionReason::TooManyFiles),
metadata_filtered_empty_path: filtered.count(MetadataRejectionReason::EmptyPath),
metadata_filtered_path_too_long: filtered.count(MetadataRejectionReason::PathTooLong),
metadata_filtered_path_too_deep: filtered.count(MetadataRejectionReason::PathTooDeep),
metadata_filtered_invalid_path: filtered.count(MetadataRejectionReason::InvalidPath),
metadata_filtered_size_overflow: filtered.count(MetadataRejectionReason::SizeOverflow),
metadata_filtered_total_size_mismatch: filtered
.count(MetadataRejectionReason::TotalSizeMismatch),
persistence_accepted: persistence.accepted,
persistence_inserted: persistence.inserted,
persistence_updated: persistence.updated,
+20 -2
View File
@@ -60,7 +60,7 @@ mod tests {
};
use dht_crawler::{DhtRuntimeStats, FileInfo, TorrentInfo};
use dht_search::{
domain::{InfoHash, TorrentRecord},
domain::{InfoHash, MetadataLimits, TorrentRecord},
search::SearchEngine,
storage::{RocksTorrentRepository, TorrentRepository},
};
@@ -105,7 +105,8 @@ mod tests {
let search = SearchEngine::open(directory.path().join("tantivy")).unwrap();
search.index_pending(repository.as_ref(), 10, 20).unwrap();
let repository_trait: Arc<dyn TorrentRepository> = repository.clone();
let persistence = PersistencePipeline::start(repository_trait.clone(), 4);
let persistence =
PersistencePipeline::start(repository_trait.clone(), 4, MetadataLimits::default());
let verification = VerificationIngress::for_test(repository.clone(), 10);
let web_dir = directory.path().join("web");
std::fs::create_dir_all(&web_dir).unwrap();
@@ -130,6 +131,23 @@ mod tests {
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(body.as_ref(), b"<main>DHT Search</main>");
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(json["metadata_filtered"], 0);
assert_eq!(json["metadata_filtered_too_many_files"], 0);
let response = app
.clone()
.oneshot(
+14
View File
@@ -29,6 +29,20 @@ pub(crate) struct StatsResponse {
pub(crate) metadata_in_flight: usize,
pub(crate) metadata_ok: u64,
pub(crate) metadata_failed: u64,
pub(crate) metadata_filtered: u64,
pub(crate) metadata_filtered_too_large: u64,
pub(crate) metadata_filtered_invalid_info_hash: u64,
pub(crate) metadata_filtered_empty_name: u64,
pub(crate) metadata_filtered_name_too_long: u64,
pub(crate) metadata_filtered_invalid_name: u64,
pub(crate) metadata_filtered_empty_file_list: u64,
pub(crate) metadata_filtered_too_many_files: u64,
pub(crate) metadata_filtered_empty_path: u64,
pub(crate) metadata_filtered_path_too_long: u64,
pub(crate) metadata_filtered_path_too_deep: u64,
pub(crate) metadata_filtered_invalid_path: u64,
pub(crate) metadata_filtered_size_overflow: u64,
pub(crate) metadata_filtered_total_size_mismatch: u64,
pub(crate) persistence_accepted: u64,
pub(crate) persistence_inserted: u64,
pub(crate) persistence_updated: u64,
+20 -4
View File
@@ -8,7 +8,7 @@ use std::{
use dht_crawler::DHTServer;
use dht_search::{
domain::InfoHash,
domain::{InfoHash, MetadataRejectionReason},
search::SearchEngine,
storage::{RocksTorrentRepository, TorrentRepository},
};
@@ -25,15 +25,22 @@ use crate::{
pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> {
std::fs::create_dir_all(&config.data_dir)?;
let database_path = config.data_dir.join("rocksdb");
let repository = Arc::new(RocksTorrentRepository::open(&database_path)?);
let metadata_limits = config.metadata_limits();
let repository = Arc::new(RocksTorrentRepository::open_with_rejection_rule(
&database_path,
metadata_limits.rule_id(),
)?);
let (search, search_created) = SearchEngine::open_with_status(config.data_dir.join("tantivy"))?;
if search_created {
let records = repository.prepare_full_reindex()?;
tracing::info!(records, "检测到新搜索索引并准备全量重建");
}
let repository_api: Arc<dyn TorrentRepository> = repository.clone();
let mut persistence =
PersistencePipeline::start(repository_api, config.persistence_queue_capacity);
let mut persistence = PersistencePipeline::start(
repository_api,
config.persistence_queue_capacity,
metadata_limits,
);
let ingress = persistence.ingress.clone();
let options = config.dht_options();
@@ -377,6 +384,15 @@ async fn monitor(
metadata_in_flight = dht.metadata_in_flight,
metadata_ok = dht.metadata_peer_succeeded,
metadata_failed = dht.metadata_peer_failed,
metadata_filtered = observability
.metadata_failure_size_limit
.saturating_add(storage.filtered.total()),
metadata_filtered_too_many_files = storage
.filtered
.count(MetadataRejectionReason::TooManyFiles),
metadata_filtered_invalid_path = storage
.filtered
.count(MetadataRejectionReason::InvalidPath),
persistence_accepted = storage.accepted,
persistence_inserted = storage.inserted,
persistence_updated = storage.updated,
+53
View File
@@ -10,6 +10,7 @@ use dht_crawler::{
use serde::Deserialize;
use crate::error::AppError;
use dht_search::domain::MetadataLimits;
#[derive(Debug, Parser)]
#[command(name = "dht-search", version, about = "DHT 元数据采集和搜索服务")]
@@ -31,6 +32,7 @@ pub(crate) struct AppConfig {
pub(crate) run_duration_secs: Option<u64>,
pub(crate) index_batch_size: usize,
pub(crate) index_interval_millis: u64,
pub(crate) metadata_limits: MetadataLimitsConfig,
pub(crate) dht: DhtConfig,
pub(crate) http: HttpConfig,
pub(crate) verification: VerificationConfig,
@@ -74,6 +76,16 @@ pub(crate) struct VerificationConfig {
pub(crate) poll_interval_millis: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct MetadataLimitsConfig {
pub(crate) max_metadata_bytes: usize,
pub(crate) max_files: usize,
pub(crate) max_name_bytes: usize,
pub(crate) max_path_bytes: usize,
pub(crate) max_path_depth: usize,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum NetworkMode {
@@ -129,6 +141,7 @@ impl AppConfig {
max_queue_size: self.dht.metadata_queue_capacity,
max_worker_count: self.dht.metadata_workers,
max_connects_per_second: self.dht.metadata_connects_per_second,
max_metadata_size_bytes: self.metadata_limits.max_metadata_bytes,
..defaults.metadata
},
peer_lookup: PeerLookupOptions {
@@ -164,6 +177,15 @@ impl AppConfig {
}
}
pub(crate) fn metadata_limits(&self) -> MetadataLimits {
MetadataLimits {
max_files: self.metadata_limits.max_files,
max_name_bytes: self.metadata_limits.max_name_bytes,
max_path_bytes: self.metadata_limits.max_path_bytes,
max_path_depth: self.metadata_limits.max_path_depth,
}
}
fn validate(&self) -> Result<(), AppError> {
if self.persistence_queue_capacity == 0 {
return Err(AppError::Config(
@@ -185,6 +207,16 @@ impl AppConfig {
"索引批量大小和执行间隔必须大于零".to_owned(),
));
}
if self.metadata_limits.max_metadata_bytes == 0
|| self.metadata_limits.max_files == 0
|| self.metadata_limits.max_name_bytes == 0
|| self.metadata_limits.max_path_bytes == 0
|| self.metadata_limits.max_path_depth == 0
{
return Err(AppError::Config(
"Metadata 大小文件数名称路径和目录层级上限必须大于零".to_owned(),
));
}
if self.dht.metadata_workers == 0 || self.dht.metadata_queue_capacity == 0 {
return Err(AppError::Config(
"Metadata worker 和队列容量必须大于零".to_owned(),
@@ -221,6 +253,7 @@ impl Default for AppConfig {
run_duration_secs: None,
index_batch_size: 512,
index_interval_millis: 5_000,
metadata_limits: MetadataLimitsConfig::default(),
dht: DhtConfig::default(),
http: HttpConfig::default(),
verification: VerificationConfig::default(),
@@ -228,6 +261,18 @@ impl Default for AppConfig {
}
}
impl Default for MetadataLimitsConfig {
fn default() -> Self {
Self {
max_metadata_bytes: 10 * 1024 * 1024,
max_files: 20_000,
max_name_bytes: 1_024,
max_path_bytes: 4_096,
max_path_depth: 64,
}
}
}
impl Default for DhtConfig {
fn default() -> Self {
Self {
@@ -311,6 +356,7 @@ mod tests {
assert_eq!(options.port, 12_313);
assert_eq!(options.metadata.max_worker_count, 8);
assert_eq!(options.metadata.max_connects_per_second, 2);
assert_eq!(options.metadata.max_metadata_size_bytes, 10 * 1024 * 1024);
assert_eq!(options.max_outbound_queries_per_second, 10);
assert_eq!(options.crawl.rate_limit.max_find_node_rate_per_sec, 6);
}
@@ -345,4 +391,11 @@ mod tests {
.unwrap_err();
assert!(matches!(error, AppError::Toml(_)));
}
#[test]
fn zero_metadata_limit_is_rejected() {
let mut config = AppConfig::default();
config.metadata_limits.max_files = 0;
assert!(matches!(config.validate(), Err(AppError::Config(_))));
}
}
+114 -13
View File
@@ -1,6 +1,7 @@
// 负责定义采集阶段之间的有界队列背压和任务流转规则
use std::{
str::FromStr,
sync::{
Arc, Mutex,
atomic::{AtomicU64, AtomicUsize, Ordering},
@@ -11,7 +12,7 @@ use std::{
use dht_crawler::TorrentInfo;
use dht_search::{
domain::TorrentRecord,
domain::{InfoHash, MetadataLimits, MetadataRejectionReason, RejectedMetadata, TorrentRecord},
storage::{StorageError, TorrentRepository, UpsertOutcome},
};
use tokio::sync::oneshot;
@@ -20,8 +21,9 @@ use crate::error::AppError;
#[derive(Clone)]
pub(crate) struct PersistenceIngress {
sender: Arc<Mutex<Option<SyncSender<TorrentRecord>>>>,
sender: Arc<Mutex<Option<SyncSender<PersistenceItem>>>>,
stats: Arc<PersistenceStats>,
limits: MetadataLimits,
}
pub(crate) struct PersistencePipeline {
@@ -39,6 +41,7 @@ pub(crate) struct PersistenceStats {
invalid: AtomicU64,
failed: AtomicU64,
queue_depth: AtomicUsize,
filtered: [AtomicU64; MetadataRejectionReason::COUNT],
}
#[derive(Debug, Clone, Copy)]
@@ -50,26 +53,48 @@ pub(crate) struct PersistenceSnapshot {
pub(crate) invalid: u64,
pub(crate) failed: u64,
pub(crate) queue_depth: usize,
pub(crate) filtered: MetadataFilterSnapshot,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct MetadataFilterSnapshot {
counts: [u64; MetadataRejectionReason::COUNT],
}
enum PersistenceItem {
Record(TorrentRecord),
Rejection(RejectedMetadata),
}
impl PersistencePipeline {
pub(crate) fn start(repository: Arc<dyn TorrentRepository>, capacity: usize) -> Self {
let (sender, receiver) = mpsc::sync_channel::<TorrentRecord>(capacity);
pub(crate) fn start(
repository: Arc<dyn TorrentRepository>,
capacity: usize,
limits: MetadataLimits,
) -> Self {
let (sender, receiver) = mpsc::sync_channel::<PersistenceItem>(capacity);
let (fatal_tx, fatal) = oneshot::channel();
let stats = Arc::new(PersistenceStats::default());
let worker_stats = stats.clone();
let worker = thread::Builder::new()
.name("torrent-persistence".to_owned())
.spawn(move || {
while let Ok(record) = receiver.recv() {
while let Ok(item) = receiver.recv() {
worker_stats.queue_depth.fetch_sub(1, Ordering::Relaxed);
match repository.upsert(record) {
Ok(UpsertOutcome::Inserted) => {
let outcome = match item {
PersistenceItem::Record(record) => repository.upsert(record).map(Some),
PersistenceItem::Rejection(rejection) => {
repository.record_rejection(rejection).map(|()| None)
}
};
match outcome {
Ok(Some(UpsertOutcome::Inserted)) => {
worker_stats.inserted.fetch_add(1, Ordering::Relaxed);
}
Ok(UpsertOutcome::Updated { .. }) => {
Ok(Some(UpsertOutcome::Updated { .. })) => {
worker_stats.updated.fetch_add(1, Ordering::Relaxed);
}
Ok(None) => {}
Err(error) => {
worker_stats.failed.fetch_add(1, Ordering::Relaxed);
let _ = fatal_tx.send(error.to_string());
@@ -85,6 +110,7 @@ impl PersistencePipeline {
ingress: PersistenceIngress {
sender: Arc::new(Mutex::new(Some(sender))),
stats,
limits,
},
fatal,
worker,
@@ -103,14 +129,31 @@ impl PersistencePipeline {
impl PersistenceIngress {
pub(crate) fn try_enqueue(&self, torrent: TorrentInfo) -> bool {
let record = match TorrentRecord::try_from(torrent) {
let rejected_info_hash = InfoHash::from_str(&torrent.info_hash).ok();
let rejected_at = torrent.timestamp;
let record = match TorrentRecord::try_from_with_limits(torrent, self.limits) {
Ok(record) => record,
Err(error) => {
self.stats.invalid.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "拒绝无效 Metadata");
let reason = error.rejection_reason();
self.stats.filtered[reason.index()].fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, ?reason, "拒绝无效 Metadata");
if let Some(info_hash) = rejected_info_hash {
let rejection = RejectedMetadata::new(
info_hash,
reason,
self.limits.rule_id(),
rejected_at,
);
self.try_send(PersistenceItem::Rejection(rejection), false);
}
return false;
}
};
self.try_send(PersistenceItem::Record(record), true)
}
fn try_send(&self, item: PersistenceItem, accepted_record: bool) -> bool {
let sender = self
.sender
.lock()
@@ -118,9 +161,11 @@ impl PersistenceIngress {
let Some(sender) = sender.as_ref() else {
return false;
};
match sender.try_send(record) {
match sender.try_send(item) {
Ok(()) => {
self.stats.accepted.fetch_add(1, Ordering::Relaxed);
if accepted_record {
self.stats.accepted.fetch_add(1, Ordering::Relaxed);
}
self.stats.queue_depth.fetch_add(1, Ordering::Relaxed);
true
}
@@ -154,10 +199,23 @@ impl PersistenceStats {
invalid: self.invalid.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
queue_depth: self.queue_depth.load(Ordering::Relaxed),
filtered: MetadataFilterSnapshot {
counts: std::array::from_fn(|index| self.filtered[index].load(Ordering::Relaxed)),
},
}
}
}
impl MetadataFilterSnapshot {
pub(crate) fn total(self) -> u64 {
self.counts.iter().copied().sum()
}
pub(crate) fn count(self, reason: MetadataRejectionReason) -> u64 {
self.counts[reason.index()]
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
@@ -176,6 +234,7 @@ mod tests {
#[derive(Default)]
struct MemoryRepository {
records: Mutex<Vec<TorrentRecord>>,
rejections: Mutex<Vec<RejectedMetadata>>,
}
impl TorrentRepository for MemoryRepository {
@@ -194,6 +253,15 @@ mod tests {
Ok(UpsertOutcome::Inserted)
}
fn rejection(&self, _: InfoHash) -> Result<Option<RejectedMetadata>, StorageError> {
Ok(None)
}
fn record_rejection(&self, rejection: RejectedMetadata) -> Result<(), StorageError> {
self.rejections.lock().unwrap().push(rejection);
Ok(())
}
fn observe_existing(&self, _: InfoHash, _: u64) -> Result<bool, StorageError> {
Ok(false)
}
@@ -280,10 +348,43 @@ mod tests {
#[tokio::test]
async fn accepted_record_is_drained_before_shutdown() {
let repository = Arc::new(MemoryRepository::default());
let pipeline = PersistencePipeline::start(repository.clone(), 1);
let pipeline = PersistencePipeline::start(repository.clone(), 1, MetadataLimits::default());
assert!(pipeline.ingress.try_enqueue(torrent()));
pipeline.close_and_join().await.unwrap();
let records = repository.records.lock().unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn invalid_record_is_classified_and_persists_only_a_rejection() {
let repository = Arc::new(MemoryRepository::default());
let limits = MetadataLimits {
max_files: 1,
..MetadataLimits::default()
};
let pipeline = PersistencePipeline::start(repository.clone(), 1, limits);
let mut invalid = torrent();
invalid.files.push(FileInfo {
path: "second".into(),
size: 1,
});
invalid.total_size = 2;
assert!(!pipeline.ingress.try_enqueue(invalid));
let snapshot = pipeline.ingress.snapshot();
assert_eq!(snapshot.accepted, 0);
assert_eq!(snapshot.filtered.total(), 1);
assert_eq!(
snapshot
.filtered
.count(MetadataRejectionReason::TooManyFiles),
1
);
pipeline.close_and_join().await.unwrap();
assert!(repository.records.lock().unwrap().is_empty());
let rejections = repository.rejections.lock().unwrap();
assert_eq!(rejections.len(), 1);
assert_eq!(rejections[0].reason, MetadataRejectionReason::TooManyFiles);
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ pub(crate) use torrent::ContentGroupBuilder;
#[cfg(test)]
pub(crate) use torrent::test_record;
pub use torrent::{
Availability, AvailabilityStatus, ContentGroup, Heat, HeatLevel, InfoHash, TorrentFile,
TorrentRecord, TorrentRecordError, VerificationResult,
Availability, AvailabilityStatus, ContentGroup, Heat, HeatLevel, InfoHash, MetadataLimits,
MetadataRejectionReason, RejectedMetadata, TorrentFile, TorrentRecord, TorrentRecordError,
VerificationResult,
};
+348 -61
View File
@@ -9,6 +9,110 @@ use super::fingerprint::content_key;
const MAX_STORED_PEERS: usize = 32;
const ACTIVITY_SCALE: u64 = 1_000;
const ACTIVITY_HALF_LIFE_SECS: f64 = 86_400.0;
const METADATA_VALIDATION_VERSION: u64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MetadataLimits {
pub max_files: usize,
pub max_name_bytes: usize,
pub max_path_bytes: usize,
pub max_path_depth: usize,
}
impl MetadataLimits {
pub fn rule_id(self) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(b"dht-search-metadata-limits\0");
hasher.update(&METADATA_VALIDATION_VERSION.to_be_bytes());
hasher.update(&(self.max_files as u64).to_be_bytes());
hasher.update(&(self.max_name_bytes as u64).to_be_bytes());
hasher.update(&(self.max_path_bytes as u64).to_be_bytes());
hasher.update(&(self.max_path_depth as u64).to_be_bytes());
*hasher.finalize().as_bytes()
}
}
impl Default for MetadataLimits {
fn default() -> Self {
Self {
max_files: 20_000,
max_name_bytes: 1_024,
max_path_bytes: 4_096,
max_path_depth: 64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetadataRejectionReason {
InvalidInfoHash,
EmptyName,
NameTooLong,
InvalidName,
EmptyFileList,
TooManyFiles,
EmptyPath,
PathTooLong,
PathTooDeep,
InvalidPath,
SizeOverflow,
TotalSizeMismatch,
}
impl MetadataRejectionReason {
pub const COUNT: usize = 12;
pub const fn index(self) -> usize {
match self {
Self::InvalidInfoHash => 0,
Self::EmptyName => 1,
Self::NameTooLong => 2,
Self::InvalidName => 3,
Self::EmptyFileList => 4,
Self::TooManyFiles => 5,
Self::EmptyPath => 6,
Self::PathTooLong => 7,
Self::PathTooDeep => 8,
Self::InvalidPath => 9,
Self::SizeOverflow => 10,
Self::TotalSizeMismatch => 11,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RejectedMetadata {
pub info_hash: InfoHash,
pub reason: MetadataRejectionReason,
pub rule_id: [u8; 32],
pub first_rejected_at: u64,
pub last_seen: u64,
pub seen_count: u64,
}
impl RejectedMetadata {
pub fn new(
info_hash: InfoHash,
reason: MetadataRejectionReason,
rule_id: [u8; 32],
timestamp: u64,
) -> Self {
Self {
info_hash,
reason,
rule_id,
first_rejected_at: timestamp,
last_seen: timestamp,
seen_count: 1,
}
}
pub fn observe_again(&mut self, timestamp: u64) {
self.last_seen = self.last_seen.max(timestamp);
self.seen_count = self.seen_count.saturating_add(1);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct InfoHash([u8; 20]);
@@ -278,6 +382,91 @@ pub struct TorrentRecord {
}
impl TorrentRecord {
pub fn try_from_with_limits(
info: TorrentInfo,
limits: MetadataLimits,
) -> Result<Self, TorrentRecordError> {
let info_hash = InfoHash::from_str(&info.info_hash)?;
if info.name.trim().is_empty() {
return Err(TorrentRecordError::EmptyName);
}
if info.name.len() > limits.max_name_bytes {
return Err(TorrentRecordError::NameTooLong {
actual: info.name.len(),
limit: limits.max_name_bytes,
});
}
if info.name.chars().any(char::is_control) {
return Err(TorrentRecordError::InvalidName);
}
if info.files.is_empty() {
return Err(TorrentRecordError::EmptyFileList);
}
if info.files.len() > limits.max_files {
return Err(TorrentRecordError::TooManyFiles {
actual: info.files.len(),
limit: limits.max_files,
});
}
for file in &info.files {
validate_path(&file.path, limits)?;
}
let files: Vec<_> = info
.files
.into_iter()
.map(|file| TorrentFile {
path: file.path,
size: file.size,
})
.collect();
let calculated_size = files.iter().try_fold(0_u64, |total, file| {
total
.checked_add(file.size)
.ok_or(TorrentRecordError::SizeOverflow)
})?;
if calculated_size != info.total_size {
return Err(TorrentRecordError::TotalSizeMismatch {
declared: info.total_size,
calculated: calculated_size,
});
}
let content_key = content_key(&files)?;
let mut source_peers = info.peers;
source_peers.sort_unstable();
source_peers.dedup();
source_peers.truncate(MAX_STORED_PEERS);
let reachable_peers = source_peers.len().min(u32::MAX as usize) as u32;
let availability = if reachable_peers > 0 {
Availability {
status: AvailabilityStatus::Active,
last_verified_at: Some(info.timestamp),
last_success_at: Some(info.timestamp),
discovered_peers: reachable_peers,
reachable_peers,
consecutive_failures: 0,
next_check_at: info.timestamp.saturating_add(86_400),
}
} else {
Availability::default()
};
Ok(Self {
info_hash,
name: info.name,
total_size: info.total_size,
files,
piece_length: info.piece_length,
source_peers,
content_key,
first_seen: info.timestamp,
last_seen: info.timestamp,
seen_count: 1,
availability,
activity_score_millis: ACTIVITY_SCALE,
activity_updated_at: info.timestamp,
})
}
pub fn observe_again(&mut self, timestamp: u64, peers: &[String]) {
self.last_seen = self.last_seen.max(timestamp);
self.seen_count = self.seen_count.saturating_add(1);
@@ -369,78 +558,59 @@ impl TryFrom<TorrentInfo> for TorrentRecord {
type Error = TorrentRecordError;
fn try_from(info: TorrentInfo) -> Result<Self, Self::Error> {
let info_hash = InfoHash::from_str(&info.info_hash)?;
let files: Vec<_> = info
.files
.into_iter()
.map(|file| TorrentFile {
path: file.path,
size: file.size,
})
.collect();
if info.name.trim().is_empty() {
return Err(TorrentRecordError::EmptyName);
}
if files.is_empty() {
return Err(TorrentRecordError::EmptyFileList);
}
let calculated_size = files.iter().try_fold(0_u64, |total, file| {
total
.checked_add(file.size)
.ok_or(TorrentRecordError::SizeOverflow)
})?;
if calculated_size != info.total_size {
return Err(TorrentRecordError::TotalSizeMismatch {
declared: info.total_size,
calculated: calculated_size,
});
}
let content_key = content_key(&files)?;
let mut source_peers = info.peers;
source_peers.sort_unstable();
source_peers.dedup();
source_peers.truncate(MAX_STORED_PEERS);
let reachable_peers = source_peers.len().min(u32::MAX as usize) as u32;
let availability = if reachable_peers > 0 {
Availability {
status: AvailabilityStatus::Active,
last_verified_at: Some(info.timestamp),
last_success_at: Some(info.timestamp),
discovered_peers: reachable_peers,
reachable_peers,
consecutive_failures: 0,
next_check_at: info.timestamp.saturating_add(86_400),
}
} else {
Availability::default()
};
Ok(Self {
info_hash,
name: info.name,
total_size: info.total_size,
files,
piece_length: info.piece_length,
source_peers,
content_key,
first_seen: info.timestamp,
last_seen: info.timestamp,
seen_count: 1,
availability,
activity_score_millis: ACTIVITY_SCALE,
activity_updated_at: info.timestamp,
})
Self::try_from_with_limits(info, MetadataLimits::default())
}
}
fn validate_path(path: &str, limits: MetadataLimits) -> Result<(), TorrentRecordError> {
if path.is_empty() {
return Err(TorrentRecordError::EmptyNormalizedPath);
}
if path.len() > limits.max_path_bytes {
return Err(TorrentRecordError::PathTooLong {
actual: path.len(),
limit: limits.max_path_bytes,
});
}
if path.chars().any(char::is_control) {
return Err(TorrentRecordError::InvalidPath);
}
let mut depth = 0_usize;
for component in path.split(['/', '\\']) {
if component.is_empty() || matches!(component, "." | "..") {
return Err(TorrentRecordError::InvalidPath);
}
depth += 1;
}
if depth > limits.max_path_depth {
return Err(TorrentRecordError::PathTooDeep {
actual: depth,
limit: limits.max_path_depth,
});
}
Ok(())
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum TorrentRecordError {
#[error("infohash 必须是二十字节的十六进制字符串")]
InvalidInfoHash,
#[error("种子名称不能为空")]
EmptyName,
#[error("种子名称长度 {actual} 字节超过上限 {limit}")]
NameTooLong { actual: usize, limit: usize },
#[error("种子名称包含控制字符")]
InvalidName,
#[error("文件列表不能为空")]
EmptyFileList,
#[error("文件数量 {actual} 超过上限 {limit}")]
TooManyFiles { actual: usize, limit: usize },
#[error("文件路径长度 {actual} 字节超过上限 {limit}")]
PathTooLong { actual: usize, limit: usize },
#[error("文件路径目录层级 {actual} 超过上限 {limit}")]
PathTooDeep { actual: usize, limit: usize },
#[error("文件路径包含空段上级目录当前目录或控制字符")]
InvalidPath,
#[error("文件总大小溢出")]
SizeOverflow,
#[error("声明大小 {declared} 与文件计算大小 {calculated} 不一致")]
@@ -449,6 +619,25 @@ pub enum TorrentRecordError {
EmptyNormalizedPath,
}
impl TorrentRecordError {
pub fn rejection_reason(&self) -> MetadataRejectionReason {
match self {
Self::InvalidInfoHash => MetadataRejectionReason::InvalidInfoHash,
Self::EmptyName => MetadataRejectionReason::EmptyName,
Self::NameTooLong { .. } => MetadataRejectionReason::NameTooLong,
Self::InvalidName => MetadataRejectionReason::InvalidName,
Self::EmptyFileList => MetadataRejectionReason::EmptyFileList,
Self::TooManyFiles { .. } => MetadataRejectionReason::TooManyFiles,
Self::EmptyNormalizedPath => MetadataRejectionReason::EmptyPath,
Self::PathTooLong { .. } => MetadataRejectionReason::PathTooLong,
Self::PathTooDeep { .. } => MetadataRejectionReason::PathTooDeep,
Self::InvalidPath => MetadataRejectionReason::InvalidPath,
Self::SizeOverflow => MetadataRejectionReason::SizeOverflow,
Self::TotalSizeMismatch { .. } => MetadataRejectionReason::TotalSizeMismatch,
}
}
}
#[cfg(test)]
pub(crate) fn test_record(hash_byte: u8, timestamp: u64) -> TorrentRecord {
let files = vec![TorrentFile {
@@ -477,12 +666,110 @@ mod tests {
use super::*;
use dht_crawler::FileInfo;
fn torrent_info(files: Vec<FileInfo>) -> TorrentInfo {
TorrentInfo {
info_hash: "0101010101010101010101010101010101010101".into(),
magnet_link: String::new(),
name: "Example".into(),
total_size: files
.iter()
.fold(0_u64, |total, file| total.saturating_add(file.size)),
files,
piece_length: 16_384,
peers: Vec::new(),
timestamp: 1,
}
}
#[test]
fn infohash_round_trips_as_lowercase_hex() {
let hash = InfoHash::from_str("ABABABABABABABABABABABABABABABABABABABAB").unwrap();
assert_eq!(hash.to_string(), "abababababababababababababababababababab");
}
#[test]
fn metadata_limits_accept_boundary_and_reject_excess() {
let limits = MetadataLimits {
max_files: 1,
max_name_bytes: 7,
max_path_bytes: 8,
max_path_depth: 2,
};
let accepted = torrent_info(vec![FileInfo {
path: "dir/a.rs".into(),
size: 1,
}]);
assert!(TorrentRecord::try_from_with_limits(accepted, limits).is_ok());
let too_many = torrent_info(vec![
FileInfo {
path: "a".into(),
size: 1,
},
FileInfo {
path: "b".into(),
size: 1,
},
]);
let error = TorrentRecord::try_from_with_limits(too_many, limits).unwrap_err();
assert_eq!(
error.rejection_reason(),
MetadataRejectionReason::TooManyFiles
);
}
#[test]
fn unsafe_and_deep_paths_are_rejected_by_reason() {
let limits = MetadataLimits {
max_path_depth: 2,
..MetadataLimits::default()
};
for (path, reason) in [
("dir/../file", MetadataRejectionReason::InvalidPath),
("dir//file", MetadataRejectionReason::InvalidPath),
("a/b/c", MetadataRejectionReason::PathTooDeep),
("a\0b", MetadataRejectionReason::InvalidPath),
] {
let info = torrent_info(vec![FileInfo {
path: path.into(),
size: 1,
}]);
let error = TorrentRecord::try_from_with_limits(info, limits).unwrap_err();
assert_eq!(error.rejection_reason(), reason, "path={path:?}");
}
}
#[test]
fn size_overflow_is_rejected_without_panicking() {
let mut info = torrent_info(vec![
FileInfo {
path: "a".into(),
size: u64::MAX,
},
FileInfo {
path: "b".into(),
size: 1,
},
]);
info.total_size = u64::MAX;
let error =
TorrentRecord::try_from_with_limits(info, MetadataLimits::default()).unwrap_err();
assert_eq!(
error.rejection_reason(),
MetadataRejectionReason::SizeOverflow
);
}
#[test]
fn rule_id_changes_when_a_limit_changes() {
let defaults = MetadataLimits::default();
let changed = MetadataLimits {
max_files: defaults.max_files - 1,
..defaults
};
assert_ne!(defaults.rule_id(), changed.rule_id());
}
#[test]
fn repeated_observation_updates_time_count_and_unique_peers() {
let mut record = test_record(1, 10);
+5
View File
@@ -6,6 +6,7 @@ pub(crate) const DATABASE_FORMAT_KEY: &[u8] = b"\x00database-format";
pub(crate) const DATABASE_FORMAT_VALUE: &[u8] = b"dht-search";
pub(crate) const VERIFICATION_QUEUE_COUNT_KEY: &[u8] = b"\x00verification-queue-count";
const TORRENT_PREFIX: u8 = b't';
const REJECTED_METADATA_PREFIX: u8 = b'r';
const CONTENT_PREFIX: u8 = b'c';
const CONTENT_GROUP_PREFIX: u8 = b'g';
const PENDING_INDEX_PREFIX: u8 = b'p';
@@ -18,6 +19,10 @@ pub(crate) fn torrent_key(info_hash: InfoHash) -> [u8; 1 + InfoHash::BYTE_LEN] {
prefixed_info_hash(TORRENT_PREFIX, info_hash)
}
pub(crate) fn rejected_metadata_key(info_hash: InfoHash) -> [u8; 1 + InfoHash::BYTE_LEN] {
prefixed_info_hash(REJECTED_METADATA_PREFIX, info_hash)
}
pub(crate) fn content_group_key(content_key: &[u8; 32]) -> [u8; 1 + 32] {
prefixed_content_key(CONTENT_GROUP_PREFIX, content_key)
}
+5 -1
View File
@@ -1,6 +1,6 @@
// 负责定义元数据去重状态恢复和索引任务所需的存储接口
use crate::domain::{ContentGroup, InfoHash, TorrentRecord, VerificationResult};
use crate::domain::{ContentGroup, InfoHash, RejectedMetadata, TorrentRecord, VerificationResult};
pub trait TorrentRepository: Send + Sync {
fn get(&self, info_hash: InfoHash) -> Result<Option<TorrentRecord>, StorageError>;
@@ -11,6 +11,10 @@ pub trait TorrentRepository: Send + Sync {
fn upsert(&self, observation: TorrentRecord) -> Result<UpsertOutcome, StorageError>;
fn rejection(&self, info_hash: InfoHash) -> Result<Option<RejectedMetadata>, StorageError>;
fn record_rejection(&self, rejection: RejectedMetadata) -> Result<(), StorageError>;
fn observe_existing(&self, info_hash: InfoHash, observed_at: u64)
-> Result<bool, StorageError>;
+166 -13
View File
@@ -7,15 +7,18 @@ use rocksdb::{
SliceTransform, WriteBatch,
};
use crate::domain::{ContentGroupBuilder, InfoHash, TorrentRecord, VerificationResult};
use crate::domain::{
ContentGroupBuilder, InfoHash, MetadataLimits, RejectedMetadata, TorrentRecord,
VerificationResult,
};
use super::{
keys::{
DATABASE_FORMAT_KEY, DATABASE_FORMAT_VALUE, VERIFICATION_QUEUE_COUNT_KEY,
content_group_key, content_group_prefix, content_member_key, content_members_prefix,
decode_content_member_info_hash, decode_pending_content_key, decode_verification_lease,
decode_verification_task, pending_index_key, pending_index_prefix, torrent_key,
verification_lease_key, verification_lease_prefix, verification_locator_key,
decode_verification_task, pending_index_key, pending_index_prefix, rejected_metadata_key,
torrent_key, verification_lease_key, verification_lease_prefix, verification_locator_key,
verification_task_key, verification_task_prefix,
},
repository::{
@@ -36,10 +39,18 @@ struct ContentGroupState {
pub struct RocksTorrentRepository {
db: DB,
write_lock: Mutex<()>,
rejection_rule_id: [u8; 32],
}
impl RocksTorrentRepository {
pub fn open(path: impl AsRef<Path>) -> Result<Self, StorageError> {
Self::open_with_rejection_rule(path, MetadataLimits::default().rule_id())
}
pub fn open_with_rejection_rule(
path: impl AsRef<Path>,
rejection_rule_id: [u8; 32],
) -> Result<Self, StorageError> {
let mut block_options = BlockBasedOptions::default();
block_options.set_bloom_filter(10.0, false);
let block_cache = Cache::new_lru_cache(DEFAULT_BLOCK_CACHE_BYTES);
@@ -55,6 +66,7 @@ impl RocksTorrentRepository {
let repository = Self {
db: DB::open(&options, path)?,
write_lock: Mutex::new(()),
rejection_rule_id,
};
repository.initialize_format()?;
Ok(repository)
@@ -79,6 +91,14 @@ impl RocksTorrentRepository {
rmp_serde::from_slice(bytes).map_err(Into::into)
}
fn encode_rejection(rejection: &RejectedMetadata) -> Result<Vec<u8>, StorageError> {
rmp_serde::to_vec_named(rejection).map_err(Into::into)
}
fn decode_rejection(bytes: &[u8]) -> Result<RejectedMetadata, StorageError> {
rmp_serde::from_slice(bytes).map_err(Into::into)
}
fn encode_group(state: ContentGroupState) -> Result<Vec<u8>, StorageError> {
rmp_serde::to_vec_named(&state).map_err(Into::into)
}
@@ -197,6 +217,7 @@ impl TorrentRepository for RocksTorrentRepository {
torrent_key(observation.info_hash),
Self::encode(&observation)?,
);
batch.delete(rejected_metadata_key(observation.info_hash));
batch.put(
content_member_key(&observation.content_key, observation.info_hash),
[],
@@ -206,6 +227,34 @@ impl TorrentRepository for RocksTorrentRepository {
Ok(UpsertOutcome::Inserted)
}
fn rejection(&self, info_hash: InfoHash) -> Result<Option<RejectedMetadata>, StorageError> {
self.db
.get(rejected_metadata_key(info_hash))?
.map(|bytes| Self::decode_rejection(&bytes))
.transpose()
}
fn record_rejection(&self, mut rejection: RejectedMetadata) -> Result<(), StorageError> {
let _guard = self
.write_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.get(rejection.info_hash)?.is_some() {
return Ok(());
}
if let Some(mut current) = self.rejection(rejection.info_hash)?
&& current.rule_id == rejection.rule_id
{
current.observe_again(rejection.last_seen);
rejection = current;
}
self.db.put(
rejected_metadata_key(rejection.info_hash),
Self::encode_rejection(&rejection)?,
)?;
Ok(())
}
fn observe_existing(
&self,
info_hash: InfoHash,
@@ -216,7 +265,18 @@ impl TorrentRepository for RocksTorrentRepository {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(mut record) = self.get(info_hash)? else {
return Ok(false);
let Some(mut rejection) = self.rejection(info_hash)? else {
return Ok(false);
};
if rejection.rule_id != self.rejection_rule_id {
return Ok(false);
}
rejection.observe_again(observed_at);
self.db.put(
rejected_metadata_key(info_hash),
Self::encode_rejection(&rejection)?,
)?;
return Ok(true);
};
record.observe_again(observed_at, &[]);
let mut batch = WriteBatch::default();
@@ -239,21 +299,42 @@ impl TorrentRepository for RocksTorrentRepository {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let keys: Vec<_> = info_hashes.iter().copied().map(torrent_key).collect();
let rejected_keys: Vec<_> = info_hashes
.iter()
.copied()
.map(rejected_metadata_key)
.collect();
let records = self.db.multi_get(keys.iter());
let rejections = self.db.multi_get(rejected_keys.iter());
let mut unknown = Vec::with_capacity(info_hashes.len());
let mut batch = WriteBatch::default();
let mut updated = 0_usize;
let mut dirty_groups = std::collections::BTreeSet::new();
for ((info_hash, key), record) in info_hashes.iter().zip(&keys).zip(records) {
let Some(bytes) = record? else {
unknown.push(*info_hash);
for ((((info_hash, key), rejected_key), record), rejection) in info_hashes
.iter()
.zip(&keys)
.zip(&rejected_keys)
.zip(records)
.zip(rejections)
{
if let Some(bytes) = record? {
let mut record = Self::decode(&bytes)?;
record.observe_again(observed_at, &[]);
batch.put(key, Self::encode(&record)?);
dirty_groups.insert(record.content_key);
updated += 1;
continue;
};
let mut record = Self::decode(&bytes)?;
record.observe_again(observed_at, &[]);
batch.put(key, Self::encode(&record)?);
dirty_groups.insert(record.content_key);
updated += 1;
}
if let Some(bytes) = rejection? {
let mut rejection = Self::decode_rejection(&bytes)?;
if rejection.rule_id == self.rejection_rule_id {
rejection.observe_again(observed_at);
batch.put(rejected_key, Self::encode_rejection(&rejection)?);
updated += 1;
continue;
}
}
unknown.push(*info_hash);
}
if updated > 0 {
for content_key in dirty_groups {
@@ -666,6 +747,78 @@ mod tests {
assert_eq!(stored.seen_count, 2);
}
#[test]
fn rejection_survives_restart_and_blocks_the_same_rule() {
let directory = TempDir::new().unwrap();
let limits = MetadataLimits::default();
let info_hash = InfoHash::from_bytes([9; 20]);
{
let repository = RocksTorrentRepository::open_with_rejection_rule(
directory.path(),
limits.rule_id(),
)
.unwrap();
repository
.record_rejection(RejectedMetadata::new(
info_hash,
crate::domain::MetadataRejectionReason::TooManyFiles,
limits.rule_id(),
10,
))
.unwrap();
assert!(repository.get(info_hash).unwrap().is_none());
assert!(repository.observe_existing(info_hash, 20).unwrap());
}
let repository =
RocksTorrentRepository::open_with_rejection_rule(directory.path(), limits.rule_id())
.unwrap();
assert!(repository.observe_existing(info_hash, 30).unwrap());
let rejection = repository.rejection(info_hash).unwrap().unwrap();
assert_eq!(rejection.first_rejected_at, 10);
assert_eq!(rejection.last_seen, 30);
assert_eq!(rejection.seen_count, 3);
assert!(repository.pending_index(10).unwrap().is_empty());
}
#[test]
fn changed_rule_releases_old_rejection_and_valid_upsert_removes_it() {
let directory = TempDir::new().unwrap();
let old_limits = MetadataLimits::default();
let info_hash = InfoHash::from_bytes([10; 20]);
{
let repository = RocksTorrentRepository::open_with_rejection_rule(
directory.path(),
old_limits.rule_id(),
)
.unwrap();
repository
.record_rejection(RejectedMetadata::new(
info_hash,
crate::domain::MetadataRejectionReason::TooManyFiles,
old_limits.rule_id(),
10,
))
.unwrap();
}
let new_limits = MetadataLimits {
max_files: old_limits.max_files + 1,
..old_limits
};
let repository = RocksTorrentRepository::open_with_rejection_rule(
directory.path(),
new_limits.rule_id(),
)
.unwrap();
assert!(!repository.observe_existing(info_hash, 20).unwrap());
let mut record = test_record(10, 20);
record.info_hash = info_hash;
repository.upsert(record).unwrap();
assert!(repository.rejection(info_hash).unwrap().is_none());
assert!(repository.get(info_hash).unwrap().is_some());
}
#[test]
fn equal_content_maps_multiple_infohashes_without_merging_records() {
let directory = TempDir::new().unwrap();
+1 -1
View File
@@ -212,7 +212,7 @@ onBeforeUnmount(() => {
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="stats ? 'bg-emerald-500' : 'bg-muted-foreground'" /></span></Button>
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs"><div class="status-cell"><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></div><div class="status-cell"><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></div><div class="status-cell"><span>获取成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></div><div class="status-cell"><span>下载中</span><b>{{ stats.metadata_in_flight }}</b></div><div class="status-cell"><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></div><div class="status-cell"><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></div></div>
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs"><div class="status-cell"><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></div><div class="status-cell"><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></div><div class="status-cell"><span>获取成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></div><div class="status-cell"><span>下载中</span><b>{{ stats.metadata_in_flight }}</b></div><div class="status-cell"><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></div><div class="status-cell"><span>已过滤</span><b>{{ stats.metadata_filtered.toLocaleString() }}</b></div><div class="status-cell"><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></div></div>
<p v-else class="py-4 text-center text-xs text-muted-foreground">无法获取服务状态</p>
</div>
</div>
+1
View File
@@ -99,6 +99,7 @@ export interface ServiceStats {
metadata_ok: number
metadata_failed: number
metadata_in_flight: number
metadata_filtered: number
persistence_inserted: number
persistence_updated: number
persistence_queue: number