bidi2pdf 0.1.14 → 0.1.16

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +69 -2
  4. data/README.md +258 -10
  5. data/docker/Dockerfile +4 -0
  6. data/docker/Dockerfile.slim +5 -1
  7. data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  9. data/lib/bidi2pdf/bidi/client.rb +12 -5
  10. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  11. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  12. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  13. data/lib/bidi2pdf/bidi/session.rb +1 -1
  14. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  15. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  16. data/lib/bidi2pdf/cli.rb +559 -17
  17. data/lib/bidi2pdf/diagnose.rb +119 -0
  18. data/lib/bidi2pdf/error_codes.rb +59 -0
  19. data/lib/bidi2pdf/exit_codes.rb +44 -0
  20. data/lib/bidi2pdf/launcher.rb +23 -0
  21. data/lib/bidi2pdf/manifest.rb +65 -0
  22. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  23. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  24. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  25. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  26. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  27. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  28. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  29. data/lib/bidi2pdf/recipe.rb +83 -0
  30. data/lib/bidi2pdf/result.rb +67 -0
  31. data/lib/bidi2pdf/result_collector.rb +150 -0
  32. data/lib/bidi2pdf/schema.rb +382 -0
  33. data/lib/bidi2pdf/session_runner.rb +42 -0
  34. data/lib/bidi2pdf/session_warmer.rb +377 -0
  35. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
  36. data/lib/bidi2pdf/version.rb +1 -1
  37. data/lib/bidi2pdf.rb +114 -9
  38. data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
  39. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  40. data/sig/bidi2pdf/bidi/client.rbs +10 -2
  41. data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
  42. data/sig/bidi2pdf/bidi/session.rbs +1 -1
  43. data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
  44. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  45. data/sig/bidi2pdf/cli.rbs +112 -2
  46. data/sig/bidi2pdf/diagnose.rbs +30 -0
  47. data/sig/bidi2pdf/error_codes.rbs +21 -0
  48. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  49. data/sig/bidi2pdf/launcher.rbs +9 -0
  50. data/sig/bidi2pdf/manifest.rbs +38 -0
  51. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  52. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  53. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  54. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  55. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  56. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  57. data/sig/bidi2pdf/recipe.rbs +72 -0
  58. data/sig/bidi2pdf/result.rbs +71 -0
  59. data/sig/bidi2pdf/result_collector.rbs +106 -0
  60. data/sig/bidi2pdf/schema.rbs +45 -0
  61. data/sig/bidi2pdf/session_runner.rbs +17 -0
  62. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  63. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
  64. data/sig/bidi2pdf/version.rbs +1 -1
  65. data/sig/bidi2pdf.rbs +84 -0
  66. data/tasks/release_credentials_check.rake +97 -0
  67. metadata +39 -4
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bidi2pdf
6
+ # Collects the render-focused diagnostics behind `bidi2pdf diagnose`: why does the PDF not look
7
+ # like the page, not what the page's content is. Everything here reuses
8
+ # BrowserTab#execute_script - no DOM dump, no headings/links/forms extraction; console and
9
+ # network data come from the caller's own
10
+ # ResultCollector, the same source `render` uses, not from here.
11
+ #
12
+ # Each script returns (or resolves to) a JSON string, extracted via #dig("result", "value") -
13
+ # BiDi's script.evaluate already awaits a returned Promise (awaitPromise: true, see
14
+ # Commands::ScriptEvaluate), so document.fonts.ready needs no wrap_in_promise: wrapper.
15
+ class Diagnose
16
+ PAGE_SCRIPT = <<~JS
17
+ JSON.stringify({
18
+ title: document.title,
19
+ url: window.location.href,
20
+ lang: document.documentElement.lang || null
21
+ })
22
+ JS
23
+
24
+ FONTS_SCRIPT = <<~JS
25
+ document.fonts.ready.then(function () {
26
+ var loaded = [];
27
+ var failed = [];
28
+ document.fonts.forEach(function (face) {
29
+ var label = face.family + " " + face.weight + " " + face.style;
30
+ if (face.status === "error") { failed.push(label); } else { loaded.push(label); }
31
+ });
32
+ return JSON.stringify({ status: failed.length > 0 ? "partial" : "loaded", loaded: loaded, failed: failed });
33
+ })
34
+ JS
35
+
36
+ PRINT_MEDIA_SCRIPT = <<~JS
37
+ (function () {
38
+ var printSheets = [];
39
+ var pageRules = [];
40
+ var unreadable = [];
41
+
42
+ for (var i = 0; i < document.styleSheets.length; i++) {
43
+ var sheet = document.styleSheets[i];
44
+ try {
45
+ var rules = sheet.cssRules || sheet.rules;
46
+ for (var j = 0; j < rules.length; j++) {
47
+ var rule = rules[j];
48
+ if (rule.media && Array.prototype.includes.call(rule.media, "print")) {
49
+ printSheets.push(sheet.href || "(inline)");
50
+ }
51
+ if (typeof CSSRule !== "undefined" && rule.type === CSSRule.PAGE_RULE) {
52
+ pageRules.push(rule.cssText);
53
+ }
54
+ }
55
+ } catch (e) {
56
+ unreadable.push(sheet.href || "(inline)");
57
+ }
58
+ }
59
+
60
+ var fixedOrSticky = [];
61
+ var breakInsideAvoidCount = 0;
62
+ var elements = document.querySelectorAll("body *");
63
+ for (var k = 0; k < elements.length; k++) {
64
+ var el = elements[k];
65
+ var computed = window.getComputedStyle(el);
66
+ if (computed.position === "fixed" || computed.position === "sticky") {
67
+ var classPart = typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\\s+/).join(".") : "";
68
+ var selector = el.id ? "#" + el.id : el.tagName.toLowerCase() + classPart;
69
+ fixedOrSticky.push({ selector: selector, position: computed.position });
70
+ }
71
+ if (computed.breakInside === "avoid" || computed.pageBreakInside === "avoid") {
72
+ breakInsideAvoidCount++;
73
+ }
74
+ }
75
+
76
+ return JSON.stringify({
77
+ stylesheets_with_print_rules: Array.from(new Set(printSheets)),
78
+ page_rules: pageRules,
79
+ break_inside_avoid_count: breakInsideAvoidCount,
80
+ fixed_or_sticky_elements: fixedOrSticky,
81
+ unreadable_stylesheets: Array.from(new Set(unreadable))
82
+ });
83
+ })()
84
+ JS
85
+
86
+ PAGED_JS_SCRIPT = <<~JS
87
+ (function () {
88
+ var detected = !!(window.Paged || window.PagedPolyfill);
89
+ var pages = document.querySelectorAll(".pagedjs_page").length;
90
+ return JSON.stringify({ detected: detected, ready: pages > 0, pages: pages });
91
+ })()
92
+ JS
93
+
94
+ def initialize(tab:)
95
+ @tab = tab
96
+ end
97
+
98
+ def call
99
+ {
100
+ page: collect(PAGE_SCRIPT),
101
+ fonts: collect(FONTS_SCRIPT),
102
+ print_media: collect(PRINT_MEDIA_SCRIPT),
103
+ paged_js: collect(PAGED_JS_SCRIPT)
104
+ }
105
+ end
106
+
107
+ private
108
+
109
+ def collect(script)
110
+ response = @tab.execute_script(script)
111
+ return nil unless response.is_a?(Hash) && response["type"] == "success"
112
+
113
+ value = response.dig("result", "value")
114
+ value ? JSON.parse(value) : nil
115
+ rescue JSON::ParserError
116
+ nil
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ # Maps an exception to the stable, machine-readable code used by --json/--json-stream/manifests
5
+ # and recipe results. Derived from the existing Bidi2pdf::Error hierarchy rather than duplicated
6
+ # beside it: a new failure mode gets a new subclass in lib/bidi2pdf.rb and a row here, never a
7
+ # bare string minted ad hoc.
8
+ #
9
+ # MAPPING is ordered most-specific-subclass first - #for walks it in order and returns the first
10
+ # match, so a subclass must appear before any of its ancestors.
11
+ module ErrorCodes
12
+ MAPPING = {
13
+ MissingInputError => "MISSING_INPUT",
14
+ MultipleInputSourcesError => "MULTIPLE_INPUT_SOURCES",
15
+ EmptyInputError => "EMPTY_INPUT",
16
+ InvalidConfigError => "INVALID_CONFIG",
17
+ InvalidPrintOptionError => "INVALID_PRINT_OPTION",
18
+ InvalidRecipeError => "INVALID_RECIPE",
19
+ PdfInspectionUnavailableError => "PDF_INSPECTION_UNAVAILABLE",
20
+ SessionNotStartedError => "BROWSER_LAUNCH_FAILED",
21
+ CmdTimeoutError => "COMMAND_TIMEOUT",
22
+ WebsocketError => "BROWSER_DISCONNECTED",
23
+ NavigationTimeoutError => "NAVIGATION_TIMEOUT",
24
+ NavigationAuthError => "NAVIGATION_AUTH",
25
+ NavigationNotFoundError => "NAVIGATION_NOT_FOUND",
26
+ NavigationDNSError => "DNS_ERROR",
27
+ NavigationError => "NAVIGATION_FAILED",
28
+ SelectorNotFoundError => "SELECTOR_NOT_FOUND",
29
+ PageNotAsExpectedError => "PAGE_NOT_AS_EXPECTED",
30
+ ScriptInjectionError => "SCRIPT_ERROR",
31
+ StyleInjectionError => "STYLE_ERROR",
32
+ PrintError => "PDF_GENERATION_FAILED",
33
+ ScreenshotError => "SCREENSHOT_FAILED",
34
+ OutputWriteError => "OUTPUT_WRITE_FAILED",
35
+ Error => "INTERNAL_ERROR"
36
+ }.freeze
37
+
38
+ DEFAULT_CODE = "INTERNAL_ERROR"
39
+
40
+ def self.for(exception)
41
+ _klass, code = MAPPING.find { |klass, _code| exception.is_a?(klass) }
42
+
43
+ code || DEFAULT_CODE
44
+ end
45
+
46
+ # Builds the {code:, message:, retryable:, hint:, details:} hash used as Result#error and in
47
+ # recipe action/assertion failures. Works for any StandardError, not only a Bidi2pdf::Error -
48
+ # an unexpected exception still gets INTERNAL_ERROR with retryable/hint defaulted safely.
49
+ def self.describe(exception, details: {})
50
+ {
51
+ code: self.for(exception),
52
+ message: exception.message,
53
+ retryable: exception.respond_to?(:retryable?) ? exception.retryable? : false,
54
+ hint: exception.respond_to?(:hint) ? exception.hint : nil,
55
+ details: exception.respond_to?(:details) && !exception.details.empty? ? exception.details : details
56
+ }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ # Maps an ErrorCodes string to the process exit status the CLI reports. Centralized here so
5
+ # render/diagnose/run share one table instead of each re-deciding what a given failure is worth.
6
+ module ExitCodes
7
+ SUCCESS = 0
8
+ CLI_ERROR = 2
9
+ BROWSER_ERROR = 3
10
+ PAGE_NOT_AS_EXPECTED = 4
11
+ OUTPUT_ERROR = 6
12
+ INTERNAL_ERROR = 70
13
+
14
+ CODE_TO_EXIT = {
15
+ "MISSING_INPUT" => CLI_ERROR,
16
+ "MULTIPLE_INPUT_SOURCES" => CLI_ERROR,
17
+ "EMPTY_INPUT" => CLI_ERROR,
18
+ "INVALID_CONFIG" => CLI_ERROR,
19
+ "INVALID_PRINT_OPTION" => CLI_ERROR,
20
+ "INVALID_RECIPE" => CLI_ERROR,
21
+ "PDF_INSPECTION_UNAVAILABLE" => CLI_ERROR,
22
+ "BROWSER_LAUNCH_FAILED" => BROWSER_ERROR,
23
+ "BROWSER_DISCONNECTED" => BROWSER_ERROR,
24
+ "COMMAND_TIMEOUT" => BROWSER_ERROR,
25
+ "NAVIGATION_FAILED" => BROWSER_ERROR,
26
+ "NAVIGATION_TIMEOUT" => BROWSER_ERROR,
27
+ "NAVIGATION_AUTH" => BROWSER_ERROR,
28
+ "NAVIGATION_NOT_FOUND" => BROWSER_ERROR,
29
+ "DNS_ERROR" => BROWSER_ERROR,
30
+ "SELECTOR_NOT_FOUND" => PAGE_NOT_AS_EXPECTED,
31
+ "PAGE_NOT_AS_EXPECTED" => PAGE_NOT_AS_EXPECTED,
32
+ "SCRIPT_ERROR" => PAGE_NOT_AS_EXPECTED,
33
+ "STYLE_ERROR" => PAGE_NOT_AS_EXPECTED,
34
+ "PDF_GENERATION_FAILED" => OUTPUT_ERROR,
35
+ "SCREENSHOT_FAILED" => OUTPUT_ERROR,
36
+ "OUTPUT_WRITE_FAILED" => OUTPUT_ERROR,
37
+ "INTERNAL_ERROR" => INTERNAL_ERROR
38
+ }.freeze
39
+
40
+ def self.for(code)
41
+ CODE_TO_EXIT.fetch(code, INTERNAL_ERROR)
42
+ end
43
+ end
44
+ end
@@ -77,7 +77,30 @@ module Bidi2pdf
77
77
  runner.run
78
78
  end
79
79
 
80
+ # Like #launch, but navigates only (see SessionRunner#run_diagnose) and leaves the tab open -
81
+ # used by `bidi2pdf diagnose`, which has no PDF to produce. #stop closes it, same as it stops
82
+ # everything else #launch/#diagnose started.
83
+ #
84
+ # @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
85
+ def diagnose
86
+ @diagnose_runner = SessionRunner.new(
87
+ session: session,
88
+ url: @url,
89
+ inputfile: @inputfile,
90
+ output: @output,
91
+ cookies: @cookies,
92
+ headers: @headers,
93
+ auth: @auth,
94
+ wait_window_loaded: @wait_window_loaded,
95
+ wait_network_idle: @wait_network_idle,
96
+ print_options: @print_options,
97
+ network_log_format: @network_log_format
98
+ )
99
+ @diagnose_runner.run_diagnose
100
+ end
101
+
80
102
  def stop
103
+ @diagnose_runner&.close_all
81
104
  @manager&.stop
82
105
  @custom_session&.close
83
106
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Bidi2pdf
6
+ # Builds the render manifest (--manifest FILE) from a completed Bidi2pdf::Result plus the
7
+ # input/browser/header context around it. Redacts secret-
8
+ # bearing headers and never records cookie values at all.
9
+ class Manifest
10
+ SCHEMA_VERSION = 1
11
+
12
+ REDACT_HEADER_NAMES = %w[authorization proxy-authorization cookie set-cookie x-api-key api-key].freeze
13
+ SENSITIVE_SUBSTRINGS = %w[token secret password authorization api-key apikey].freeze
14
+
15
+ def initialize(result:, input:, headers: {}, navigation_duration_ms: nil, browser: {})
16
+ @result = result
17
+ @input = input
18
+ @headers = headers
19
+ @navigation_duration_ms = navigation_duration_ms
20
+ @browser = browser
21
+ end
22
+
23
+ def self.redact(headers)
24
+ headers.to_h { |key, value| [key, sensitive?(key) ? "[REDACTED]" : value] }
25
+ end
26
+
27
+ def self.sensitive?(key)
28
+ normalized = key.to_s.downcase
29
+
30
+ REDACT_HEADER_NAMES.include?(normalized) || SENSITIVE_SUBSTRINGS.any? { |substring| normalized.include?(substring) }
31
+ end
32
+
33
+ def to_h
34
+ base = {
35
+ schema_version: SCHEMA_VERSION,
36
+ bidi2pdf_version: Bidi2pdf::VERSION,
37
+ created_at: Time.now.utc.iso8601,
38
+ input: @input,
39
+ output: output_section,
40
+ browser: @browser,
41
+ navigation: navigation_section,
42
+ render: { duration_ms: @result.duration_ms },
43
+ console: @result.console,
44
+ network_failures: @result.network_failures,
45
+ warnings: @result.warnings
46
+ }
47
+
48
+ @headers.empty? ? base : base.merge(headers: self.class.redact(@headers))
49
+ end
50
+
51
+ def to_json(*)
52
+ to_h.to_json(*)
53
+ end
54
+
55
+ private
56
+
57
+ def output_section
58
+ { path: @result.output, pages: @result.pages, bytes: @result.bytes, sha256: @result.sha256 }
59
+ end
60
+
61
+ def navigation_section
62
+ (@result.navigation || {}).merge(duration_ms: @navigation_duration_ms)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bidi2pdf
6
+ module Notifications
7
+ # Emits one JSON object per line to an IO (stderr for --json-stream, docs/specs/llm-friendly-
8
+ # spec.md section 7) as a render progresses, by subscribing to the same Bidi2pdf::Notifications
9
+ # events Bidi2pdf::ResultCollector and LoggingSubscriber already use - no new instrumentation
10
+ # points needed. #emit_result writes the final "result" event once the caller has a Result.
11
+ class JsonSubscriber
12
+ SCHEMA_VERSION = 1
13
+
14
+ def initialize(io: $stderr)
15
+ @io = io
16
+ @start = now_ms
17
+ subscribe
18
+ end
19
+
20
+ def emit_result(result)
21
+ write(event: "result", result: result.to_h)
22
+ end
23
+
24
+ def unsubscribe
25
+ Array(@registered).each { |pattern, block| Bidi2pdf.notification_service.unsubscribe(pattern, block) }
26
+ end
27
+
28
+ private
29
+
30
+ def now_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
31
+
32
+ def elapsed_ms = (now_ms - @start).round
33
+
34
+ def subscribe
35
+ handlers = {
36
+ "navigate_to.bidi2pdf" => method(:on_navigate),
37
+ "render_html_content.bidi2pdf" => method(:on_navigate),
38
+ "page_loaded.bidi2pdf" => method(:on_page_loaded),
39
+ "network_idle.bidi2pdf" => method(:on_network_idle),
40
+ "browser_console_log_received.bidi2pdf" => method(:on_console),
41
+ "print.bidi2pdf" => method(:on_print),
42
+ "screenshot.bidi2pdf" => method(:on_screenshot)
43
+ }
44
+
45
+ @registered = handlers.map { |pattern, handler| [pattern, Bidi2pdf.notification_service.subscribe(pattern, &handler)] }
46
+ end
47
+
48
+ def on_navigate(event)
49
+ write(event: "navigate", url: event.payload[:url])
50
+ end
51
+
52
+ def on_page_loaded(_event)
53
+ write(event: "page_loaded")
54
+ end
55
+
56
+ def on_network_idle(_event)
57
+ write(event: "network_idle")
58
+ end
59
+
60
+ def on_console(event)
61
+ payload = event.payload
62
+ write(event: "console", level: payload[:level], text: payload[:text])
63
+ end
64
+
65
+ def on_print(_event)
66
+ write(event: "print")
67
+ end
68
+
69
+ def on_screenshot(_event)
70
+ write(event: "screenshot")
71
+ end
72
+
73
+ def write(fields)
74
+ @io.puts({ schema_version: SCHEMA_VERSION, t_ms: elapsed_ms }.merge(fields).to_json)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -95,6 +95,8 @@ module Bidi2pdf
95
95
  end
96
96
  when Array
97
97
  obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
98
+ when String
99
+ Bidi2pdf.truncate_for_log(obj)
98
100
  else
99
101
  obj
100
102
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Bidi2pdf
6
+ # Lazy, optional wrapper around the pdf-reader gem. pdf-reader is deliberately NOT a bidi2pdf
7
+ # runtime dependency - render works fine without it,
8
+ # just with `pages` left nil and a warning, and a recipe assertion that needs it fails
9
+ # INVALID_RECIPE at validation time rather than crashing mid-render. The official Docker images
10
+ # (docker/Dockerfile, docker/Dockerfile.slim) install it, so a render inside one of them - the
11
+ # environment an agent or CI job actually uses - always has page counts and PDF assertions
12
+ # available without anyone opting in by hand.
13
+ module PdfInspection
14
+ class << self
15
+ def available?
16
+ return @available if defined?(@available)
17
+
18
+ @available = load_pdf_reader
19
+ end
20
+
21
+ # @param bytes [String, nil] raw PDF bytes.
22
+ # @return [Integer, nil] page count, or nil when pdf-reader is unavailable, bytes is nil,
23
+ # or the PDF is malformed.
24
+ def page_count(bytes)
25
+ return nil unless bytes && available?
26
+
27
+ reader_for(bytes).page_count
28
+ rescue PDF::Reader::MalformedPDFError
29
+ nil
30
+ end
31
+
32
+ # @param bytes [String, nil] raw PDF bytes.
33
+ # @return [String, nil] extracted text across all pages, joined with newlines.
34
+ def text(bytes)
35
+ return nil unless bytes && available?
36
+
37
+ reader_for(bytes).pages.map(&:text).join("\n")
38
+ rescue PDF::Reader::MalformedPDFError
39
+ nil
40
+ end
41
+
42
+ # Clears the memoized availability check - test-only, so a spec can exercise both branches
43
+ # of #available? regardless of load order elsewhere in the process.
44
+ def reset_for_testing!
45
+ remove_instance_variable(:@available) if defined?(@available)
46
+ end
47
+
48
+ private
49
+
50
+ def load_pdf_reader
51
+ require "pdf-reader"
52
+ true
53
+ rescue LoadError
54
+ false
55
+ end
56
+
57
+ def reader_for(bytes)
58
+ PDF::Reader.new(StringIO.new(bytes))
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+
6
+ module Bidi2pdf
7
+ class Recipe
8
+ # Reads a recipe file (YAML, or JSON when the extension is .json) into a plain Hash. Never
9
+ # launches a browser - a malformed file fails right here.
10
+ #
11
+ # Security: YAML is parsed with Psych.safe_load and no extra options. Its defaults already
12
+ # refuse both custom type tags (no arbitrary Ruby object instantiation) and aliases - the
13
+ # latter matters concretely: a small file using nested YAML anchors/aliases ("billion laughs")
14
+ # can expand to millions of elements in memory; confirmed live, a 273-byte such file produced
15
+ # 10 million array elements in under 2ms. Neither aliases nor a Date/Time value are used by any
16
+ # field a recipe defines, so neither is turned back on "just in case" - only what the format
17
+ # actually needs is permitted. MAX_BYTES bounds the file itself before it is even parsed.
18
+ module Loader
19
+ MAX_BYTES = 1_048_576 # 1 MiB - generous for a recipe file, small enough to bound a huge one.
20
+
21
+ # rubocop:disable-next Metrics/AbcSize
22
+ def self.load(path)
23
+ raise Bidi2pdf::InvalidRecipeError.new("Recipe file not found: #{path}", details: { path: path }) unless File.exist?(path)
24
+
25
+ raise Bidi2pdf::InvalidRecipeError.new("Recipe file '#{path}' is too large (max #{MAX_BYTES} bytes)", details: { path: path }) if File.size(path) > MAX_BYTES
26
+
27
+ content = File.read(path)
28
+ parsed = path.end_with?(".json") ? JSON.parse(content) : YAML.safe_load(content)
29
+
30
+ raise Bidi2pdf::InvalidRecipeError.new("Recipe must be a mapping at the top level", details: { path: "." }) unless parsed.is_a?(Hash)
31
+
32
+ parsed
33
+ rescue Psych::Exception, JSON::ParserError => e
34
+ # Psych::Exception covers SyntaxError, AliasesNotEnabled and DisallowedClass alike, so a
35
+ # recipe attempting to use any of the features safe_load refuses fails the same clean way
36
+ # a syntax error would, not with an unhandled exception. The message is truncated the same
37
+ # way Bidi2pdf.truncate_for_log bounds any other value that can hold arbitrary input -
38
+ # a recipe may carry credentials (basic auth, headers), and a parser error's own message
39
+ # can otherwise quote a slice of the offending content straight back.
40
+ raise Bidi2pdf::InvalidRecipeError.new("Could not parse recipe '#{path}': #{Bidi2pdf.truncate_for_log(e.message)}", details: { path: path })
41
+ end
42
+ end
43
+ end
44
+ end