- _is_safe_channel_segment: отвергает имя канала с '/'/'\\'/'..' или не-basename;
вызывается в начале цикла миграции ДО любого os.rename/_merge_dir_tree/rmtree,
покрывает оба источника кандидатов (SELECT DISTINCT channel + листинг каталогов).
Грязный канал (напр. из pre-existing route-traversal) -> skip+warning+failures++,
старт не падает, деструктивная rename/rmtree не уходит за cache_dir.
- Тесты (мутационно проверены): FS-fail -> SQL пропущен, строки old-cased,
failures++ (снять continue -> тест краснеет); reverse max(added) (Durov=100/
durov=300 -> 300); traversal-гард скипает '../Evil'/'a/B' без FS-операций.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Один канал жил под ключами Durov/durov/@name в трёх слоях (tgcache-файлы,
data/cache/ каталоги, SQLite-строки) -> дубли кеша, двойные походы в Telegram,
осиротевшие деревья. ID<->username НЕ унифицируем (сознательно).
- channel_key.py: canonical_channel_key (strip @, -100... числовые verbatim,
иначе lowercase; безопасно и для API-вызовов).
- tg_cache._cache_file_path прогоняет ключ через canonical.
- post_parser.get_channel_username -> lowercase (обе ветки; канонизирует
SQLite/media-URL/t.me). Только эти 2 строки.
- api_server.get_media: fs_channel = canonical_channel_key(channel) ПОСЛЕ проверки
дайджеста (дайджест — по ИСХОДНОМУ url, иначе ломаются выданные ссылки); fs_channel
во всех путях/ключах/download ниже.
- migrate_channel_keys_sync в lifespan (после init_db_sync, до client.start, через
asyncio.to_thread): канал целиком FS-потом-SQL. Обязательный os.path.samefile
guard ДО merge/rmtree — на case-insensitive FS Durov/durov один inode, иначе
снесли бы кеш. FS-сбой -> SQL пропуск (строки старорегистровые, дерево видно
свиперу, нет вечных orphan). SQL-merge PK-safe (twin -> merge added=max/mime
non-NULL + DELETE, иначе plain UPDATE). Идемпотентно.
closes#24
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>