feat: 增加可回滚的无效文件过滤

This commit is contained in:
chuan
2026-08-10 12:43:01 +08:00
parent b41fa23a77
commit 3345dd126e
19 changed files with 986 additions and 31 deletions
Generated
+1
View File
@@ -657,6 +657,7 @@ dependencies = [
"clap",
"dht-crawler",
"hex",
"regex",
"rmp-serde",
"rocksdb",
"serde",
+2
View File
@@ -23,6 +23,8 @@ Rust 测试分层和默认验证命令见 [`TESTING.md`](TESTING.md)
应用配置模板见 [`dht-search.example.toml`](dht-search.example.toml)
无效文件隐藏规则见 [`content-filters.toml`](content-filters.toml)
应用构建运行和 API 文档见 [`dht-search/README.md`](dht-search/README.md)
Web 开发和构建方式见 [`web/README.md`](web/README.md)
+10 -2
View File
@@ -213,7 +213,14 @@
- [x] `/stats` 返回验证队列发现握手成功失败和拒绝指标
- [ ] 统计真实数据的 infohash 重复率和内容重复率
- [ ] 定义可配置的名称路径扩展名和大小过滤规则
- [x] 使用独立配置文件定义文件名和文件路径隐藏规则
- [x] 支持精确前缀后缀包含通配符和正则匹配并限制规则复杂度
- [x] 保留 RocksDB 原始文件列表并为详情统计搜索和内容聚合生成有效内容视图
- [x] 使用规则指纹在配置变化时重算内容组并从 RocksDB 重建 Tantivy
- [x] 默认隐藏 BitComet padding 文件以及 `.pad``.____padding_file` 填充目录
- [x] 全部文件被隐藏的 Metadata 只保留原始记录且不进入公开索引
- [ ] 根据真实垃圾数据决定是否增加种子名称扩展名和大小准入规则
- [ ] 增加按规则 ID 分类的隐藏文件命中指标
- [x] 定义可配置的 Metadata 最大大小文件数名称路径长度和目录层级限制
- [x] 识别空名称控制字符异常路径大小溢出总大小不一致和文件数量攻击
- [ ] 设计可解释的名称标准化规则
@@ -232,7 +239,7 @@
- [x] 精确重复不会重复下载和重复展示
- [x] 内容重复可以折叠并保留全部 infohash
- [ ] 过滤规则可以配置更新和回滚
- [x] 过滤规则可以配置更新和回滚且不会删除原始 Metadata
- [ ] 模糊去重不会直接造成数据丢失
## 阶段六 性能资源和长期运行
@@ -325,6 +332,7 @@
- [x] 将 DHT 响应限流器从服务器编排中提取为独立组合组件
- [x] 将规模基准拆分为参数数据集工作负载采样报告和编排模块
- [x] 明确单元组件集成端到端和性能测试层级并增加公开 API 集成测试
- [x] 实现可回滚的无效文件过滤并重建有效内容聚合和搜索索引
完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本
+33
View File
@@ -0,0 +1,33 @@
# 定义不参与搜索展示统计和内容聚合的无效文件规则
version = 1
[[file_rules]]
id = "bitcomet-padding-file"
enabled = true
field = "file-name"
match = "prefix"
value = "_____padding_file_"
case_sensitive = false
action = "hide"
reason = "BitComet 分片边界填充文件"
[[file_rules]]
id = "generic-pad-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.pad/'
case_sensitive = false
action = "hide"
reason = "客户端分片边界填充目录"
[[file_rules]]
id = "libtorrent-padding-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.____padding_file/'
case_sensitive = false
action = "hide"
reason = "libtorrent 分片边界填充目录"
+1
View File
@@ -1,6 +1,7 @@
# 定义 dht-search 的推荐起始配置并作为用户配置模板
data_dir = "data"
content_filter_file = "content-filters.toml"
persistence_queue_capacity = 8192
stats_interval_secs = 10
index_batch_size = 1024
+1
View File
@@ -21,6 +21,7 @@ dht-crawler = { path = "../dht-crawler", features = ["metrics"] }
hex = "0.4"
rocksdb = { version = "0.24.0", default-features = false, features = ["bindgen-runtime", "lz4"], optional = true }
rmp-serde = "1.3"
regex = "1.12"
serde.workspace = true
serde_json = "1.0"
tantivy = "0.26.1"
+14
View File
@@ -26,6 +26,8 @@ Copy-Item dht-search.example.toml dht-search.toml
相对 `data_dir` 以配置文件所在目录为基准解析
`content_filter_file` 指向独立的无效文件过滤配置 相对路径同样以主配置文件所在目录为基准解析 推荐直接使用根目录的 `content-filters.toml`
也可以通过命令行覆盖数据目录和本次运行时长
```powershell
@@ -111,6 +113,18 @@ Invoke-RestMethod http://127.0.0.1:8080/stats | ConvertTo-Json -Depth 5
严格测试配置仅用于观察过滤效果 不应作为正式采集配置
### 无效文件隐藏规则
`content-filters.toml` 控制哪些文件不参与详情展示 搜索 文件数量 有效大小和内容聚合 默认规则会隐藏 BitComet `_____padding_file_` 文件以及 `.pad``.____padding_file` 填充目录
RocksDB 始终保存完整原始 Metadata 隐藏规则不会删除文件或种子 修改或回滚规则后应用会根据规则指纹重新计算内容组并从 RocksDB 重建 Tantivy
每条规则包含稳定 `id` 开关 匹配字段 匹配方式 值 大小写选项和可读原因 当前字段支持 `file-name``file-path` 匹配方式支持 `exact` `prefix` `suffix` `contains` `wildcard``regex` 动作只允许安全的 `hide`
通配符中 `*` 表示任意长度字符 `?` 表示一个字符并匹配完整字段 正则表达式使用 Rust `regex` 语法 文件路径在匹配前统一使用 `/` 分隔符
如果一个 Metadata 的全部文件都被隐藏 原始记录仍保留在 RocksDB 但不会进入搜索索引或公开详情
### 采样去重和 Peer 查找
BEP-51 返回的 infohash 会先进入有界批量准入队列并由 RocksDB 精确判断
+1 -1
View File
@@ -260,7 +260,7 @@ pub(crate) async fn torrent(
let verification = state.verification.clone();
let info_hash =
InfoHash::from_str(&info_hash).map_err(|error| ApiError::bad_request(error.to_string()))?;
let record = tokio::task::spawn_blocking(move || state.repository.get(info_hash))
let record = tokio::task::spawn_blocking(move || state.repository.get_visible(info_hash))
.await
.map_err(|error| ApiError::internal(error.to_string()))?
.map_err(|error| ApiError::internal(error.to_string()))?
+9 -2
View File
@@ -26,11 +26,18 @@ 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 metadata_limits = config.metadata_limits();
let repository = Arc::new(RocksTorrentRepository::open_with_rejection_rule(
let content_filter = Arc::new(config.content_filter()?);
let repository = Arc::new(RocksTorrentRepository::open_with_rules(
&database_path,
metadata_limits.rule_id(),
content_filter,
)?);
let (search, search_created) = SearchEngine::open_with_status(config.data_dir.join("tantivy"))?;
let search_path = config.data_dir.join("tantivy");
let (search, search_created) = if repository.content_filter_changed() {
(SearchEngine::recreate(&search_path)?, true)
} else {
SearchEngine::open_with_status(&search_path)?
};
if search_created {
let records = repository.prepare_full_reindex()?;
tracing::info!(records, "检测到新搜索索引并准备全量重建");
+17 -1
View File
@@ -10,7 +10,7 @@ use dht_crawler::{
use serde::Deserialize;
use crate::error::AppError;
use dht_search::domain::MetadataLimits;
use dht_search::domain::{ContentFilter, ContentFilterConfig, MetadataLimits};
#[derive(Debug, Parser)]
#[command(name = "dht-search", version, about = "DHT 元数据采集和搜索服务")]
@@ -27,6 +27,7 @@ pub(crate) struct Cli {
#[serde(default, deny_unknown_fields)]
pub(crate) struct AppConfig {
pub(crate) data_dir: PathBuf,
pub(crate) content_filter_file: PathBuf,
pub(crate) persistence_queue_capacity: usize,
pub(crate) stats_interval_secs: u64,
pub(crate) run_duration_secs: Option<u64>,
@@ -122,6 +123,10 @@ impl Cli {
config.data_dir = base.join(&config.data_dir);
}
config.data_dir = normalize_absolute(config.data_dir)?;
if config.content_filter_file.is_relative() {
config.content_filter_file = base.join(&config.content_filter_file);
}
config.content_filter_file = normalize_absolute(config.content_filter_file)?;
if config.http.web_dir.is_relative() {
config.http.web_dir = base.join(&config.http.web_dir);
}
@@ -132,6 +137,12 @@ impl Cli {
}
impl AppConfig {
pub(crate) fn content_filter(&self) -> Result<ContentFilter, AppError> {
let contents = fs::read_to_string(&self.content_filter_file)?;
let config = toml::from_str::<ContentFilterConfig>(&contents)?;
ContentFilter::compile(config).map_err(AppError::from)
}
pub(crate) fn dht_options(&self) -> DHTOptions {
let defaults = DHTOptions::default();
DHTOptions {
@@ -264,6 +275,7 @@ impl Default for AppConfig {
fn default() -> Self {
Self {
data_dir: PathBuf::from("data"),
content_filter_file: PathBuf::from("content-filters.toml"),
persistence_queue_capacity: 8_192,
stats_interval_secs: 10,
run_duration_secs: None,
@@ -405,6 +417,10 @@ mod tests {
.load()
.unwrap();
assert_eq!(config.data_dir, directory.path().join("state"));
assert_eq!(
config.content_filter_file,
directory.path().join("content-filters.toml")
);
assert_eq!(config.http.web_dir, directory.path().join("web/dist"));
}
+408
View File
@@ -0,0 +1,408 @@
// 负责定义可配置的无效文件识别规则和面向用户的有效内容视图
use std::collections::HashSet;
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use super::{TorrentFile, TorrentRecord, TorrentRecordError, content_key};
const FILTER_FORMAT_VERSION: u64 = 1;
const MAX_RULES: usize = 256;
const MAX_PATTERN_BYTES: usize = 1_024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContentFilterConfig {
pub version: u64,
#[serde(default)]
pub file_rules: Vec<FileFilterRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileFilterRule {
pub id: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub field: FileMatchField,
#[serde(rename = "match")]
pub match_kind: FileMatchKind,
pub value: String,
#[serde(default)]
pub case_sensitive: bool,
pub action: FileRuleAction,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileMatchField {
FileName,
FilePath,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileMatchKind {
Exact,
Prefix,
Suffix,
Contains,
Wildcard,
Regex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileRuleAction {
Hide,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FilterOutcome {
pub hidden_files: usize,
pub searchable: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ContentFilterError {
#[error("内容过滤规则版本 {0} 不受支持")]
UnsupportedVersion(u64),
#[error("内容过滤规则数量 {actual} 超过上限 {limit}")]
TooManyRules { actual: usize, limit: usize },
#[error("内容过滤规则 ID 不能为空")]
EmptyRuleId,
#[error("内容过滤规则 ID 重复: {0}")]
DuplicateRuleId(String),
#[error("内容过滤规则 {0} 的匹配值不能为空")]
EmptyPattern(String),
#[error("内容过滤规则 {id} 的匹配值超过 {limit} 字节")]
PatternTooLong { id: String, limit: usize },
#[error("内容过滤规则 {id} 的正则表达式无效: {source}")]
InvalidRegex { id: String, source: regex::Error },
#[error("内容过滤规则无法生成稳定指纹: {0}")]
Fingerprint(serde_json::Error),
}
#[derive(Debug)]
pub struct ContentFilter {
fingerprint: [u8; 32],
rules: Vec<CompiledRule>,
}
#[derive(Debug)]
struct CompiledRule {
field: FileMatchField,
case_sensitive: bool,
matcher: CompiledMatcher,
}
#[derive(Debug)]
enum CompiledMatcher {
Exact(String),
Prefix(String),
Suffix(String),
Contains(String),
Pattern(Regex),
}
impl ContentFilter {
pub fn compile(config: ContentFilterConfig) -> Result<Self, ContentFilterError> {
if config.version != FILTER_FORMAT_VERSION {
return Err(ContentFilterError::UnsupportedVersion(config.version));
}
if config.file_rules.len() > MAX_RULES {
return Err(ContentFilterError::TooManyRules {
actual: config.file_rules.len(),
limit: MAX_RULES,
});
}
let fingerprint =
*blake3::hash(&serde_json::to_vec(&config).map_err(ContentFilterError::Fingerprint)?)
.as_bytes();
let mut ids = HashSet::with_capacity(config.file_rules.len());
let mut rules = Vec::new();
for rule in config.file_rules {
if rule.id.trim().is_empty() {
return Err(ContentFilterError::EmptyRuleId);
}
if !ids.insert(rule.id.clone()) {
return Err(ContentFilterError::DuplicateRuleId(rule.id));
}
if rule.value.is_empty() {
return Err(ContentFilterError::EmptyPattern(rule.id));
}
if rule.value.len() > MAX_PATTERN_BYTES {
return Err(ContentFilterError::PatternTooLong {
id: rule.id,
limit: MAX_PATTERN_BYTES,
});
}
if !rule.enabled {
continue;
}
let mut value = normalize(&rule.value, rule.case_sensitive);
if rule.field == FileMatchField::FilePath && rule.match_kind != FileMatchKind::Regex {
value = value.replace('\\', "/");
}
let matcher = match rule.match_kind {
FileMatchKind::Exact => CompiledMatcher::Exact(value),
FileMatchKind::Prefix => CompiledMatcher::Prefix(value),
FileMatchKind::Suffix => CompiledMatcher::Suffix(value),
FileMatchKind::Contains => CompiledMatcher::Contains(value),
FileMatchKind::Wildcard => CompiledMatcher::Pattern(
RegexBuilder::new(&glob_regex(&value))
.case_insensitive(false)
.build()
.map_err(|source| ContentFilterError::InvalidRegex {
id: rule.id.clone(),
source,
})?,
),
FileMatchKind::Regex => CompiledMatcher::Pattern(
RegexBuilder::new(&rule.value)
.case_insensitive(!rule.case_sensitive)
.build()
.map_err(|source| ContentFilterError::InvalidRegex {
id: rule.id.clone(),
source,
})?,
),
};
rules.push(CompiledRule {
field: rule.field,
case_sensitive: rule.case_sensitive || rule.match_kind == FileMatchKind::Regex,
matcher,
});
}
Ok(Self { fingerprint, rules })
}
pub fn fingerprint(&self) -> [u8; 32] {
self.fingerprint
}
pub fn apply_derivatives(
&self,
record: &mut TorrentRecord,
) -> Result<FilterOutcome, TorrentRecordError> {
let visible: Vec<_> = record
.files
.iter()
.filter(|file| !self.is_hidden(file))
.cloned()
.collect();
let hidden_files = record.files.len().saturating_sub(visible.len());
record.searchable = !visible.is_empty();
record.content_key = if record.searchable {
content_key(&visible)?
} else {
[0; 32]
};
Ok(FilterOutcome {
hidden_files,
searchable: record.searchable,
})
}
pub fn public_record(&self, record: &TorrentRecord) -> Option<TorrentRecord> {
if !record.searchable {
return None;
}
let mut public = record.clone();
public.files.retain(|file| !self.is_hidden(file));
if public.files.is_empty() {
return None;
}
public.total_size = public
.files
.iter()
.fold(0_u64, |total, file| total.saturating_add(file.size));
Some(public)
}
pub fn is_hidden(&self, file: &TorrentFile) -> bool {
self.rules.iter().any(|rule| rule.matches(file))
}
}
impl Default for ContentFilter {
fn default() -> Self {
Self::compile(ContentFilterConfig {
version: FILTER_FORMAT_VERSION,
file_rules: Vec::new(),
})
.expect("空内容过滤规则必须有效")
}
}
impl CompiledRule {
fn matches(&self, file: &TorrentFile) -> bool {
let target = match self.field {
FileMatchField::FileName => file
.path
.rsplit(['/', '\\'])
.next()
.unwrap_or(file.path.as_str()),
FileMatchField::FilePath => &file.path,
};
let mut target = normalize(target, self.case_sensitive);
if self.field == FileMatchField::FilePath {
target = target.replace('\\', "/");
}
match &self.matcher {
CompiledMatcher::Exact(value) => target == *value,
CompiledMatcher::Prefix(value) => target.starts_with(value),
CompiledMatcher::Suffix(value) => target.ends_with(value),
CompiledMatcher::Contains(value) => target.contains(value),
CompiledMatcher::Pattern(pattern) => pattern.is_match(&target),
}
}
}
fn normalize(value: &str, case_sensitive: bool) -> String {
let normalized: String = value.nfkc().collect();
if case_sensitive {
normalized
} else {
normalized.to_lowercase()
}
}
fn glob_regex(pattern: &str) -> String {
let mut output = String::from("^");
let mut escaped = false;
for character in pattern.chars() {
if escaped {
output.push_str(&regex::escape(&character.to_string()));
escaped = false;
} else {
match character {
'\\' => escaped = true,
'*' => output.push_str(".*"),
'?' => output.push('.'),
_ => output.push_str(&regex::escape(&character.to_string())),
}
}
}
if escaped {
output.push_str(r"\\");
}
output.push('$');
output
}
const fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::test_record;
fn filter(rules: Vec<FileFilterRule>) -> ContentFilter {
ContentFilter::compile(ContentFilterConfig {
version: 1,
file_rules: rules,
})
.unwrap()
}
fn rule(field: FileMatchField, match_kind: FileMatchKind, value: &str) -> FileFilterRule {
FileFilterRule {
id: format!("{field:?}-{match_kind:?}"),
enabled: true,
field,
match_kind,
value: value.into(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "测试".into(),
}
}
#[test]
fn prefix_rule_hides_bitcomet_padding_case_insensitively() {
let filter = filter(vec![rule(
FileMatchField::FileName,
FileMatchKind::Prefix,
"_____padding_file_",
)]);
assert!(filter.is_hidden(&TorrentFile {
path: "目录/_____PADDING_FILE_1_请升级____".into(),
size: 16,
}));
}
#[test]
fn wildcard_matches_the_whole_selected_field() {
let filter = filter(vec![rule(
FileMatchField::FilePath,
FileMatchKind::Wildcard,
".pad/*",
)]);
assert!(filter.is_hidden(&TorrentFile {
path: ".pad/123".into(),
size: 1,
}));
assert!(!filter.is_hidden(&TorrentFile {
path: "movie.pad/123".into(),
size: 1,
}));
}
#[test]
fn regex_rule_matches_nested_libtorrent_padding_directory() {
let filter = filter(vec![rule(
FileMatchField::FilePath,
FileMatchKind::Regex,
r"(^|/)\.____padding_file/",
)]);
assert!(filter.is_hidden(&TorrentFile {
path: "release/.____padding_file/47".into(),
size: 1,
}));
assert!(!filter.is_hidden(&TorrentFile {
path: "release/real_padding_file.txt".into(),
size: 1,
}));
}
#[test]
fn public_record_keeps_raw_record_unchanged() {
let filter = filter(vec![rule(
FileMatchField::FileName,
FileMatchKind::Prefix,
"_____padding_file_",
)]);
let mut record = test_record(1, 1);
record.files.push(TorrentFile {
path: "_____padding_file_1_".into(),
size: 100,
});
record.total_size += 100;
filter.apply_derivatives(&mut record).unwrap();
let public = filter.public_record(&record).unwrap();
assert_eq!(record.files.len(), 2);
assert_eq!(record.total_size, 142);
assert_eq!(public.files.len(), 1);
assert_eq!(public.total_size, 42);
}
#[test]
fn record_with_only_hidden_files_is_not_searchable() {
let filter = filter(vec![rule(
FileMatchField::FileName,
FileMatchKind::Prefix,
"_____padding_file_",
)]);
let mut record = test_record(1, 1);
record.files[0].path = "_____padding_file_1_".into();
let outcome = filter.apply_derivatives(&mut record).unwrap();
assert!(!outcome.searchable);
assert!(filter.public_record(&record).is_none());
}
}
+5
View File
@@ -1,11 +1,16 @@
// 负责导出不依赖存储搜索和传输实现的核心领域模型
mod content_filter;
mod content_group;
mod fingerprint;
mod info_hash;
mod metadata;
mod torrent;
pub use content_filter::{
ContentFilter, ContentFilterConfig, ContentFilterError, FileFilterRule, FileMatchField,
FileMatchKind, FileRuleAction, FilterOutcome,
};
pub use content_group::ContentGroup;
pub(crate) use content_group::ContentGroupBuilder;
pub use fingerprint::content_key;
+8
View File
@@ -80,6 +80,8 @@ pub struct TorrentRecord {
pub piece_length: u64,
pub source_peers: Vec<String>,
pub content_key: [u8; 32],
#[serde(default = "default_searchable")]
pub searchable: bool,
pub first_seen: u64,
pub last_seen: u64,
pub seen_count: u64,
@@ -113,6 +115,7 @@ impl TorrentRecord {
piece_length: new.piece_length,
source_peers: new.source_peers,
content_key: new.content_key,
searchable: true,
first_seen: new.timestamp,
last_seen: new.timestamp,
seen_count: 1,
@@ -194,6 +197,10 @@ impl TorrentRecord {
}
}
const fn default_searchable() -> bool {
true
}
fn default_activity_score() -> u64 {
ACTIVITY_SCALE
}
@@ -227,6 +234,7 @@ pub(crate) fn test_record(hash_byte: u8, timestamp: u64) -> TorrentRecord {
name: "Example".to_owned(),
total_size: 42,
content_key: content_key(&files).expect("test file path is valid"),
searchable: true,
files,
piece_length: 16_384,
source_peers: vec!["127.0.0.1:6881".to_owned()],
+2
View File
@@ -6,6 +6,8 @@ pub(crate) enum AppError {
Io(#[from] std::io::Error),
#[error("配置解析失败: {0}")]
Toml(#[from] toml::de::Error),
#[error("内容过滤配置无效: {0}")]
ContentFilter(#[from] dht_search::domain::ContentFilterError),
#[error("配置无效: {0}")]
Config(String),
#[error("DHT 服务失败: {0}")]
+10
View File
@@ -42,6 +42,15 @@ impl SearchEngine {
Self::open_with_status(path).map(|(engine, _)| engine)
}
pub fn recreate(path: impl AsRef<Path>) -> Result<Self, SearchError> {
let path = path.as_ref();
if path.exists() {
std::fs::remove_dir_all(path)
.map_err(|error| SearchError::Directory(error.to_string()))?;
}
Self::open(path)
}
pub fn open_with_status(path: impl AsRef<Path>) -> Result<(Self, bool), SearchError> {
let path = path.as_ref().to_path_buf();
std::fs::create_dir_all(&path)
@@ -288,6 +297,7 @@ mod tests {
piece_length: 16_384,
source_peers: Vec::new(),
content_key: [2; 32],
searchable: true,
first_seen: 10,
last_seen: 20,
seen_count: 3,
+10
View File
@@ -5,6 +5,8 @@ use crate::domain::InfoHash;
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";
pub(crate) const CONTENT_FILTER_FINGERPRINT_KEY: &[u8] = b"\x00content-filter-fingerprint";
pub(crate) const CONTENT_FILTER_MIGRATION_KEY: &[u8] = b"\x00content-filter-migration";
const TORRENT_PREFIX: u8 = b't';
const REJECTED_METADATA_PREFIX: u8 = b'r';
const CONTENT_PREFIX: u8 = b'c';
@@ -19,6 +21,10 @@ pub(crate) fn torrent_key(info_hash: InfoHash) -> [u8; 1 + InfoHash::BYTE_LEN] {
prefixed_info_hash(TORRENT_PREFIX, info_hash)
}
pub(crate) fn torrent_prefix() -> [u8; 1] {
[TORRENT_PREFIX]
}
pub(crate) fn rejected_metadata_key(info_hash: InfoHash) -> [u8; 1 + InfoHash::BYTE_LEN] {
prefixed_info_hash(REJECTED_METADATA_PREFIX, info_hash)
}
@@ -120,6 +126,10 @@ pub(crate) fn content_members_prefix(content_key: &[u8; 32]) -> [u8; 1 + 32] {
key
}
pub(crate) fn content_member_prefix() -> [u8; 1] {
[CONTENT_PREFIX]
}
pub(crate) fn decode_content_member_info_hash(
key: &[u8],
content_key: &[u8; 32],
+9 -1
View File
@@ -1,10 +1,16 @@
// 负责定义元数据去重状态恢复和索引任务所需的存储接口
use crate::domain::{ContentGroup, InfoHash, RejectedMetadata, TorrentRecord, VerificationResult};
use crate::domain::{
ContentGroup, InfoHash, RejectedMetadata, TorrentRecord, TorrentRecordError, VerificationResult,
};
pub trait TorrentRepository: Send + Sync {
fn get(&self, info_hash: InfoHash) -> Result<Option<TorrentRecord>, StorageError>;
fn get_visible(&self, info_hash: InfoHash) -> Result<Option<TorrentRecord>, StorageError> {
self.get(info_hash)
}
fn contains(&self, info_hash: InfoHash) -> Result<bool, StorageError> {
self.get(info_hash).map(|record| record.is_some())
}
@@ -119,6 +125,8 @@ pub enum StorageError {
Encode(#[from] rmp_serde::encode::Error),
#[error("记录解码失败: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("无法计算过滤后的内容结构: {0}")]
DerivedContent(#[from] TorrentRecordError),
#[error("数据库格式与当前程序不兼容 请清理开发数据目录后重新启动")]
IncompatibleDatabaseFormat,
#[error("待索引内容组不存在 content_key={0}")]
+383 -22
View File
@@ -1,6 +1,10 @@
// 负责实现 RocksDB 打开配置批量写入精确查询和关闭流程
use std::{path::Path, sync::Mutex};
use std::{
collections::BTreeMap,
path::Path,
sync::{Arc, Mutex},
};
use rocksdb::{
BlockBasedOptions, Cache, DB, DBCompressionType, Direction, IteratorMode, Options,
@@ -8,18 +12,19 @@ use rocksdb::{
};
use crate::domain::{
ContentGroupBuilder, InfoHash, MetadataLimits, RejectedMetadata, TorrentRecord,
ContentFilter, 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,
CONTENT_FILTER_FINGERPRINT_KEY, CONTENT_FILTER_MIGRATION_KEY, DATABASE_FORMAT_KEY,
DATABASE_FORMAT_VALUE, VERIFICATION_QUEUE_COUNT_KEY, content_group_key,
content_group_prefix, content_member_key, content_member_prefix, content_members_prefix,
decode_content_member_info_hash, decode_pending_content_key, decode_verification_lease,
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,
torrent_key, torrent_prefix, verification_lease_key, verification_lease_prefix,
verification_locator_key, verification_task_key, verification_task_prefix,
},
repository::{
ContentGroupTask, ContentVariants, StorageError, TorrentRepository, UpsertOutcome,
@@ -40,16 +45,30 @@ pub struct RocksTorrentRepository {
db: DB,
write_lock: Mutex<()>,
rejection_rule_id: [u8; 32],
content_filter: Arc<ContentFilter>,
content_filter_changed: bool,
}
impl RocksTorrentRepository {
pub fn open(path: impl AsRef<Path>) -> Result<Self, StorageError> {
Self::open_with_rejection_rule(path, MetadataLimits::default().rule_id())
Self::open_with_rules(
path,
MetadataLimits::default().rule_id(),
Arc::new(ContentFilter::default()),
)
}
pub fn open_with_rejection_rule(
path: impl AsRef<Path>,
rejection_rule_id: [u8; 32],
) -> Result<Self, StorageError> {
Self::open_with_rules(path, rejection_rule_id, Arc::new(ContentFilter::default()))
}
pub fn open_with_rules(
path: impl AsRef<Path>,
rejection_rule_id: [u8; 32],
content_filter: Arc<ContentFilter>,
) -> Result<Self, StorageError> {
let mut block_options = BlockBasedOptions::default();
block_options.set_bloom_filter(10.0, false);
@@ -63,15 +82,22 @@ impl RocksTorrentRepository {
options.set_prefix_extractor(SliceTransform::create_fixed_prefix(1));
options.set_max_open_files(256);
let repository = Self {
let mut repository = Self {
db: DB::open(&options, path)?,
write_lock: Mutex::new(()),
rejection_rule_id,
content_filter,
content_filter_changed: false,
};
repository.initialize_format()?;
repository.content_filter_changed = repository.synchronize_content_filter()?;
Ok(repository)
}
pub fn content_filter_changed(&self) -> bool {
self.content_filter_changed
}
fn initialize_format(&self) -> Result<(), StorageError> {
match self.db.get(DATABASE_FORMAT_KEY)? {
None => self
@@ -83,6 +109,159 @@ impl RocksTorrentRepository {
}
}
fn synchronize_content_filter(&self) -> Result<bool, StorageError> {
const MIGRATION_BATCH_SIZE: usize = 1_000;
let fingerprint = self.content_filter.fingerprint();
if self.db.get(CONTENT_FILTER_MIGRATION_KEY)?.is_none()
&& self
.db
.get(CONTENT_FILTER_FINGERPRINT_KEY)?
.is_some_and(|stored| stored.as_slice() == fingerprint)
{
return Ok(false);
}
let _guard = self
.write_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.db.put(CONTENT_FILTER_MIGRATION_KEY, fingerprint)?;
self.clear_prefix(content_member_prefix(), MIGRATION_BATCH_SIZE)?;
self.clear_prefix(content_group_prefix(), MIGRATION_BATCH_SIZE)?;
self.clear_prefix(pending_index_prefix(), MIGRATION_BATCH_SIZE)?;
let prefix = torrent_prefix();
let iterator = self
.db
.iterator(IteratorMode::From(&prefix, Direction::Forward));
let mut batch = WriteBatch::default();
let mut groups = BTreeMap::<[u8; 32], u64>::new();
let mut batch_records = 0_usize;
let mut records = 0_u64;
let mut hidden_files = 0_u64;
let mut hidden_records = 0_u64;
for entry in iterator {
let (key, value) = entry?;
if !key.starts_with(&prefix) {
break;
}
let mut record = Self::decode(&value)?;
let old_content_key = record.content_key;
let old_searchable = record.searchable;
let outcome = self.content_filter.apply_derivatives(&mut record)?;
records = records.saturating_add(1);
hidden_files = hidden_files.saturating_add(outcome.hidden_files as u64);
if !outcome.searchable {
hidden_records = hidden_records.saturating_add(1);
}
if old_content_key != record.content_key || old_searchable != record.searchable {
batch.put(&key, Self::encode(&record)?);
}
if record.searchable {
batch.put(
content_member_key(&record.content_key, record.info_hash),
[],
);
*groups.entry(record.content_key).or_default() += 1;
}
batch_records += 1;
if batch_records == MIGRATION_BATCH_SIZE {
self.write_filter_migration_batch(batch, &groups)?;
batch = WriteBatch::default();
groups.clear();
batch_records = 0;
}
}
if batch_records > 0 {
self.write_filter_migration_batch(batch, &groups)?;
}
self.restore_pending_markers(MIGRATION_BATCH_SIZE)?;
let mut finish = WriteBatch::default();
finish.put(CONTENT_FILTER_FINGERPRINT_KEY, fingerprint);
finish.delete(CONTENT_FILTER_MIGRATION_KEY);
self.db.write(finish)?;
tracing::info!(
records,
hidden_files,
hidden_records,
"内容过滤规则已更新并重建内容组"
);
Ok(true)
}
fn clear_prefix(&self, prefix: [u8; 1], limit: usize) -> Result<(), StorageError> {
loop {
let iterator = self
.db
.iterator(IteratorMode::From(&prefix, Direction::Forward));
let mut batch = WriteBatch::default();
let mut deleted = 0_usize;
for entry in iterator.take(limit) {
let (key, _) = entry?;
if !key.starts_with(&prefix) {
break;
}
batch.delete(key);
deleted += 1;
}
if deleted == 0 {
return Ok(());
}
self.db.write(batch)?;
}
}
fn write_filter_migration_batch(
&self,
mut batch: WriteBatch,
groups: &BTreeMap<[u8; 32], u64>,
) -> Result<(), StorageError> {
for (content_key, added) in groups {
let member_count = self
.group_state(content_key)?
.map_or(*added, |state| state.member_count.saturating_add(*added));
batch.put(
content_group_key(content_key),
Self::encode_group(ContentGroupState {
revision: 1,
member_count,
})?,
);
}
self.db.write(batch)?;
Ok(())
}
fn restore_pending_markers(&self, batch_size: usize) -> Result<(), StorageError> {
let prefix = content_group_prefix();
let iterator = self
.db
.iterator(IteratorMode::From(&prefix, Direction::Forward));
let mut batch = WriteBatch::default();
let mut batch_len = 0_usize;
for entry in iterator {
let (key, _) = entry?;
if !key.starts_with(&prefix) {
break;
}
let content_key: [u8; 32] = key[1..]
.try_into()
.map_err(|_| StorageError::CorruptContentGroup)?;
batch.put(pending_index_key(&content_key), 1_u64.to_be_bytes());
batch_len += 1;
if batch_len == batch_size {
self.db.write(batch)?;
batch = WriteBatch::default();
batch_len = 0;
}
}
if batch_len > 0 {
self.db.write(batch)?;
}
Ok(())
}
fn encode(record: &TorrentRecord) -> Result<Vec<u8>, StorageError> {
rmp_serde::to_vec_named(record).map_err(Into::into)
}
@@ -196,7 +375,14 @@ impl TorrentRepository for RocksTorrentRepository {
.transpose()
}
fn upsert(&self, observation: TorrentRecord) -> Result<UpsertOutcome, StorageError> {
fn get_visible(&self, info_hash: InfoHash) -> Result<Option<TorrentRecord>, StorageError> {
Ok(self
.get(info_hash)?
.and_then(|record| self.content_filter.public_record(&record)))
}
fn upsert(&self, mut observation: TorrentRecord) -> Result<UpsertOutcome, StorageError> {
self.content_filter.apply_derivatives(&mut observation)?;
let _guard = self
.write_lock
.lock()
@@ -205,7 +391,9 @@ impl TorrentRepository for RocksTorrentRepository {
current.observe_again(observation.last_seen, &observation.source_peers);
let mut batch = WriteBatch::default();
batch.put(torrent_key(current.info_hash), Self::encode(&current)?);
self.dirty_group(&mut batch, &current.content_key, false)?;
if current.searchable {
self.dirty_group(&mut batch, &current.content_key, false)?;
}
self.db.write(batch)?;
return Ok(UpsertOutcome::Updated {
seen_count: current.seen_count,
@@ -218,11 +406,13 @@ impl TorrentRepository for RocksTorrentRepository {
Self::encode(&observation)?,
);
batch.delete(rejected_metadata_key(observation.info_hash));
batch.put(
content_member_key(&observation.content_key, observation.info_hash),
[],
);
self.dirty_group(&mut batch, &observation.content_key, true)?;
if observation.searchable {
batch.put(
content_member_key(&observation.content_key, observation.info_hash),
[],
);
self.dirty_group(&mut batch, &observation.content_key, true)?;
}
self.db.write(batch)?;
Ok(UpsertOutcome::Inserted)
}
@@ -281,7 +471,9 @@ impl TorrentRepository for RocksTorrentRepository {
record.observe_again(observed_at, &[]);
let mut batch = WriteBatch::default();
batch.put(torrent_key(info_hash), Self::encode(&record)?);
self.dirty_group(&mut batch, &record.content_key, false)?;
if record.searchable {
self.dirty_group(&mut batch, &record.content_key, false)?;
}
self.db.write(batch)?;
Ok(true)
}
@@ -321,7 +513,9 @@ impl TorrentRepository for RocksTorrentRepository {
let mut record = Self::decode(&bytes)?;
record.observe_again(observed_at, &[]);
batch.put(key, Self::encode(&record)?);
dirty_groups.insert(record.content_key);
if record.searchable {
dirty_groups.insert(record.content_key);
}
updated += 1;
continue;
}
@@ -391,7 +585,10 @@ impl TorrentRepository for RocksTorrentRepository {
let Some(info_hash) = decode_content_member_info_hash(&key, content_key) else {
break;
};
if let Some(record) = self.get(info_hash)? {
if let Some(record) = self
.get(info_hash)?
.and_then(|record| self.content_filter.public_record(&record))
{
builder.push(record);
found = true;
}
@@ -474,7 +671,10 @@ impl TorrentRepository for RocksTorrentRepository {
let hashes = self.content_member_hashes(content_key, offset.saturating_add(limit))?;
let mut records = Vec::with_capacity(limit);
for info_hash in hashes.into_iter().skip(offset).take(limit) {
if let Some(record) = self.get(info_hash)? {
if let Some(record) = self
.get(info_hash)?
.and_then(|record| self.content_filter.public_record(&record))
{
records.push(record);
}
}
@@ -632,7 +832,9 @@ impl TorrentRepository for RocksTorrentRepository {
let count = self.verification_queue_len_inner()?.saturating_sub(1);
let mut batch = WriteBatch::default();
batch.put(torrent_key(info_hash), Self::encode(&record)?);
self.dirty_group(&mut batch, &record.content_key, false)?;
if record.searchable {
self.dirty_group(&mut batch, &record.content_key, false)?;
}
if let Some(queued_key) = queued_key {
batch.delete(queued_key);
}
@@ -649,12 +851,36 @@ impl TorrentRepository for RocksTorrentRepository {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use tempfile::TempDir;
use crate::domain::{AvailabilityStatus, VerificationResult, test_record};
use crate::domain::{
AvailabilityStatus, ContentFilter, ContentFilterConfig, FileFilterRule, FileMatchField,
FileMatchKind, FileRuleAction, TorrentFile, VerificationResult, test_record,
};
use super::*;
fn padding_filter() -> Arc<ContentFilter> {
Arc::new(
ContentFilter::compile(ContentFilterConfig {
version: 1,
file_rules: vec![FileFilterRule {
id: "padding".into(),
enabled: true,
field: FileMatchField::FileName,
match_kind: FileMatchKind::Prefix,
value: "_____padding_file_".into(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "测试".into(),
}],
})
.unwrap(),
)
}
#[test]
fn record_survives_close_and_reopen() {
let directory = TempDir::new().unwrap();
@@ -670,6 +896,141 @@ mod tests {
assert_eq!(repository.get(expected.info_hash).unwrap(), Some(expected));
}
#[test]
fn filter_keeps_raw_files_but_returns_effective_view() {
let directory = TempDir::new().unwrap();
let repository = RocksTorrentRepository::open_with_rules(
directory.path(),
MetadataLimits::default().rule_id(),
padding_filter(),
)
.unwrap();
let mut record = test_record(1, 10);
record.files.push(TorrentFile {
path: "_____padding_file_1_请升级____".into(),
size: 100,
});
record.total_size += 100;
repository.upsert(record.clone()).unwrap();
let raw = repository.get(record.info_hash).unwrap().unwrap();
assert_eq!(raw.files.len(), 2);
assert_eq!(raw.total_size, 142);
let visible = repository.get_visible(record.info_hash).unwrap().unwrap();
assert_eq!(visible.files.len(), 1);
assert_eq!(visible.total_size, 42);
assert_eq!(repository.pending_index(10).unwrap().len(), 1);
}
#[test]
fn changed_filter_rebuilds_groups_without_deleting_raw_metadata() {
let directory = TempDir::new().unwrap();
let mut record = test_record(2, 10);
record.files.push(TorrentFile {
path: "_____padding_file_1_".into(),
size: 100,
});
record.total_size += 100;
let old_content_key;
{
let repository = RocksTorrentRepository::open(directory.path()).unwrap();
repository.upsert(record.clone()).unwrap();
old_content_key = repository
.get(record.info_hash)
.unwrap()
.unwrap()
.content_key;
}
let repository = RocksTorrentRepository::open_with_rules(
directory.path(),
MetadataLimits::default().rule_id(),
padding_filter(),
)
.unwrap();
assert!(repository.content_filter_changed());
let raw = repository.get(record.info_hash).unwrap().unwrap();
assert_eq!(raw.files.len(), 2);
assert_ne!(raw.content_key, old_content_key);
assert_eq!(
repository
.content_variants(&old_content_key, 0, 10)
.unwrap()
.total,
0
);
assert_eq!(
repository
.content_variants(&raw.content_key, 0, 10)
.unwrap()
.total,
1
);
drop(repository);
let rolled_back = RocksTorrentRepository::open(directory.path()).unwrap();
assert!(rolled_back.content_filter_changed());
let restored = rolled_back.get_visible(record.info_hash).unwrap().unwrap();
assert_eq!(restored.files.len(), 2);
assert_eq!(restored.total_size, 142);
assert_eq!(restored.content_key, old_content_key);
}
#[test]
fn unfinished_filter_migration_is_rebuilt_even_when_fingerprint_matches() {
let directory = TempDir::new().unwrap();
let record = test_record(4, 10);
{
let repository = RocksTorrentRepository::open(directory.path()).unwrap();
repository.upsert(record.clone()).unwrap();
repository
.db
.put(CONTENT_FILTER_MIGRATION_KEY, b"interrupted")
.unwrap();
repository
.db
.delete(content_member_key(&record.content_key, record.info_hash))
.unwrap();
}
let recovered = RocksTorrentRepository::open(directory.path()).unwrap();
assert!(recovered.content_filter_changed());
assert_eq!(
recovered
.content_variants(&record.content_key, 0, 10)
.unwrap()
.total,
1
);
assert!(recovered.pending_index(10).unwrap().len() == 1);
assert!(
recovered
.db
.get(CONTENT_FILTER_MIGRATION_KEY)
.unwrap()
.is_none()
);
}
#[test]
fn torrent_with_only_hidden_files_is_kept_but_not_indexed() {
let directory = TempDir::new().unwrap();
let repository = RocksTorrentRepository::open_with_rules(
directory.path(),
MetadataLimits::default().rule_id(),
padding_filter(),
)
.unwrap();
let mut record = test_record(3, 10);
record.files[0].path = "_____padding_file_1_".into();
repository.upsert(record.clone()).unwrap();
let raw = repository.get(record.info_hash).unwrap().unwrap();
assert!(!raw.searchable);
assert!(repository.get_visible(record.info_hash).unwrap().is_none());
assert!(repository.pending_index(10).unwrap().is_empty());
}
#[test]
fn incompatible_database_format_is_rejected() {
let directory = TempDir::new().unwrap();
@@ -902,7 +1263,7 @@ mod tests {
let repository = RocksTorrentRepository::open(directory.path()).unwrap();
let first = test_record(7, 10);
let mut second = test_record(8, 10);
second.content_key = [8; 32];
second.files[0].path = "different.bin".into();
repository.upsert(first.clone()).unwrap();
repository.upsert(second.clone()).unwrap();
for task in repository.pending_index(10).unwrap() {
+62 -2
View File
@@ -1,13 +1,17 @@
// 负责从 crate 外部验证持久化索引搜索和重启恢复的公开组合契约
// 负责从 crate 外部验证持久化索引过滤搜索和重启恢复的公开组合契约
#![cfg(feature = "rocksdb-storage")]
use dht_crawler::{FileInfo, TorrentInfo};
use dht_search::{
domain::TorrentRecord,
domain::{
ContentFilter, ContentFilterConfig, FileFilterRule, FileMatchField, FileMatchKind,
FileRuleAction, TorrentRecord,
},
search::SearchEngine,
storage::{RocksTorrentRepository, TorrentRepository, UpsertOutcome},
};
use std::sync::Arc;
use tempfile::TempDir;
#[test]
@@ -46,3 +50,59 @@ fn public_components_compose_into_a_restart_safe_search_flow() {
let reopened = RocksTorrentRepository::open(&rocksdb).unwrap();
assert_eq!(reopened.get(record.info_hash).unwrap(), Some(record));
}
#[test]
fn content_filter_excludes_padding_from_search_and_visible_details() {
let directory = TempDir::new().unwrap();
let filter = Arc::new(
ContentFilter::compile(ContentFilterConfig {
version: 1,
file_rules: vec![FileFilterRule {
id: "bitcomet-padding".into(),
enabled: true,
field: FileMatchField::FileName,
match_kind: FileMatchKind::Prefix,
value: "_____padding_file_".into(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "测试".into(),
}],
})
.unwrap(),
);
let repository = RocksTorrentRepository::open_with_rules(
directory.path().join("rocksdb"),
dht_search::domain::MetadataLimits::default().rule_id(),
filter,
)
.unwrap();
let record = TorrentRecord::try_from(TorrentInfo {
info_hash: "3434343434343434343434343434343434343434".into(),
magnet_link: String::new(),
name: "Filtered Movie".into(),
total_size: 142,
files: vec![
FileInfo {
path: "movie.mkv".into(),
size: 42,
},
FileInfo {
path: "_____padding_file_1_请升级____".into(),
size: 100,
},
],
piece_length: 16_384,
peers: Vec::new(),
timestamp: 100,
})
.unwrap();
repository.upsert(record.clone()).unwrap();
let search = SearchEngine::open(directory.path().join("tantivy")).unwrap();
assert_eq!(search.index_pending(&repository, 100, 100).unwrap(), 1);
assert_eq!(search.search("padding_file", 0, 10).unwrap().total, 0);
assert_eq!(search.search("*.mkv", 0, 10).unwrap().total, 1);
let visible = repository.get_visible(record.info_hash).unwrap().unwrap();
assert_eq!(visible.files.len(), 1);
assert_eq!(visible.total_size, 42);
}