diff --git a/CLAUDE.md b/CLAUDE.md index 29e5b7d..d2bb5d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` に追加 diff --git a/Cargo.lock b/Cargo.lock index c25c80c..9d6c254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "notecli" -version = "0.8.0" +version = "0.8.1" dependencies = [ "android-native-keyring-store", "apple-native-keyring-store", diff --git a/Cargo.toml b/Cargo.toml index 6460b4b..c6f1fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 14b852a..15a3dbe 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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(()) } } diff --git a/src/db.rs b/src/db.rs index a5373c8..101a115 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1049,9 +1049,11 @@ 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() @@ -1059,6 +1061,7 @@ impl Database { .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", @@ -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( @@ -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 { + fn trim_timelines_chunked(&self, per_timeline_limit: i64) -> Result { 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 ( @@ -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 は残る diff --git a/src/http_server.rs b/src/http_server.rs index c91455f..d3c349a 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -145,15 +145,15 @@ impl From 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(), } } } diff --git a/src/models.rs b/src/models.rs index 87e7ffe..18babf7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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 { @@ -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}'" + ))), } } } @@ -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 内の ':' は保持する