Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ git push origin vX.Y.Z

## コーディング規約

- エラー型は `error.rs` の `NotecliError` に統一
- エラー型は `error.rs` の `NoteDeckError` に統一(歴史的な名前。notedeck / notetui / notebot も同名で参照している)
- API トークンをログ・エラーメッセージに含めない(`safe_message()` を使用)
- 認証情報は keychain 優先、DB フォールバック
- 新しい Misskey API エンドポイントは `api.rs` の `MisskeyClient` に追加
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "notecli"
version = "0.8.0"
version = "0.8.1"
edition = "2021"
description = "Headless Misskey client — CLI & library"
repository = "https://github.com/notedeck-dev/notecli"
Expand Down
7 changes: 6 additions & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ pub async fn run_cli(
return match cache_cmd {
crate::cli::CacheCommands::Sweep => {
let deleted = db.sweep_orphan_notes()?;
println!("Removed {deleted} orphan note(s) from cache");
crate::format::print_action(
fmt,
&format!(r#"{{"removed":{deleted}}}"#),
&deleted.to_string(),
&format!("Removed {deleted} orphan note(s) from cache"),
);
Ok(())
}
}
Expand Down
63 changes: 55 additions & 8 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,16 +1049,19 @@ impl Database {
return Ok(0);
}

let conn = self.lock_write()?;
let mut total_deleted: u64 = 0;

// writer lock はフェーズ単位で取り直す。②のチャンク分割は tx だけでなく
// Mutex も手放さないと意味がない (握ったままだと ingest_notes 等の
// lock_write 呼び出しがトリム完走まで待たされる)。
// ① TTL (単一 tx)
if let Some(ttl_days) = config.ttl_days {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let ttl_cutoff = now - ttl_days * 86_400;
let conn = self.lock_write()?;
let tx = conn.unchecked_transaction()?;
let n = tx.execute(
"DELETE FROM notes_cache WHERE cached_at < ?1",
Expand All @@ -1068,13 +1071,14 @@ impl Database {
total_deleted += n as u64;
}

// ② per-timeline トリム (チャンク分割 tx)
// ② per-timeline トリム (チャンクごとに lock + tx)
if let Some(per_timeline_limit) = config.per_timeline_limit {
total_deleted += self.trim_timelines_chunked(&conn, per_timeline_limit)?;
total_deleted += self.trim_timelines_chunked(per_timeline_limit)?;
}

// ③ per-account hard cap (単一 tx)
if let Some(per_account_limit) = config.per_account_limit {
let conn = self.lock_write()?;
let tx = conn.unchecked_transaction()?;
// SQLite 3.25+ の window function で 1 クエリで評価。
let n = tx.execute(
Expand Down Expand Up @@ -1102,13 +1106,13 @@ impl Database {
/// per-timeline トリムの実体。victim (バケット上限超過の membership) を
/// チャンクごとの tx で削除し、victim のうち所属ゼロになった entity を
/// 同一 tx で掃除する。戻り値は削除した membership + entity の総行数。
fn trim_timelines_chunked(
&self,
conn: &Connection,
per_timeline_limit: i64,
) -> Result<u64, NoteDeckError> {
fn trim_timelines_chunked(&self, per_timeline_limit: i64) -> Result<u64, NoteDeckError> {
let mut total: u64 = 0;
loop {
// チャンクごとに writer lock を取り直す。ここで Mutex を手放すことで
// WS ingest や他コマンドの書込がトリムの合間に割り込める
// (握りっぱなしだと 1M 行規模で分オーダーの停止になる)。
let conn = self.lock_write()?;
let tx = conn.unchecked_transaction()?;
tx.execute_batch(
"CREATE TEMP TABLE IF NOT EXISTS trim_victims (
Expand Down Expand Up @@ -3347,6 +3351,49 @@ mod tests {
assert_eq!(db.account_cache_count("acc-1").unwrap(), 2);
}

#[test]
fn per_timeline_trim_releases_writer_lock_between_chunks() {
// チャンク分割の目的は writer Mutex を手放して他の書込を割り込ませること。
// lock を握りっぱなしだと (tx だけ分割しても) この ingest は完走まで待たされる。
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Database::open(&dir.path().join("test.db")).unwrap());
for i in 0..200 {
db.ingest_notes(
&[note_with_created_at(
&format!("n{i:03}"),
"acc-1",
&format!("2025-06-01T00:00:{:02}Z", i % 60),
)],
&tk("home"),
)
.unwrap();
}

let trimmer = Arc::clone(&db);
let handle = std::thread::spawn(move || {
trimmer
.cleanup_with_eviction(&EvictionConfig {
per_account_limit: None,
ttl_days: None,
per_timeline_limit: Some(1),
})
.unwrap()
});

// トリム中でも新規 ingest が完了できる (デッドロック・恒久ブロックしない)
db.ingest_notes(&[note_for_account("concurrent", "acc-1")], &tk("social"))
.unwrap();
let deleted = handle.join().unwrap();
assert!(deleted > 0);
assert_eq!(
db.get_cached_timeline("acc-1", &tk("social"), 10)
.unwrap()
.len(),
1
);
}

#[test]
fn per_timeline_trim_keeps_shared_entities() {
// §9-5: トリムで membership を失っても他バケット所属の entity は残る
Expand Down
6 changes: 3 additions & 3 deletions src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ impl From<crate::error::NoteDeckError> for ApiError {
let code = e.code().to_string();
let status = match &e {
// クライアント入力起因 (不正なタイムラインキー等) は 400。
// InvalidInput の Display はキー文字列等の入力のみでトークンを含まない
// ため e.to_string() のままでよい。
crate::error::NoteDeckError::InvalidInput(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
Self {
status,
code,
message: e.to_string(),
// 外部へ出すメッセージは必ず safe_message() を通す。Display は
// Internal / Database の内部詳細をそのまま含み得る。
message: e.safe_message(),
}
}
}
Expand Down
46 changes: 45 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,18 @@ pub enum TimelineKey {
/// bare 単独で現れたら parse エラーになる prefix 予約語
const RESERVED_PREFIXES: [&str; 6] = ["user-list", "antenna", "channel", "role", "clip", "user"];

/// `Basic` タイムライン名として許す形(lowercase ASCII 英数 + `-`)か。
///
/// 名前は API パス (`notes/{t}-timeline`) と WS チャンネル名 (`{t}Timeline`) へ
/// 直接補間されるため、パス区切りやクエリ文字を含む名前を通すとリクエスト先を
/// 差し替えられてしまう。既知のフォーク TL (bubble / vmimi-relay / hanami 等) は
/// すべてこの形に収まる。
fn is_valid_basic_name(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}

/// kebab-case を lowerCamelCase に変換("vmimi-relay" → "vmimiRelay")。
/// Misskey の WS チャンネル名は lowerCamel、endpoint は kebab が慣行。
fn kebab_to_lower_camel(s: &str) -> String {
Expand Down Expand Up @@ -553,7 +565,15 @@ impl TimelineKey {
_ if RESERVED_PREFIXES.contains(&s) => Err(NoteDeckError::InvalidInput(format!(
"bare reserved timeline key '{s}' (id required)"
))),
_ => Ok(Self::Basic(s.to_string())),
// Basic 名は `api_endpoint` が `notes/{t}-timeline` として API パスへ
// 補間し、`ws_channel` が `{t}Timeline` としてチャンネル名にする。
// `/` `.` `?` `#` 等を許すとリクエスト先そのものを差し替えられる
// (例: `../../admin/x?` → `/api/admin/x`) ため、Misskey の TL 命名
// 慣行どおり lowercase kebab に限定する。
_ if is_valid_basic_name(s) => Ok(Self::Basic(s.to_string())),
_ => Err(NoteDeckError::InvalidInput(format!(
"invalid basic timeline key '{s}'"
))),
}
}
}
Expand Down Expand Up @@ -2049,6 +2069,30 @@ mod tests {
}
}

#[test]
fn timeline_key_rejects_path_unsafe_basic_names() {
// Basic 名は API パス (notes/{t}-timeline) へ補間されるため、リクエスト先を
// 差し替え得る文字を含む名前は parse で弾く
for s in [
"../../admin/x?",
"notes/../admin",
"home/x",
"home?x",
"home#x",
"home.x",
"home%2f",
"home x",
"Home", // 大文字は endpoint scan 由来の実キーに現れない
"ホーム", // 非 ASCII
] {
assert!(TimelineKey::parse(s).is_err(), "expected Err for {s:?}");
}
// 既知のフォーク TL は従来どおり通ること
for s in ["home", "bubble", "vmimi-relay", "hanami", "yami2"] {
assert!(TimelineKey::parse(s).is_ok(), "expected Ok for {s:?}");
}
}

#[test]
fn timeline_key_splitn_keeps_colon_in_id() {
// 最初の ':' で分割し、id 内の ':' は保持する
Expand Down
Loading