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.
- checksums.yaml +7 -0
- data/.bundle/config +4 -0
- data/.bundle/config.bak +3 -0
- data/.rspec_status +42 -0
- data/.rubocop.yml +124 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/Rakefile +29 -0
- data/bin/start +15 -0
- data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
- data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
- data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
- data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
- data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
- data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
- data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
- data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
- data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
- data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
- data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
- data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
- data/lib/shoko/adapters/input/command_bridge.rb +148 -0
- data/lib/shoko/adapters/input/command_factory.rb +255 -0
- data/lib/shoko/adapters/input/commands.rb +60 -0
- data/lib/shoko/adapters/input/dispatcher.rb +69 -0
- data/lib/shoko/adapters/input/input_controller.rb +250 -0
- data/lib/shoko/adapters/input/key_definitions.rb +108 -0
- data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
- data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
- data/lib/shoko/adapters/monitoring/logger.rb +150 -0
- data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
- data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
- data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
- data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
- data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
- data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
- data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
- data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
- data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
- data/lib/shoko/adapters/output/notification_service.rb +58 -0
- data/lib/shoko/adapters/output/render_registry.rb +45 -0
- data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
- data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
- data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
- data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
- data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
- data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
- data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
- data/lib/shoko/adapters/output/terminal/input.rb +161 -0
- data/lib/shoko/adapters/output/terminal/output.rb +105 -0
- data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
- data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
- data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
- data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
- data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
- data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
- data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
- data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
- data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
- data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
- data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
- data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
- data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
- data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
- data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
- data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
- data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
- data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
- data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
- data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
- data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
- data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
- data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
- data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
- data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
- data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
- data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
- data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
- data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
- data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
- data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
- data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
- data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
- data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
- data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
- data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
- data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
- data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
- data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
- data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
- data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
- data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
- data/lib/shoko/adapters/storage/background_worker.rb +66 -0
- data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
- data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
- data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
- data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
- data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
- data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
- data/lib/shoko/adapters/storage/config_paths.rb +30 -0
- data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
- data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
- data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
- data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
- data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
- data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
- data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
- data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
- data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
- data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
- data/lib/shoko/adapters/storage/recent_files.rb +78 -0
- data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
- data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
- data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
- data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
- data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
- data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
- data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
- data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
- data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
- data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
- data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
- data/lib/shoko/application/cli.rb +134 -0
- data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
- data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
- data/lib/shoko/application/controllers/menu_controller.rb +469 -0
- data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
- data/lib/shoko/application/controllers/reader_controller.rb +449 -0
- data/lib/shoko/application/controllers/state_controller.rb +410 -0
- data/lib/shoko/application/controllers/ui_controller.rb +782 -0
- data/lib/shoko/application/dependency_container.rb +301 -0
- data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
- data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
- data/lib/shoko/application/infrastructure/state_store.rb +413 -0
- data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
- data/lib/shoko/application/pending_jump_handler.rb +122 -0
- data/lib/shoko/application/reader_lifecycle.rb +65 -0
- data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
- data/lib/shoko/application/selectors/config_selectors.rb +62 -0
- data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
- data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
- data/lib/shoko/application/state/actions/base_action.rb +24 -0
- data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
- data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
- data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
- data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
- data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
- data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
- data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
- data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
- data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
- data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
- data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
- data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
- data/lib/shoko/application/unified_application.rb +48 -0
- data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
- data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
- data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
- data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
- data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
- data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
- data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
- data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
- data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
- data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
- data/lib/shoko/application/use_cases/settings_service.rb +123 -0
- data/lib/shoko/core/events/annotation_events.rb +94 -0
- data/lib/shoko/core/events/base_domain_event.rb +169 -0
- data/lib/shoko/core/events/bookmark_events.rb +41 -0
- data/lib/shoko/core/events/domain_event_bus.rb +163 -0
- data/lib/shoko/core/events/progress_events.rb +108 -0
- data/lib/shoko/core/models/bookmark.rb +36 -0
- data/lib/shoko/core/models/bookmark_data.rb +10 -0
- data/lib/shoko/core/models/chapter.rb +25 -0
- data/lib/shoko/core/models/content_block.rb +44 -0
- data/lib/shoko/core/models/reader_settings.rb +20 -0
- data/lib/shoko/core/models/selection_anchor.rb +73 -0
- data/lib/shoko/core/models/toc_entry.rb +14 -0
- data/lib/shoko/core/ports/annotation_repository.rb +0 -0
- data/lib/shoko/core/ports/book_repository.rb +0 -0
- data/lib/shoko/core/ports/book_source.rb +0 -0
- data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
- data/lib/shoko/core/ports/cache.rb +0 -0
- data/lib/shoko/core/ports/input_handler.rb +0 -0
- data/lib/shoko/core/ports/renderer.rb +0 -0
- data/lib/shoko/core/ports/storage.rb +0 -0
- data/lib/shoko/core/services/annotation_service.rb +102 -0
- data/lib/shoko/core/services/base_service.rb +60 -0
- data/lib/shoko/core/services/bookmark_service.rb +267 -0
- data/lib/shoko/core/services/coordinate_service.rb +265 -0
- data/lib/shoko/core/services/layout_service.rb +95 -0
- data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
- data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
- data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
- data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
- data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
- data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
- data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
- data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
- data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
- data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
- data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
- data/lib/shoko/core/services/navigation_service.rb +150 -0
- data/lib/shoko/core/services/page_calculator_service.rb +242 -0
- data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
- data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
- data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
- data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
- data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
- data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
- data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
- data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
- data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
- data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
- data/lib/shoko/core/services/pagination.rb +10 -0
- data/lib/shoko/core/services/progress_helper.rb +22 -0
- data/lib/shoko/core/services/selection_service.rb +126 -0
- data/lib/shoko/core/validator.rb +76 -0
- data/lib/shoko/shared/errors.rb +97 -0
- data/lib/shoko/shared/version.rb +5 -0
- data/lib/shoko/test_support/terminal_double.rb +175 -0
- data/lib/shoko/test_support/test_mode.rb +78 -0
- data/lib/shoko.rb +279 -0
- data/lib/zip.rb +732 -0
- data/zip.rb +5 -0
- 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
|