shoko 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +4 -0
  3. data/.bundle/config.bak +3 -0
  4. data/.rspec_status +42 -0
  5. data/.rubocop.yml +124 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE +21 -0
  8. data/README.md +82 -0
  9. data/Rakefile +29 -0
  10. data/bin/start +15 -0
  11. data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
  12. data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
  13. data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
  14. data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
  15. data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
  16. data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
  17. data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
  18. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
  19. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
  20. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
  21. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
  22. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
  23. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
  24. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
  25. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
  26. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
  27. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
  28. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
  29. data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
  30. data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
  31. data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
  32. data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
  33. data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
  34. data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
  35. data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
  36. data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
  37. data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
  38. data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
  39. data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
  40. data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
  41. data/lib/shoko/adapters/input/command_bridge.rb +148 -0
  42. data/lib/shoko/adapters/input/command_factory.rb +255 -0
  43. data/lib/shoko/adapters/input/commands.rb +60 -0
  44. data/lib/shoko/adapters/input/dispatcher.rb +69 -0
  45. data/lib/shoko/adapters/input/input_controller.rb +250 -0
  46. data/lib/shoko/adapters/input/key_definitions.rb +108 -0
  47. data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
  48. data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
  49. data/lib/shoko/adapters/monitoring/logger.rb +150 -0
  50. data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
  51. data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
  52. data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
  53. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
  54. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
  55. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
  56. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
  57. data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
  58. data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
  59. data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
  60. data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
  61. data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
  62. data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
  63. data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
  64. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
  65. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
  66. data/lib/shoko/adapters/output/notification_service.rb +58 -0
  67. data/lib/shoko/adapters/output/render_registry.rb +45 -0
  68. data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
  69. data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
  70. data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
  71. data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
  72. data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
  73. data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
  74. data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
  75. data/lib/shoko/adapters/output/terminal/input.rb +161 -0
  76. data/lib/shoko/adapters/output/terminal/output.rb +105 -0
  77. data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
  78. data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
  79. data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
  80. data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
  81. data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
  82. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
  83. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
  84. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
  85. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
  86. data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
  87. data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
  88. data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
  89. data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
  90. data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
  91. data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
  92. data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
  93. data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
  94. data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
  95. data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
  96. data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
  97. data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
  98. data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
  99. data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
  100. data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
  101. data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
  102. data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
  103. data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
  104. data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
  105. data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
  106. data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
  107. data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
  108. data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
  109. data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
  110. data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
  111. data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
  112. data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
  113. data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
  114. data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
  115. data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
  116. data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
  117. data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
  118. data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
  119. data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
  120. data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
  121. data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
  122. data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
  123. data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
  124. data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
  125. data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
  126. data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
  127. data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
  128. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
  129. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
  130. data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
  131. data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
  132. data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
  133. data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
  134. data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
  135. data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
  136. data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
  137. data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
  138. data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
  139. data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
  140. data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
  141. data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
  142. data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
  143. data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
  144. data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
  145. data/lib/shoko/adapters/storage/background_worker.rb +66 -0
  146. data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
  147. data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
  148. data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
  149. data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
  150. data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
  151. data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
  152. data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
  153. data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
  154. data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
  155. data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
  156. data/lib/shoko/adapters/storage/config_paths.rb +30 -0
  157. data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
  158. data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
  159. data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
  160. data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
  161. data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
  162. data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
  163. data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
  164. data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
  165. data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
  166. data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
  167. data/lib/shoko/adapters/storage/recent_files.rb +78 -0
  168. data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
  169. data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
  170. data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
  171. data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
  172. data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
  173. data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
  174. data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
  175. data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
  176. data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
  177. data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
  178. data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
  179. data/lib/shoko/application/cli.rb +134 -0
  180. data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
  181. data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
  182. data/lib/shoko/application/controllers/menu_controller.rb +469 -0
  183. data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
  184. data/lib/shoko/application/controllers/reader_controller.rb +449 -0
  185. data/lib/shoko/application/controllers/state_controller.rb +410 -0
  186. data/lib/shoko/application/controllers/ui_controller.rb +782 -0
  187. data/lib/shoko/application/dependency_container.rb +301 -0
  188. data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
  189. data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
  190. data/lib/shoko/application/infrastructure/state_store.rb +413 -0
  191. data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
  192. data/lib/shoko/application/pending_jump_handler.rb +122 -0
  193. data/lib/shoko/application/reader_lifecycle.rb +65 -0
  194. data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
  195. data/lib/shoko/application/selectors/config_selectors.rb +62 -0
  196. data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
  197. data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
  198. data/lib/shoko/application/state/actions/base_action.rb +24 -0
  199. data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
  200. data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
  201. data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
  202. data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
  203. data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
  204. data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
  205. data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
  206. data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
  207. data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
  208. data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
  209. data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
  210. data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
  211. data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
  212. data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
  213. data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
  214. data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
  215. data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
  216. data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
  217. data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
  218. data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
  219. data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
  220. data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
  221. data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
  222. data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
  223. data/lib/shoko/application/unified_application.rb +48 -0
  224. data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
  225. data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
  226. data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
  227. data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
  228. data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
  229. data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
  230. data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
  231. data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
  232. data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
  233. data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
  234. data/lib/shoko/application/use_cases/settings_service.rb +123 -0
  235. data/lib/shoko/core/events/annotation_events.rb +94 -0
  236. data/lib/shoko/core/events/base_domain_event.rb +169 -0
  237. data/lib/shoko/core/events/bookmark_events.rb +41 -0
  238. data/lib/shoko/core/events/domain_event_bus.rb +163 -0
  239. data/lib/shoko/core/events/progress_events.rb +108 -0
  240. data/lib/shoko/core/models/bookmark.rb +36 -0
  241. data/lib/shoko/core/models/bookmark_data.rb +10 -0
  242. data/lib/shoko/core/models/chapter.rb +25 -0
  243. data/lib/shoko/core/models/content_block.rb +44 -0
  244. data/lib/shoko/core/models/reader_settings.rb +20 -0
  245. data/lib/shoko/core/models/selection_anchor.rb +73 -0
  246. data/lib/shoko/core/models/toc_entry.rb +14 -0
  247. data/lib/shoko/core/ports/annotation_repository.rb +0 -0
  248. data/lib/shoko/core/ports/book_repository.rb +0 -0
  249. data/lib/shoko/core/ports/book_source.rb +0 -0
  250. data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
  251. data/lib/shoko/core/ports/cache.rb +0 -0
  252. data/lib/shoko/core/ports/input_handler.rb +0 -0
  253. data/lib/shoko/core/ports/renderer.rb +0 -0
  254. data/lib/shoko/core/ports/storage.rb +0 -0
  255. data/lib/shoko/core/services/annotation_service.rb +102 -0
  256. data/lib/shoko/core/services/base_service.rb +60 -0
  257. data/lib/shoko/core/services/bookmark_service.rb +267 -0
  258. data/lib/shoko/core/services/coordinate_service.rb +265 -0
  259. data/lib/shoko/core/services/layout_service.rb +95 -0
  260. data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
  261. data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
  262. data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
  263. data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
  264. data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
  265. data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
  266. data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
  267. data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
  268. data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
  269. data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
  270. data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
  271. data/lib/shoko/core/services/navigation_service.rb +150 -0
  272. data/lib/shoko/core/services/page_calculator_service.rb +242 -0
  273. data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
  274. data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
  275. data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
  276. data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
  277. data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
  278. data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
  279. data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
  280. data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
  281. data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
  282. data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
  283. data/lib/shoko/core/services/pagination.rb +10 -0
  284. data/lib/shoko/core/services/progress_helper.rb +22 -0
  285. data/lib/shoko/core/services/selection_service.rb +126 -0
  286. data/lib/shoko/core/validator.rb +76 -0
  287. data/lib/shoko/shared/errors.rb +97 -0
  288. data/lib/shoko/shared/version.rb +5 -0
  289. data/lib/shoko/test_support/terminal_double.rb +175 -0
  290. data/lib/shoko/test_support/test_mode.rb +78 -0
  291. data/lib/shoko.rb +279 -0
  292. data/lib/zip.rb +732 -0
  293. data/zip.rb +5 -0
  294. metadata +370 -0
data/lib/zip.rb ADDED
@@ -0,0 +1,732 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal, read-only ZIP reader compatible with the subset of rubyzip API
4
+ # used by this project. Supports STORE (0) and DEFLATE (8) entries.
5
+ #
6
+ # Public API:
7
+ # Zip::File.open(path) { |zip| ... }
8
+ # zip.read(entry_path) -> String (binary)
9
+ # zip.find_entry(entry_path) -> entry or nil
10
+ # zip.close ; zip.closed?
11
+ # Zip::Error raised for malformed/unsupported archives or missing entries
12
+
13
+ require 'zlib'
14
+
15
+ # Namespace for ZIP file operations
16
+ module Zip
17
+ # Base error class for all ZIP-related errors
18
+ class Error < StandardError; end
19
+
20
+ # Metadata for a Central Directory entry.
21
+ Entry = Struct.new(
22
+ :name,
23
+ :compressed_size,
24
+ :uncompressed_size,
25
+ :compression_method,
26
+ :gp_flags,
27
+ :local_header_offset,
28
+ keyword_init: true
29
+ )
30
+
31
+ # ZIP file format signature constants
32
+ module Signatures
33
+ EOCD = [0x06054B50].pack('V').freeze # "PK\x05\x06"
34
+ CENTRAL_DIR = [0x02014B50].pack('V').freeze # "PK\x01\x02"
35
+ LOCAL_FILE = [0x04034B50].pack('V').freeze # "PK\x03\x04"
36
+ end
37
+
38
+ # ZIP file format size constants
39
+ module Sizes
40
+ MAX_EOCD_SCAN = 66_560 # 64 KiB comment + 2 KiB buffer
41
+ READ_CHUNK = 16 * 1024
42
+ end
43
+
44
+ # Default size limit constants
45
+ module Limits
46
+ MAX_ENTRY_COMPRESSED = 64 * 1024 * 1024
47
+ MAX_ENTRY_UNCOMPRESSED = 64 * 1024 * 1024
48
+ MAX_TOTAL_UNCOMPRESSED = 256 * 1024 * 1024
49
+ end
50
+
51
+ # Utilities for normalizing entry names
52
+ module NameNormalizer
53
+ module_function
54
+
55
+ def normalize(name)
56
+ string_value = ensure_string(name)
57
+ binary_string = string_value.force_encoding(Encoding::BINARY)
58
+ with_forward_slashes = binary_string.tr('\\', '/')
59
+ remove_leading_dot_slash(with_forward_slashes)
60
+ end
61
+
62
+ def ensure_string(name)
63
+ name.is_a?(String) ? name.dup : name.to_s
64
+ end
65
+
66
+ def remove_leading_dot_slash(path)
67
+ path.sub(%r{^\./}, '')
68
+ end
69
+ end
70
+
71
+ # Parser for Central Directory Fixed Header fields
72
+ class CentralDirectoryHeaderParser
73
+ FIELD_INDICES = {
74
+ gp_flags: 2,
75
+ compression_method: 3,
76
+ compressed_size: 7,
77
+ uncompressed_size: 8,
78
+ name_length: 9,
79
+ extra_length: 10,
80
+ comment_length: 11,
81
+ local_header_offset: 15,
82
+ }.freeze
83
+
84
+ def self.extract_named_fields(field_values)
85
+ FIELD_INDICES.transform_values { |index| field_values[index] }
86
+ end
87
+
88
+ def initialize(header_bytes)
89
+ @header_bytes = header_bytes
90
+ end
91
+
92
+ def parse
93
+ field_values = @header_bytes.unpack('v v v v v v V V V v v v v v V V')
94
+ self.class.extract_named_fields(field_values)
95
+ end
96
+ end
97
+
98
+ # Parser for End of Central Directory record
99
+ class EOCDParser
100
+ def self.parse(tail_data, eocd_index)
101
+ new(tail_data, eocd_index).parse
102
+ end
103
+
104
+ def self.extract_directory_info(eocd_record)
105
+ cd_size = eocd_record.byteslice(12, 4).unpack1('V')
106
+ cd_offset = eocd_record.byteslice(16, 4).unpack1('V')
107
+ [cd_offset, cd_size]
108
+ end
109
+
110
+ def initialize(tail_data, eocd_index)
111
+ @tail_data = tail_data
112
+ @eocd_index = eocd_index
113
+ end
114
+
115
+ def parse
116
+ eocd_record = extract_eocd_record
117
+ validate_eocd_record(eocd_record)
118
+ self.class.extract_directory_info(eocd_record)
119
+ end
120
+
121
+ private
122
+
123
+ def extract_eocd_record
124
+ @tail_data.byteslice(@eocd_index, 22)
125
+ end
126
+
127
+ def validate_eocd_record(eocd_record)
128
+ raise Error, 'truncated EOCD' unless eocd_record && eocd_record.bytesize == 22
129
+ end
130
+ end
131
+
132
+ # Factory for creating Entry objects from Central Directory data
133
+ class EntryFactory
134
+ def self.create_from_header(normalized_name, header_data)
135
+ Entry.new(
136
+ name: normalized_name,
137
+ compressed_size: header_data[:compressed_size],
138
+ uncompressed_size: header_data[:uncompressed_size],
139
+ compression_method: header_data[:compression_method],
140
+ gp_flags: header_data[:gp_flags],
141
+ local_header_offset: header_data[:local_header_offset]
142
+ )
143
+ end
144
+ end
145
+
146
+ # Extracts variable-length fields from Central Directory entry
147
+ class CentralDirectoryVariableFields
148
+ def initialize(io, header_data)
149
+ @io = io
150
+ @header_data = header_data
151
+ end
152
+
153
+ def read_and_skip
154
+ entry_name = read_entry_name
155
+ skip_extra_and_comment
156
+ entry_name
157
+ end
158
+
159
+ private
160
+
161
+ def read_entry_name
162
+ name_length = @header_data[:name_length]
163
+ raw_name = @io.read(name_length) || ''
164
+ NameNormalizer.normalize(raw_name)
165
+ end
166
+
167
+ def skip_extra_and_comment
168
+ extra_length = @header_data[:extra_length]
169
+ comment_length = @header_data[:comment_length]
170
+ total_skip = extra_length + comment_length
171
+ skip_bytes(total_skip)
172
+ end
173
+
174
+ def skip_bytes(byte_count)
175
+ return if byte_count.to_i <= 0
176
+
177
+ @io.seek(byte_count, ::IO::SEEK_CUR)
178
+ end
179
+ end
180
+
181
+ # Helpers for indexing entries via the Central Directory.
182
+ module IndexBuilder
183
+ private
184
+
185
+ def build_index!
186
+ cd_offset, cd_size = locate_central_directory
187
+ read_central_directory_entries(cd_offset, cd_size)
188
+ end
189
+
190
+ def read_central_directory_entries(cd_offset, cd_size)
191
+ @io.seek(cd_offset, ::IO::SEEK_SET)
192
+ stop_position = cd_offset + cd_size
193
+
194
+ while @io.pos < stop_position
195
+ entry = read_central_directory_entry
196
+ @entries[entry.name] = entry
197
+ end
198
+ end
199
+
200
+ def read_central_directory_entry
201
+ verify_signature(Signatures::CENTRAL_DIR, 'invalid central directory header signature')
202
+ fixed_header = read_exact(42, error_message: 'truncated central directory header')
203
+ build_entry_from_header(fixed_header)
204
+ end
205
+
206
+ def build_entry_from_header(fixed_header)
207
+ header_data = CentralDirectoryHeaderParser.new(fixed_header).parse
208
+ variable_fields = CentralDirectoryVariableFields.new(@io, header_data)
209
+ entry_name = variable_fields.read_and_skip
210
+ EntryFactory.create_from_header(entry_name, header_data)
211
+ end
212
+
213
+ def locate_central_directory
214
+ file_size = @io.stat.size
215
+ tail_data = read_file_tail(file_size)
216
+ eocd_index = find_eocd_signature(tail_data)
217
+ EOCDParser.parse(tail_data, eocd_index)
218
+ end
219
+
220
+ def read_file_tail(file_size)
221
+ scan_size = [file_size, Sizes::MAX_EOCD_SCAN].min
222
+ @io.seek(file_size - scan_size, ::IO::SEEK_SET)
223
+ tail_data = @io.read(scan_size)
224
+ raise Error, 'unable to read file tail' unless tail_data
225
+
226
+ tail_data
227
+ end
228
+
229
+ def find_eocd_signature(tail_data)
230
+ eocd_index = tail_data.rindex(Signatures::EOCD)
231
+ raise Error, 'end of central directory not found' unless eocd_index
232
+
233
+ eocd_index
234
+ end
235
+ end
236
+
237
+ # Context for entry validation operations
238
+ class ValidationContext
239
+ attr_reader :entry, :requested_name
240
+
241
+ def initialize(entry, requested_name)
242
+ @entry = entry
243
+ @requested_name = requested_name
244
+ end
245
+
246
+ def compressed_size
247
+ entry.compressed_size.to_i
248
+ end
249
+
250
+ def uncompressed_size
251
+ @uncompressed_size ||= entry.uncompressed_size.to_i
252
+ end
253
+
254
+ def uncompressed_size_positive?
255
+ uncompressed_size.positive?
256
+ end
257
+
258
+ def entry_name
259
+ entry.name
260
+ end
261
+
262
+ def exceeds_uncompressed_limit?(max_limit)
263
+ uncompressed_size_positive? && uncompressed_size > max_limit
264
+ end
265
+ end
266
+
267
+ # Manages size limits and validation for ZIP entries
268
+ class SizeLimits
269
+ attr_reader :max_entry_compressed, :max_entry_uncompressed, :max_total_uncompressed
270
+
271
+ def initialize(max_entry_uncompressed:, max_entry_compressed:, max_total_uncompressed:)
272
+ @max_entry_uncompressed = LimitResolver.resolve(
273
+ max_entry_uncompressed,
274
+ env: 'SHOKO_ZIP_MAX_ENTRY_BYTES',
275
+ default: Limits::MAX_ENTRY_UNCOMPRESSED
276
+ )
277
+ @max_entry_compressed = LimitResolver.resolve(
278
+ max_entry_compressed,
279
+ env: 'SHOKO_ZIP_MAX_ENTRY_COMPRESSED_BYTES',
280
+ default: Limits::MAX_ENTRY_COMPRESSED
281
+ )
282
+ @max_total_uncompressed = LimitResolver.resolve(
283
+ max_total_uncompressed,
284
+ env: 'SHOKO_ZIP_MAX_TOTAL_BYTES',
285
+ default: Limits::MAX_TOTAL_UNCOMPRESSED
286
+ )
287
+ @total_uncompressed_bytes = 0
288
+ end
289
+
290
+ def enforce_entry_limits(entry, requested_name:)
291
+ context = ValidationContext.new(entry, requested_name)
292
+ validate_compressed_size(context)
293
+ validate_uncompressed_size(context)
294
+ validate_total_budget(context)
295
+ end
296
+
297
+ def enforce_uncompressed_budget(entry, actual_bytes)
298
+ entry_name = entry.name
299
+ validate_entry_size(entry_name, actual_bytes)
300
+ validate_archive_budget(entry_name, actual_bytes)
301
+ end
302
+
303
+ def register_uncompressed_bytes(entry, byte_count)
304
+ enforce_uncompressed_budget(entry, byte_count)
305
+ increment_total(byte_count)
306
+ end
307
+
308
+ def current_total
309
+ @total_uncompressed_bytes
310
+ end
311
+
312
+ private
313
+
314
+ def increment_total(byte_count)
315
+ @total_uncompressed_bytes += byte_count
316
+ end
317
+
318
+ def validate_compressed_size(context)
319
+ return unless context.compressed_size > max_entry_compressed
320
+
321
+ raise Error, "entry too large (compressed): #{context.requested_name}"
322
+ end
323
+
324
+ def validate_uncompressed_size(context)
325
+ return unless context.exceeds_uncompressed_limit?(max_entry_uncompressed)
326
+
327
+ raise Error, "entry too large (uncompressed): #{context.requested_name}"
328
+ end
329
+
330
+ def validate_total_budget(context)
331
+ return unless context.uncompressed_size_positive?
332
+
333
+ uncompressed_size = context.uncompressed_size
334
+ new_total = current_total + uncompressed_size
335
+ return unless new_total > max_total_uncompressed
336
+
337
+ raise Error, "archive exceeds total uncompressed limit: #{context.requested_name}"
338
+ end
339
+
340
+ def validate_entry_size(entry_name, actual_bytes)
341
+ return unless actual_bytes > max_entry_uncompressed
342
+
343
+ raise Error, "entry too large after decompression: #{entry_name}"
344
+ end
345
+
346
+ def validate_archive_budget(entry_name, actual_bytes)
347
+ new_total = current_total + actual_bytes
348
+ return unless new_total > max_total_uncompressed
349
+
350
+ raise Error, "archive exceeds total uncompressed limit: #{entry_name}"
351
+ end
352
+ end
353
+
354
+ # Resolves limit values from arguments, environment, or defaults
355
+ class LimitResolver
356
+ def self.resolve(value, env:, default:)
357
+ new(value, env, default).resolve
358
+ end
359
+
360
+ def initialize(value, env, default)
361
+ @value = value
362
+ @env = env
363
+ @default = default
364
+ end
365
+
366
+ def resolve
367
+ candidate = value_or_env
368
+ parsed = parse_integer(candidate)
369
+ valid_positive_or_default(parsed)
370
+ end
371
+
372
+ private
373
+
374
+ def value_or_env
375
+ @value || ENV.fetch(@env, nil)
376
+ end
377
+
378
+ def parse_integer(candidate)
379
+ Integer(candidate)
380
+ rescue StandardError
381
+ nil
382
+ end
383
+
384
+ def valid_positive_or_default(parsed)
385
+ parsed&.positive? ? parsed : @default
386
+ end
387
+ end
388
+
389
+ # Tracks remaining bytes during chunk reading
390
+ class ByteCounter
391
+ def initialize(total_bytes)
392
+ @remaining = total_bytes
393
+ end
394
+
395
+ attr_reader :remaining
396
+
397
+ def remaining_positive?
398
+ @remaining.positive?
399
+ end
400
+
401
+ def consume(byte_count)
402
+ @remaining -= byte_count
403
+ end
404
+ end
405
+
406
+ # Tracks remaining bytes during chunk reading
407
+ class ChunkReader
408
+ def initialize(io, total_bytes)
409
+ @io = io
410
+ @counter = ByteCounter.new(total_bytes)
411
+ end
412
+
413
+ def read_all_chunks
414
+ chunks = []
415
+ while @counter.remaining_positive?
416
+ chunk = read_next_chunk
417
+ chunks << chunk
418
+ end
419
+ chunks
420
+ end
421
+
422
+ def process_chunks_with(inflater, output)
423
+ while @counter.remaining_positive?
424
+ chunk = read_next_chunk
425
+ output.append(inflater.inflate(chunk))
426
+ end
427
+ end
428
+
429
+ private
430
+
431
+ def read_next_chunk
432
+ chunk = read_chunk_from_io
433
+ validate_chunk(chunk)
434
+ @counter.consume(chunk.bytesize)
435
+ chunk
436
+ end
437
+
438
+ def read_chunk_from_io
439
+ chunk_size = calculate_chunk_size
440
+ @io.read(chunk_size)
441
+ end
442
+
443
+ def calculate_chunk_size
444
+ remaining = @counter.remaining
445
+ [remaining, Sizes::READ_CHUNK].min
446
+ end
447
+
448
+ def validate_chunk(chunk)
449
+ raise Error, 'truncated compressed data' unless chunk && !chunk.empty?
450
+ end
451
+ end
452
+
453
+ # Handles decompression of deflated ZIP entries
454
+ class EntryDecompressor
455
+ def self.create_inflater
456
+ ::Zlib::Inflate.new(-::Zlib::MAX_WBITS)
457
+ end
458
+
459
+ def initialize(io, limits)
460
+ @io = io
461
+ @limits = limits
462
+ end
463
+
464
+ def inflate_deflated_entry(entry)
465
+ remaining_bytes = entry.compressed_size.to_i
466
+ with_inflater { |inflater| decompress_all(inflater, entry, remaining_bytes) }
467
+ end
468
+
469
+ private
470
+
471
+ def with_inflater
472
+ inflater = self.class.create_inflater
473
+ yield inflater
474
+ rescue ::Zlib::DataError => e
475
+ raise Error, "invalid deflate data: #{e.message}"
476
+ ensure
477
+ close_inflater(inflater)
478
+ end
479
+
480
+ def close_inflater(inflater)
481
+ inflater&.close
482
+ rescue StandardError
483
+ nil
484
+ end
485
+
486
+ def decompress_all(inflater, entry, remaining_bytes)
487
+ output = DecompressionOutput.new(@limits, entry)
488
+ reader = ChunkReader.new(@io, remaining_bytes)
489
+ reader.process_chunks_with(inflater, output)
490
+ output.finalize(inflater)
491
+ end
492
+ end
493
+
494
+ # Accumulates decompressed data with budget enforcement
495
+ class DecompressionOutput
496
+ def initialize(limits, entry)
497
+ @limits = limits
498
+ @entry = entry
499
+ @data = +''
500
+ end
501
+
502
+ def append(chunk)
503
+ @data << chunk
504
+ @limits.enforce_uncompressed_budget(@entry, @data.bytesize)
505
+ end
506
+
507
+ def finalize(inflater)
508
+ @data << inflater.finish
509
+ @data
510
+ end
511
+ end
512
+
513
+ # Extracts variable-length fields from Local File Header
514
+ class LocalFileHeaderParser
515
+ LOCAL_HEADER_LENGTH_INDICES = [-2, -1].freeze
516
+
517
+ def self.extract_lengths(header_bytes)
518
+ field_values = header_bytes.unpack('v v v v v V V V v v')
519
+ [field_values[LOCAL_HEADER_LENGTH_INDICES[0]], field_values[LOCAL_HEADER_LENGTH_INDICES[1]]]
520
+ end
521
+ end
522
+
523
+ # Represents decompressed entry data with metadata
524
+ class DecompressedData
525
+ attr_reader :entry, :data
526
+
527
+ def initialize(entry, data)
528
+ @entry = entry
529
+ @data = data
530
+ end
531
+
532
+ def verify_size
533
+ expected_size = entry.uncompressed_size
534
+ return unless expected_size&.positive?
535
+ return if data.bytesize == expected_size
536
+
537
+ raise Error, 'size mismatch after decompression'
538
+ end
539
+
540
+ def register_with_limits(limits)
541
+ limits.register_uncompressed_bytes(entry, data.bytesize)
542
+ encode_as_binary
543
+ end
544
+
545
+ def finalize_and_register(limits)
546
+ verify_size
547
+ register_with_limits(limits)
548
+ end
549
+
550
+ private
551
+
552
+ def encode_as_binary
553
+ data.force_encoding(Encoding::BINARY)
554
+ end
555
+ end
556
+
557
+ # Encapsulates ZIP file state
558
+ class FileState
559
+ attr_reader :io, :entries, :limits
560
+
561
+ def initialize(path, limits)
562
+ @io = ::File.open(path, 'rb')
563
+ @entries = {}
564
+ @limits = limits
565
+ @closed = false
566
+ end
567
+
568
+ def close
569
+ return if @closed
570
+
571
+ @io&.close
572
+ @closed = true
573
+ end
574
+
575
+ def closed?
576
+ @closed || !@io || @io.closed?
577
+ end
578
+ end
579
+
580
+ # Handles reading entry data from ZIP file
581
+ class EntryReader
582
+ def initialize(io, limits)
583
+ @io = io
584
+ @limits = limits
585
+ end
586
+
587
+ def read_entry(entry)
588
+ seek_to_entry_data(entry)
589
+ raw_data = read_entry_payload(entry)
590
+ decompressed = DecompressedData.new(entry, raw_data)
591
+ decompressed.finalize_and_register(@limits)
592
+ end
593
+
594
+ private
595
+
596
+ def seek_to_entry_data(entry)
597
+ @io.seek(entry.local_header_offset, ::IO::SEEK_SET)
598
+ verify_signature(Signatures::LOCAL_FILE, 'invalid local file header signature')
599
+ skip_local_file_header
600
+ end
601
+
602
+ def skip_local_file_header
603
+ header = read_exact(26, error_message: 'truncated local file header')
604
+ name_length, extra_length = LocalFileHeaderParser.extract_lengths(header)
605
+ @io.seek(name_length + extra_length, ::IO::SEEK_CUR)
606
+ end
607
+
608
+ def read_entry_payload(entry)
609
+ compression_method = entry.compression_method
610
+ case compression_method
611
+ when 0 then read_stored_entry(entry)
612
+ when 8 then decompress_deflated_entry(entry)
613
+ else raise Error, "unsupported compression method: #{compression_method}"
614
+ end
615
+ end
616
+
617
+ def read_stored_entry(entry)
618
+ compressed_size = entry.compressed_size
619
+ data = @io.read(compressed_size)
620
+ return data if data && data.bytesize == compressed_size
621
+
622
+ raise Error, 'truncated compressed data'
623
+ end
624
+
625
+ def decompress_deflated_entry(entry)
626
+ decompressor = EntryDecompressor.new(@io, @limits)
627
+ decompressor.inflate_deflated_entry(entry)
628
+ end
629
+
630
+ def verify_signature(expected_signature, error_message)
631
+ signature_bytes = @io.read(expected_signature.bytesize)
632
+ raise Error, error_message unless signature_bytes == expected_signature
633
+ end
634
+
635
+ def read_exact(byte_count, error_message:)
636
+ data = @io.read(byte_count)
637
+ return data if data && data.bytesize == byte_count
638
+
639
+ raise Error, error_message
640
+ end
641
+ end
642
+
643
+ # Read-only ZIP archive reader with explicit size safeguards.
644
+ class File
645
+ include IndexBuilder
646
+
647
+ def self.open(path, **)
648
+ zip_file = new(path, **)
649
+ return zip_file unless block_given?
650
+
651
+ begin
652
+ yield zip_file
653
+ ensure
654
+ close_safely(zip_file)
655
+ end
656
+ end
657
+
658
+ def self.close_safely(zip_file)
659
+ zip_file.close
660
+ rescue StandardError
661
+ # ignore close errors
662
+ end
663
+
664
+ def initialize(path,
665
+ max_entry_uncompressed_bytes: nil,
666
+ max_entry_compressed_bytes: nil,
667
+ max_total_uncompressed_bytes: nil)
668
+ limits = SizeLimits.new(
669
+ max_entry_uncompressed: max_entry_uncompressed_bytes,
670
+ max_entry_compressed: max_entry_compressed_bytes,
671
+ max_total_uncompressed: max_total_uncompressed_bytes
672
+ )
673
+ @state = FileState.new(path, limits)
674
+ @io = @state.io
675
+ @entries = @state.entries
676
+ build_index!
677
+ rescue StandardError
678
+ close
679
+ raise
680
+ end
681
+
682
+ def close
683
+ @state.close
684
+ end
685
+
686
+ def closed?
687
+ @state.closed?
688
+ end
689
+
690
+ def find_entry(path)
691
+ normalized_path = NameNormalizer.normalize(path)
692
+ @entries[normalized_path]
693
+ end
694
+
695
+ def read(path)
696
+ entry = find_entry_or_raise(path)
697
+ validate_entry_readable(entry)
698
+ limits = @state.limits
699
+ limits.enforce_entry_limits(entry, requested_name: path)
700
+ EntryReader.new(@state.io, limits).read_entry(entry)
701
+ end
702
+
703
+ private
704
+
705
+ def find_entry_or_raise(path)
706
+ entry = find_entry(path)
707
+ raise Error, "entry not found: #{path}" unless entry
708
+
709
+ entry
710
+ end
711
+
712
+ def validate_entry_readable(entry)
713
+ entry_name = entry.name
714
+ raise Error, "cannot read directory entry: #{entry_name}" if entry_name.end_with?('/')
715
+
716
+ gp_flags = entry.gp_flags.to_i
717
+ raise Error, "unsupported encrypted entry: #{entry_name}" if gp_flags.anybits?(0x1)
718
+ end
719
+
720
+ def verify_signature(expected_signature, error_message)
721
+ signature_bytes = @io.read(expected_signature.bytesize)
722
+ raise Error, error_message unless signature_bytes == expected_signature
723
+ end
724
+
725
+ def read_exact(byte_count, error_message:)
726
+ data = @io.read(byte_count)
727
+ return data if data && data.bytesize == byte_count
728
+
729
+ raise Error, error_message
730
+ end
731
+ end
732
+ end